Skip to content
Snippets Groups Projects
Commit e8022c74 authored by David Baumgold's avatar David Baumgold
Browse files

Merge pull request #8169 from AkA84/TNL-925-import-module-amendments

TNL-925: Import module amendments
parents bb0180d3 f8dc82c3
No related merge requests found
define([
'js/views/import', 'jquery', 'gettext', 'jquery.fileupload', 'jquery.cookie'
], function(Import, $, gettext) {
'domReady', 'js/views/import', 'jquery', 'gettext', 'jquery.fileupload', 'jquery.cookie'
], function(domReady, Import, $, gettext) {
'use strict';
return function (feedbackUrl, library) {
var dbError;
if (library) {
dbError = gettext('There was an error while importing the new library to our database.');
} else {
dbError = gettext('There was an error while importing the new course to our database.');
}
var bar = $('.progress-bar'),
fill = $('.progress-fill'),
submitBtn = $('.submit-button'),
chooseBtn = $('.choose-file-button'),
chooseBtn = $('.view-import .choose-file-button'),
defaults = [
gettext('There was an error during the upload process.') + '\n',
gettext('There was an error while unpacking the file.') + '\n',
gettext('There was an error while verifying the file you submitted.') + '\n',
dbError + '\n'
],
// Display the status of last file upload on page load
lastFileUpload = $.cookie('lastfileupload'),
unloading = false,
previousImport = Import.storedImport(),
file;
if (lastFileUpload){
Import.getAndStartUploadFeedback(feedbackUrl.replace('fillerName', lastFileUpload), lastFileUpload);
var onComplete = function () {
bar.hide();
chooseBtn
.find('.copy').html(gettext("Choose new file")).end()
.show();
}
$(window).on('beforeunload', function (event) { unloading = true; });
// Display the status of last file upload on page load
if (previousImport) {
$('.file-name-block')
.find('.file-name').html(previousImport.file.name).end()
.show();
if (previousImport.completed !== true) {
chooseBtn.hide();
}
Import.resume().then(onComplete);
}
$('#fileupload').fileupload({
......@@ -33,39 +55,48 @@ define([
maxChunkSize: 20 * 1000000, // 20 MB
autoUpload: false,
add: function(e, data) {
Import.clearImportDisplay();
Import.okayToNavigateAway = false;
Import.reset();
submitBtn.unbind('click');
file = data.files[0];
if (file.name.match(/tar\.gz$/)) {
submitBtn.click(function(event){
submitBtn.click(function(event) {
event.preventDefault();
$.cookie('lastfileupload', file.name);
Import.start(
file.name,
feedbackUrl.replace('fillerName', file.name)
).then(onComplete);
submitBtn.hide();
Import.startUploadFeedback();
data.submit().complete(function(result, textStatus, xhr) {
window.onbeforeunload = null;
if (xhr.status != 200) {
data.submit().complete(function (result, textStatus, xhr) {
if (xhr.status !== 200) {
var serverMsg, errMsg, stage;
try{
serverMsg = $.parseJSON(result.responseText);
serverMsg = $.parseJSON(result.responseText) || {};
} catch (e) {
return;
}
errMsg = serverMsg.hasOwnProperty('ErrMsg') ? serverMsg.ErrMsg : '' ;
errMsg = serverMsg.hasOwnProperty('ErrMsg') ? serverMsg.ErrMsg : '';
if (serverMsg.hasOwnProperty('Stage')) {
stage = Math.abs(serverMsg.Stage);
Import.stageError(stage, defaults[stage] + errMsg);
Import.cancel(defaults[stage] + errMsg, stage);
}
else {
// It could be that the user is simply refreshing the page
// so we need to be sure this is an actual error from the server
else if (!unloading) {
$(window).off('beforeunload.import');
Import.reset();
onComplete();
alert(gettext('Your import has failed.') + '\n\n' + errMsg);
}
chooseBtn.html(gettext('Choose new file')).show();
bar.hide();
}
Import.stopGetStatus = true;
chooseBtn.html(gettext('Choose new file')).show();
bar.hide();
});
});
} else {
......@@ -87,30 +118,45 @@ define([
}
if (percentInt >= doneAt) {
bar.hide();
// Start feedback with delay so that current stage of import properly updates in session
setTimeout(
function () { Import.startServerFeedback(feedbackUrl.replace('fillerName', file.name));},
3000
);
// Start feedback with delay so that current stage of
// import properly updates in session
setTimeout(function () { Import.pollStatus(); }, 3000);
} else {
bar.show();
fill.width(percentVal).html(percentVal);
}
},
done: function(event, data){
bar.hide();
window.onbeforeunload = null;
Import.displayFinishedImport();
},
start: function(event) {
window.onbeforeunload = function() {
if (!Import.okayToNavigateAway) {
return "${_('Your import is in progress; navigating away will abort it.')}";
}
};
},
sequentialUploads: true,
notifyOnError: false
});
var showImportSubmit = function (e) {
var filepath = $(this).val();
if (filepath.substr(filepath.length - 6, 6) === 'tar.gz') {
$('.error-block').hide();
$('.file-name').html($(this).val().replace('C:\\fakepath\\', ''));
$('.file-name-block').show();
chooseBtn.hide();
submitBtn.show();
$('.progress').show();
} else {
var msg = gettext('File format not supported. Please upload a file with a {file_extension} extension.')
.replace('{file_extension}', '<code>tar.gz</code>');
$('.error-block').html(msg).show();
}
};
domReady(function () {
// import form setup
$('.view-import .file-input').bind('change', showImportSubmit);
$('.view-import .choose-file-button, .view-import .choose-file-button-inline').bind('click', function (e) {
e.preventDefault();
$('.view-import .file-input').click();
});
});
};
});
......@@ -2,241 +2,327 @@
* Course import-related js.
*/
define(
["domReady", "jquery", "underscore", "gettext"],
function(domReady, $, _, gettext) {
["jquery", "underscore", "gettext", "moment", "jquery.cookie"],
function($, _, gettext, moment) {
"use strict";
/********** Private functions ************************************************/
/********** Private properties ****************************************/
var COOKIE_NAME = 'lastimportupload';
var STAGE = {
'UPLOADING': 0,
'UNPACKING': 1,
'VERIFYING': 2,
'UPDATING' : 3,
'SUCCESS' : 4
};
var STATE = {
'READY' : 1,
'IN_PROGRESS': 2,
'SUCCESS' : 3,
'ERROR' : 4
};
var current = { stage: 0, state: STATE.READY };
var deferred = null;
var file = { name: null, url: null };
var timeout = { id: null, delay: 1000 };
var $dom = {
stages: $('ol.status-progress').children(),
successStage: $('.item-progresspoint-success'),
wrapper: $('div.wrapper-status')
};
/********** Private functions *****************************************/
/**
* Toggle the spin on the progress cog.
* @param {boolean} isSpinning Turns cog spin on if true, off otherwise.
* Destroys any event listener Import might have needed
* during the process the import
*
*/
var updateCog = function (elem, isSpinning) {
var cogI = elem.find('i.fa-cog');
if (isSpinning) { cogI.addClass("fa-spin");}
else { cogI.removeClass("fa-spin");}
var destroyEventListeners = function () {
$(window).off('beforeunload.import');
};
/**
* Makes Import feedback status list visible
*
*/
var displayFeedbackList = function () {
$dom.wrapper.removeClass('is-hidden');
};
/**
* Manipulate the DOM to reflect current status of upload.
* @param {int} stageNo Current stage.
* Sets the Import in the "error" status.
*
* Immediately stops any further polling from the server.
* Displays the error message at the list element that corresponds
* to the stage where the error occurred.
*
* @param {string} msg Error message to display.
* @param {int} [stage=current.stage] Stage of import process at which error occurred.
*/
var updateStage = function (stageNo){
var all = $('ol.status-progress').children();
var prevList = all.slice(0, stageNo);
_.map(prevList, function (elem){
$(elem).
removeClass("is-not-started").
removeClass("is-started").
addClass("is-complete");
updateCog($(elem), false);
var error = function (msg, stage) {
current.stage = Math.abs(stage || current.stage); // Could be negative
current.state = STATE.ERROR;
destroyEventListeners();
clearTimeout(timeout.id);
updateFeedbackList(msg);
deferred.resolve();
};
/**
* Initializes the event listeners
*
*/
var initEventListeners = function () {
$(window).on('beforeunload.import', function () {
if (current.stage <= STAGE.UNPACKING) {
return gettext('Your import is in progress; navigating away will abort it.');
}
});
var curList = all.eq(stageNo);
curList.removeClass("is-not-started").addClass("is-started");
updateCog(curList, true);
};
/**
* Check for import status updates every `timeout` milliseconds, and update
* the page accordingly.
* @param {string} url Url to call for status updates.
* @param {int} timeout Number of milliseconds to wait in between ajax calls
* for new updates.
* @param {int} stage Starting stage.
* Stores in a cookie the current import data
*
* @param {boolean} [completed=false] If the import has been completed or not
*/
var getStatus = function (url, timeout, stage) {
var currentStage = stage || 0;
if (currentStage > 1) { CourseImport.okayToNavigateAway = true; }
if (CourseImport.stopGetStatus) { return ;}
if (currentStage === 4) {
// Succeeded
CourseImport.displayFinishedImport();
$('.view-import .choose-file-button').html(gettext("Choose new file")).show();
} else if (currentStage < 0) {
// Failed
var errMsg = gettext("Error importing course");
var failedStage = Math.abs(currentStage);
CourseImport.stageError(failedStage, errMsg);
$('.view-import .choose-file-button').html(gettext("Choose new file")).show();
} else {
// In progress
updateStage(currentStage);
var storeImport = function (completed) {
$.cookie(COOKIE_NAME, JSON.stringify({
file: file,
date: moment().valueOf(),
completed: completed || false
}));
};
/**
* Sets the Import on the "success" status
*
* If it wasn't already, marks the stored import as "completed",
* and updates its date timestamp
*/
var success = function () {
current.state = STATE.SUCCESS;
if (CourseImport.storedImport().completed !== true) {
storeImport(true);
}
var time = timeout || 1000;
$.getJSON(url,
function (data) {
setTimeout(function () {
getStatus(url, time, data.ImportStatus);
}, time);
}
);
destroyEventListeners();
updateFeedbackList();
deferred.resolve();
};
/**
* Updates the Import feedback status list
*
* @param {string} [currStageMsg=''] The message to show on the
* current stage (for now only in case of error)
*/
var updateFeedbackList = function (currStageMsg) {
var $checkmark, $curr, $prev, $next;
var date, successUnix, time;
$checkmark = $dom.successStage.find('.icon');
currStageMsg = currStageMsg || '';
/********** Public functions *************************************************/
function completeStage(stage) {
$(stage)
.removeClass("is-not-started is-started")
.addClass("is-complete");
}
var CourseImport = {
function errorStage(stage) {
if (!$(stage).hasClass('has-error')) {
$(stage)
.removeClass('is-started')
.addClass('has-error')
.find('p.copy')
.hide()
.after("<p class='copy error'>" + currStageMsg + "</p>");
}
}
/**
* Whether to stop sending AJAX requests for updates on the import
* progress.
*/
stopGetStatus: false,
/**
* Whether its fine to navigate away while import is in progress
*/
okayToNavigateAway: false,
function resetStage(stage) {
$(stage)
.removeClass("is-complete is-started has-error")
.addClass("is-not-started")
.find('p.error').remove().end()
.find('p.copy').show();
}
/**
* Update DOM to set all stages as not-started (for retrying an upload that
* failed).
*/
clearImportDisplay: function () {
var all = $('ol.status-progress').children();
_.map(all, function (elem){
$(elem).removeClass("is-complete").
removeClass("is-started").
removeClass("has-error").
addClass("is-not-started");
$(elem).find('p.error').remove(); // remove error messages
$(elem).find('p.copy').show();
updateCog($(elem), false);
});
all.find('.fa-check-square-o'). // Replace checkmark with unchecked box
removeClass('fa-check-square-o').
addClass('fa-square-o');
this.stopGetStatus = false;
},
switch (current.state) {
case STATE.READY:
_.map($dom.stages, resetStage);
break;
case STATE.IN_PROGRESS:
$prev = $dom.stages.slice(0, current.stage);
$curr = $dom.stages.eq(current.stage);
_.map($prev, completeStage);
$curr.removeClass("is-not-started").addClass("is-started");
break;
case STATE.SUCCESS:
successUnix = CourseImport.storedImport().date;
date = moment(successUnix).utc().format('MM/DD/YYYY');
time = moment(successUnix).utc().format('HH:mm');
_.map($dom.stages, completeStage);
$dom.successStage
.find('.item-progresspoint-success-date')
.html('(' + date + ' at ' + time + ' UTC)');
break;
case STATE.ERROR:
// Make all stages up to, and including, the error stage 'complete'.
$prev = $dom.stages.slice(0, current.stage + 1);
$curr = $dom.stages.eq(current.stage);
$next = $dom.stages.slice(current.stage + 1);
_.map($prev, completeStage);
_.map($next, resetStage);
errorStage($curr);
break;
}
if (current.state === STATE.SUCCESS) {
$checkmark.removeClass('fa-square-o').addClass('fa-check-square-o');
} else {
$checkmark.removeClass('fa-check-square-o').addClass('fa-square-o');
}
};
/********** Public functions ******************************************/
var CourseImport = {
/**
* Update DOM to set all stages as complete, and stop asking for status
* updates.
* Cancels the import and sets the Object to the error state
*
* @param {string} msg Error message to display.
* @param {int} stage Stage of import process at which error occurred.
*/
displayFinishedImport: function () {
this.stopGetStatus = true;
var all = $('ol.status-progress').children();
_.map(all, function (elem){
elem = $(elem);
$(elem).
removeClass("is-not-started").
removeClass("is-started").
addClass("is-complete");
updateCog($(elem), false);
});
all.find('.fa-square-o').
removeClass('fa-square-o').
addClass('fa-check-square-o');
cancel: function (msg, stage) {
error(msg, stage);
},
/**
* Make Import feedback status list visible.
* Entry point for server feedback
*
* Checks for import status updates every `timeout` milliseconds,
* and updates the page accordingly.
*
* @param {int} [stage=0] Starting stage.
*/
displayFeedbackList: function (){
this.stopGetStatus = false;
$('div.wrapper-status').removeClass('is-hidden');
$('.status-info').show();
pollStatus: function (stage) {
if (current.state !== STATE.IN_PROGRESS) {
return;
}
current.stage = stage || STAGE.UPLOADING;
if (current.stage === STAGE.SUCCESS) {
success();
} else if (current.stage < STAGE.UPLOADING) { // Failed
error(gettext("Error importing course"));
} else { // In progress
updateFeedbackList();
$.getJSON(file.url, function (data) {
timeout.id = setTimeout(function () {
this.pollStatus(data.ImportStatus);
}.bind(this), timeout.delay);
}.bind(this));
}
},
/**
* Start upload feedback. Makes status list visible and starts
* showing upload progress.
* Resets the Import internally and visually
*
*/
startUploadFeedback: function (){
this.displayFeedbackList();
updateStage(0);
reset: function () {
current.stage = STAGE.UPLOADING;
current.state = STATE.READY;
clearTimeout(timeout.id);
updateFeedbackList();
},
/**
* Show last import status from server and start sending requests to the server for status updates.
* Show last import status from server and start sending requests
* to the server for status updates
*
* @return {jQuery promise}
*/
getAndStartUploadFeedback: function (url, fileName){
var self = this;
$.getJSON(url,
function (data) {
if (data.ImportStatus != 0) {
$('.file-name').html(fileName);
$('.file-name-block').show();
self.displayFeedbackList();
if (data.ImportStatus === 4){
self.displayFinishedImport();
} else {
$('.view-import .choose-file-button').hide();
var time = 1000;
setTimeout(function () {
getStatus(url, time, data.ImportStatus);
}, time);
}
}
resume: function () {
deferred = $.Deferred();
file = this.storedImport().file;
$.getJSON(file.url, function (data) {
current.stage = data.ImportStatus;
displayFeedbackList();
if (current.stage !== STAGE.UPLOADING) {
current.state = STATE.IN_PROGRESS;
this.pollStatus(current.stage);
} else {
// An import in the upload stage cannot be resumed
error(gettext("There was an error with the upload"));
}
);
}.bind(this));
return deferred.promise();
},
/**
* Entry point for server feedback. Makes status list visible and starts
* sending requests to the server for status updates.
* @param {string} url The url to send Ajax GET requests for updates.
* Starts the importing process.
* Makes status list visible and starts showing upload progress.
*
* @param {string} fileName The name of the file to import
* @param {string} fileUrl The full URL to use to query the server
* about the import status
* @return {jQuery promise}
*/
startServerFeedback: function (url){
this.stopGetStatus = false;
getStatus(url, 1000, 0);
start: function (fileName, fileUrl) {
current.state = STATE.IN_PROGRESS;
deferred = $.Deferred();
file.name = fileName;
file.url = fileUrl;
initEventListeners();
storeImport();
displayFeedbackList();
updateFeedbackList();
return deferred.promise();
},
/**
* Give error message at the list element that corresponds to the stage
* where the error occurred.
* @param {int} stageNo Stage of import process at which error occurred.
* @param {string} msg Error message to display.
* Fetches the previous stored import
*
* @return {JSON} the data of the previous import
*/
stageError: function (stageNo, msg) {
this.stopGetStatus = true;
var all = $('ol.status-progress').children();
// Make all stages up to, and including, the error stage 'complete'.
var prevList = all.slice(0, stageNo + 1);
_.map(prevList, function (elem){
$(elem).
removeClass("is-not-started").
removeClass("is-started").
addClass("is-complete");
updateCog($(elem), false);
});
var message = msg || gettext("There was an error with the upload");
var elem = $('ol.status-progress').children().eq(stageNo);
if (!elem.hasClass('has-error')) {
elem.removeClass('is-started').addClass('has-error');
elem.find('p.copy').hide().after("<p class='copy error'>" + message + "</p>");
}
}
};
var showImportSubmit = function (e) {
var filepath = $(this).val();
if (filepath.substr(filepath.length - 6, 6) == 'tar.gz') {
$('.error-block').hide();
$('.file-name').html($(this).val().replace('C:\\fakepath\\', ''));
$('.file-name-block').show();
$('.view-import .choose-file-button').hide();
$('.submit-button').show();
$('.progress').show();
} else {
$('.error-block').html(gettext('File format not supported. Please upload a file with a <code>tar.gz</code> extension.')).show();
storedImport: function () {
return JSON.parse($.cookie(COOKIE_NAME));
}
};
domReady(function () {
// import form setup
$('.view-import .file-input').bind('change', showImportSubmit);
$('.view-import .choose-file-button, .view-import .choose-file-button-inline').bind('click', function (e) {
e.preventDefault();
$('.view-import .file-input').click();
});
});
return CourseImport;
});
......@@ -27,6 +27,7 @@ require.config({
"jquery.immediateDescendents": "coffee/src/jquery.immediateDescendents",
"datepair": "js/vendor/timepicker/datepair",
"date": "js/vendor/date",
"moment": "js/vendor/moment.min",
"underscore": "js/vendor/underscore-min",
"underscore.string": "js/vendor/underscore.string.min",
"backbone": "js/vendor/backbone-min",
......
......@@ -186,6 +186,17 @@
// TYPE: success
&.item-progresspoint-success {
.item-progresspoint-success-date {
@include margin-left($baseline/4);
display: none;
}
&.is-complete {
.item-progresspoint-success-date {
display: inline;
}
}
}
......@@ -217,6 +228,8 @@
}
.fa-cog {
@include animation(fa-spin 2s infinite linear);
visibility: visible;
opacity: 1.0;
}
......
......@@ -115,7 +115,7 @@ else:
<li class="item-progresspoint item-progresspoint-unpack is-started">
<span class="deco status-visual">
<i class="icon fa fa-cog fa-spin"></i>
<i class="icon fa fa-cog"></i>
<i class="icon fa fa-warning"></i>
</span>
......@@ -167,7 +167,10 @@ else:
</span>
<div class="status-detail">
<h3 class="title">${_("Success")}</h3>
<h3 class="title">
${_("Success")}
<span class="item-progresspoint-success-date"></span>
</h3>
<p class="copy">
%if library:
${_("Your imported content has now been integrated into this library")}
......
This diff is collapsed.
......@@ -3,6 +3,7 @@ Import/Export pages.
"""
from bok_choy.promise import EmptyPromise
import os
import re
import requests
from .utils import click_css
from .library import LibraryPage
......@@ -118,6 +119,16 @@ class ImportMixin(object):
url_path = "import"
@property
def timestamp(self):
"""
The timestamp is displayed on the page as "(MM/DD/YYYY at HH:mm)"
It parses the timestamp and returns a (date, time) tuple
"""
string = self.q(css='.item-progresspoint-success-date').text[0]
return re.match(r'\(([^ ]+).+?(\d{2}:\d{2})', string).groups()
def is_browser_on_page(self):
"""
Verify this is the export page
......@@ -226,6 +237,12 @@ class ImportMixin(object):
"""
return self.q(css='.wrapper-status').visible
def is_timestamp_visible(self):
"""
Checks if the UTC timestamp of the last successfull import is visible
"""
return self.q(css='.item-progresspoint-success-date').visible
def wait_for_filename_error(self):
"""
Wait for the upload field to display an error.
......
......@@ -3,6 +3,7 @@ Acceptance tests for the Import and Export pages
"""
from abc import abstractmethod
from bok_choy.promise import EmptyPromise
from datetime import datetime
from .base_studio_test import StudioLibraryTest, StudioCourseTest
from ...fixtures.course import XBlockFixtureDesc
from ...pages.studio.import_export import ExportLibraryPage, ExportCoursePage, ImportLibraryPage, ImportCoursePage
......@@ -183,6 +184,27 @@ class ImportTestMixin(object):
self.import_page.upload_tarball(self.tarball_name)
self.import_page.wait_for_upload()
def test_import_timestamp(self):
"""
Scenario: I perform a course / library import
On import success, the page displays a UTC timestamp previously not visible
And if I refresh the page, the timestamp is still displayed
"""
self.assertFalse(self.import_page.is_timestamp_visible())
self.import_page.upload_tarball(self.tarball_name)
self.import_page.wait_for_upload()
utc_now = datetime.utcnow()
import_date, import_time = self.import_page.timestamp
self.assertTrue(self.import_page.is_timestamp_visible())
self.assertEqual(utc_now.strftime('%m/%d/%Y'), import_date)
self.assertEqual(utc_now.strftime('%H:%M'), import_time)
self.import_page.visit()
self.import_page.wait_for_tasks(completed=True)
self.assertTrue(self.import_page.is_timestamp_visible())
def test_landing_url(self):
"""
Scenario: When uploading a library or course, a link appears for me to view the changes.
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment