diff --git a/ui/jstests/listing/test_import_status.jsx b/ui/jstests/listing/test_import_status.jsx new file mode 100644 index 00000000..3215d959 --- /dev/null +++ b/ui/jstests/listing/test_import_status.jsx @@ -0,0 +1,235 @@ +define(['QUnit', 'jquery', 'import_status', 'react', + 'test_utils'], + function (QUnit, $, ImportStatus, React, TestUtils) { + 'use strict'; + + var waitForAjax = TestUtils.waitForAjax; + + var TASK1_SUCCESS = { + "id": "task1", + "status": "success", + "task_type": "import_course", + "task_info": { + "repo_slug": "repo" + } + }; + var OTHER_REPO_FAILURE = { + "id": "task2", + "status": "failure", + "task_type": "import_course", + "task_info": { + "repo_slug": "otherrepo" + } + }; + var NOT_IMPORT_SUCCESS = { + "id": "task4", + "status": "success", + "task_type": "reindex" + }; + var TASK3_PROCESSING = { + "id": "task3", + "status": "processing", + "task_type": "import_course", + "task_info": { + "repo_slug": "repo" + } + }; + var TASK3_SUCCESS = { + "id": "task3", + "status": "success", + "task_type": "import_course", + "task_info": { + "repo_slug": "repo" + } + }; + var TASK3_FAILURE = { + "id": "task3", + "status": "failure", + "task_type": "import_course", + "task_info": { + "repo_slug": "repo" + }, + "result": { + "error": "Task 3 failed" + } + }; + + var makeCollectionResult = function(items) { + return { + "count": items.length, + "next": null, + "previous": null, + "results": items + }; + }; + + QUnit.module('Test import status', { + beforeEach: function () { + TestUtils.setup(); + + TestUtils.initMockjax({ + url: '/api/v1/tasks/', + type: 'GET', + responseText: makeCollectionResult([ + OTHER_REPO_FAILURE, + NOT_IMPORT_SUCCESS, + TASK3_PROCESSING + ]) + }); + + TestUtils.initMockjax({ + url: '/api/v1/tasks/task3/', + type: 'DELETE' + }); + }, + afterEach: function() { + TestUtils.cleanup(); + } + }); + + QUnit.test('Assert that existing task is deleted', + function(assert) { + var done = assert.async(); + var refreshCount = 0; + var refreshFromAPI = function() { + refreshCount++; + }; + TestUtils.replaceMockjax({ + url: '/api/v1/tasks/', + type: 'GET', + responseText: makeCollectionResult([ + TASK1_SUCCESS + ]) + }); + TestUtils.initMockjax({ + url: '/api/v1/tasks/task1/', + type: 'DELETE' + }); + + var afterMount = function(component) { + waitForAjax(2, function () { + // If we get 2 AJAX calls one of them should be a DELETE. + assert.deepEqual(component.state, { + "importTasks": { + task1: TASK1_SUCCESS + }, + "hasRefreshed": false + }); + + done(); + }); + }; + + React.addons.TestUtils.renderIntoDocument( + + ); + } + ); + + QUnit.test('Assert successful import status', + function(assert) { + var done = assert.async(); + + var refreshCount = 0; + var refreshFromAPI = function() { + refreshCount++; + }; + + var afterMount = function(component) { + waitForAjax(1, function() { + assert.deepEqual(component.state, { + "importTasks": { + "task3": TASK3_PROCESSING + }, + "hasRefreshed": true + }); + + TestUtils.replaceMockjax({ + url: '/api/v1/tasks/', + type: 'GET', + responseText: makeCollectionResult([ + OTHER_REPO_FAILURE, + NOT_IMPORT_SUCCESS, + TASK3_SUCCESS + ]) + }); + + waitForAjax(2, function() { + // One ajax call to get tasks, one to delete. + assert.deepEqual(component.state, { + importTasks: { + task3: TASK3_SUCCESS + }, + hasRefreshed: true + }); + done(); + }); + }); + }; + + React.addons.TestUtils.renderIntoDocument( + + ); + } + ); + + QUnit.test('Assert import status of failure', + function(assert) { + var done = assert.async(); + + var refreshCount = 0; + var refreshFromAPI = function() { + refreshCount++; + }; + + var afterMount = function(component) { + waitForAjax(1, function() { + assert.deepEqual(component.state, { + "importTasks": { + "task3": TASK3_PROCESSING + }, + "hasRefreshed": true + }); + + TestUtils.replaceMockjax({ + url: '/api/v1/tasks/', + type: 'GET', + responseText: makeCollectionResult([ + OTHER_REPO_FAILURE, + NOT_IMPORT_SUCCESS, + TASK3_FAILURE + ]) + }); + + waitForAjax(2, function() { + // One ajax call to get tasks, one to delete. + assert.deepEqual(component.state, { + importTasks: { + task3: TASK3_FAILURE + }, + hasRefreshed: true + }); + done(); + }); + }); + }; + + React.addons.TestUtils.renderIntoDocument( + + ); + } + ); + } +); diff --git a/ui/static/ui/js/listing/import_status.jsx b/ui/static/ui/js/listing/import_status.jsx new file mode 100644 index 00000000..58c808e5 --- /dev/null +++ b/ui/static/ui/js/listing/import_status.jsx @@ -0,0 +1,130 @@ +define("import_status", ["React", "lodash", "jquery", "utils", + "react_spinner", "status_box"], + function (React, _, $, Utils, ReactSpinner, StatusBox) { + 'use strict'; + + return React.createClass({ + getInitialState: function () { + return { + importTasks: {}, + hasRefreshed: false + }; + }, + updateImportStatus: function () { + var thiz = this; + + Utils.getCollection("/api/v1/tasks/").then(function (tasks) { + // Only deal with tasks on this repo that deal with imports. + + tasks = _.filter(tasks, function(task) { + return (task.task_type === 'import_course' && + task.task_info.repo_slug === thiz.props.repoSlug); + }); + var tasksMap = {}; + _.each(tasks, function (task) { + tasksMap[task.id] = task; + }); + thiz.setState({importTasks: tasksMap}); + + var notProcessing = _.filter(tasks, function (task) { + return task.status === 'success' || task.status === 'failure'; + }); + + var deferred = $.Deferred(); + + // Delete one after another to avoid race conditions with + // Django session. + _.each(notProcessing, function (task) { + deferred.then(function () { + return $.ajax({ + method: 'DELETE', + url: '/api/v1/tasks/' + task.id + '/' + }); + }); + }); + deferred.resolve(); + + var numProcessing = _.filter(tasks, function (task) { + return task.status === 'processing'; + }).length; + if (numProcessing > 0) { + thiz.setState({hasRefreshed: true}); + setTimeout(thiz.updateImportStatus, 3000); + } else { + if (thiz.state.hasRefreshed) { + // There were tasks and they're finished now so we should refresh. + thiz.props.refreshFromAPI(); + } + } + }); + }, + componentDidMount: function () { + this.updateImportStatus(); + }, + render: function () { + var numSuccessful = _.filter(this.state.importTasks, function (task) { + return task.status === 'success'; + }).length; + var numProcessing = _.filter(this.state.importTasks, function (task) { + return task.status === 'processing'; + }).length; + + var importWord = function (n) { + if (n === 1) { + return "import"; + } else { + return "imports"; + } + }; + + var successMessage; + if (numSuccessful !== 0) { + successMessage = ; + } + var progressMessage; + if (numProcessing !== 0) { + // Use a table for vertical centering + progressMessage = + + + + +
+ {numProcessing + ' ' + importWord(numProcessing) + ' processing'} + + +
; + } + + var failed = _.filter(this.state.importTasks, function (task) { + return task.status === 'failure'; + }); + var errorMessages = []; + _.each(failed, function (task) { + errorMessages.push( +

+ {"Import failed: " + task.result.error} +

+ ); + }); + + var errorMessage; + if (errorMessages.length > 0) { + errorMessage = ; + } + + return + {successMessage} + {errorMessage} + {progressMessage} + ; + } + }); + } +); diff --git a/ui/static/ui/js/listing/listing.jsx b/ui/static/ui/js/listing/listing.jsx index a997bda1..f0773422 100644 --- a/ui/static/ui/js/listing/listing.jsx +++ b/ui/static/ui/js/listing/listing.jsx @@ -1,11 +1,11 @@ define('listing', ['csrf', 'react', 'jquery', 'lodash', 'uri', 'history', 'manage_taxonomies', 'learning_resources', 'static_assets', 'utils', - 'lr_exports', 'listing_resources', 'xml_panel', + 'lr_exports', 'listing_resources', 'xml_panel', 'import_status', 'bootstrap', 'icheck'], function (CSRF, React, $, _, URI, History, ManageTaxonomies, LearningResources, StaticAssets, - Utils, Exports, ListingResources, XmlPanel) { + Utils, Exports, ListingResources, XmlPanel, ImportStatus) { 'use strict'; var EMAIL_EXTENSION = '@mit.edu'; @@ -184,7 +184,13 @@ define('listing', facetCounts: this.state.facetCounts, ref: "listingResources" }; - return React.createElement(ListingResources.ListingPage, options); + return React.DOM.div(null, + React.createElement(ImportStatus, { + repoSlug: this.props.repoSlug, + refreshFromAPI: this.refreshFromAPI + }), + React.createElement(ListingResources.ListingPage, options) + ); }, /** * Clears exports on page. Assumes DELETE to clear on server already diff --git a/ui/static/ui/js/require_config.js b/ui/static/ui/js/require_config.js index 1d95dbf6..618da37e 100644 --- a/ui/static/ui/js/require_config.js +++ b/ui/static/ui/js/require_config.js @@ -22,6 +22,7 @@ var REQUIRE_PATHS = { listing: "../ui/js/listing/listing.jsx?noext", listing_resources: "../ui/js/listing/listing_resources.jsx?noext", pagination: "../ui/js/listing/pagination.jsx?noext", + import_status: "../ui/js/listing/import_status.jsx?noext", add_terms_component: "../ui/js/taxonomy/add_terms_component.jsx?noext", add_vocabulary: "../ui/js/taxonomy/add_vocabulary.jsx?noext", diff --git a/ui/templates/repository.html b/ui/templates/repository.html index f89ad86c..4f58d774 100644 --- a/ui/templates/repository.html +++ b/ui/templates/repository.html @@ -85,6 +85,9 @@ +