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 @@
+