Multiple files upload handling and user experience improved. Spinner now also prevents user from accidentally leaving page.

This commit is contained in:
Matej Zrimšek 2016-08-09 14:39:24 +02:00
parent 951cf67b3d
commit c87ba5b45c
10 changed files with 188 additions and 126 deletions

View file

@ -81,7 +81,7 @@ function animateLoading(start){
* Shows spinner if start is true or not given, hides it if false.
* Optional parameter options for spin.js options.
*/
function animateSpinner(el, start, options) {
function animateSpinner(el, start, options, msg) {
// If overlaying the whole page,
// put the spinner in the middle of the page
var overlayPage = false;
@ -114,6 +114,10 @@ function animateSpinner(el, start, options) {
$(".loading-overlay").remove();
}
busy = start;
if (busy && !_.isUndefined(msg)) {
busyMsg = msg;
}
}
/*
@ -163,3 +167,18 @@ $(document).ready(function(){
truncateLongString( $(this), 30);
});
});
/*
* Prevents user from accidentally leaving page when
* server is busy and notifies him with a message.
*/
var busy = false;
var busyMsg = I18n.t("general.busy");
window.onbeforeunload = function () {
if (busy) {
var currentMsg = busyMsg;
// Reset to default message
busyMsg = I18n.t("general.busy");
return currentMsg;
}
}

View file

@ -1,5 +1,16 @@
(function (exports) {
var styleOptionRe = /(\d+)x(\d+)/i;
function parseStyleOption(option) {
var m = option.match(styleOptionRe);
return {
width: m && m[1] || 150,
height: m && m[2] || 150
};
}
// Edits (size, quality, parameters) image file for S3 server uploading
function generateThumbnail(origFile, type, max_width, max_height, cb) {
var img = new Image;
@ -21,7 +32,7 @@
size = this.width;
offsetY = (this.height - this.width) / 2;
}
if(type === "image/jpeg") {
if (type === "image/jpeg") {
type = "image/jpg";
}
@ -35,10 +46,8 @@
}
// This server checks if files are OK (correct file type, presence,
// size and spoofing) and generates posts for S3 server file uploading
// (each post for different size of the same file)
// We do this synchronically, because we need to verify all files
// before uploading them
// size and spoofing) and only then generates posts for S3 server
// file uploading (each post for different size of the same file)
function fetchUploadSignature(file, signUrl, cb) {
var csrfParam = $("meta[name=csrf-param]").attr("content");
var csrfToken = $("meta[name=csrf-token]").attr("content");
@ -50,7 +59,6 @@
url : signUrl,
type : 'POST',
data : formData,
async : false,
processData: false,
contentType: false,
complete : function(xhr) {
@ -65,7 +73,7 @@
}
// Upload file to S3 server
function uploadData(postData, cb) {
function uploadFile(postData, cb) {
var xhr = new XMLHttpRequest;
var fd = new FormData();
var fields = postData.fields;
@ -80,94 +88,126 @@
if (xhr.readyState === 4) { // complete
cb();
} else if (xhr.readyState == 0) { // connection error
cb(I18n.t("errors.upload"));
cb(I18n.t("general.file.upload_failure"));
}
}
xhr.open("POST", url);
xhr.send(fd);
}
var styleOptionRe = /(\d+)x(\d+)/i;
// For each file proccesses its posts and uploads them
// If one post fails, the user is allowed to leave page,
// but other files are still being uplaoded because of
// asynchronous behaviour, so errors for other files may
// can still show afterwards
// TODO On S3 server uplaod error the other files that
// were already asynchronously uploaded remain, but
// should be deleted - this should generally not happen,
// because all files should fail to upload in such cases
// (connection error)
function uploadFiles(ev, fileInputs, datas, cb) {
var noErrors = true;
$.each(datas, function(fileIndex, data) {
var fileInput = fileInputs.get(fileIndex);
var file = fileInput.files[0];
function parseStyleOption(option) {
var m = option.match(styleOptionRe);
function processPost(error) {
// File upload error handling
if (error) {
renderFormError(ev, fileInput, error);
noErrors = false;
animateSpinner(null, false);
return;
}
return {
width: m && m[1] || 150,
height: m && m[2] || 150
};
var postData = posts[postIndex];
if (!postData) {
if (fileIndex === datas.length-1 && noErrors) {
// After successful file processing and uploading
$.each(datas, function(fileIndex, data) {
// Use file input to pass file info on submit
var fileInput = fileInputs.get(fileIndex);
cb(fileInput, data.asset_id);
});
animateSpinner(null, false);
$(ev.target.form).submit();
}
return;
}
postData.fileName = file.name;
postIndex++;
if (postData.style_option) {
// Picture file
var styleSize = parseStyleOption(postData.style_option);
generateThumbnail(file, postData.mime_type, styleSize.width,
styleSize.height, function (blob) {
postData.file = blob;
uploadFile(postData, processPost);
});
} else {
// Other file
postData.file = file;
uploadFile(postData, processPost);
}
}
var posts = data.posts;
var postIndex = 0;
processPost();
});
}
// Validates files on this server and uploads them to S3 server
// Validates files on server and uploads them to S3 server
//
// First we validate files on server and generate post requests (fetchUploadSignature),
// if OK the post requests are used to uplaod files asyncronously to S3 (uploadFiles),
// and if successful the form is submitted, otherwise no file is saved
exports.directUpload = function (ev, fileInputs, signUrl, cb) {
var noErrors = true;
var inputPointer = 0;
animateSpinner();
var inputsPosts = []
function processFile () {
var fileInput = fileInputs.get(inputPointer);
function processFile(fileIndex) {
var fileInput = fileInputs.get(fileIndex);
if (!fileInput || !fileInput.files[0]) {
// After file processing
if(fileIndex !== 0) {
if(noErrors) {
uploadFiles(ev, fileInputs, inputsPosts, cb);
} else {
animateSpinner(null, false);
}
}
return;
}
if (fileIndex === 0) {
// Before file processing and uploading
animateSpinner(null, true, undefined, I18n.t("general.file.uploading"));
ev.preventDefault();
ev.stopPropagation();
}
var file = fileInput.files[0];
inputPointer++;
fetchUploadSignature(file, signUrl, function (data) {
function processError(errMsgs) {
renderFormError(ev, fileInput, errMsgs);
noErrors = false;
}
function processPost(error) {
// File post error handling
if (error) {
processError(error);
}
var postData = posts[postPosition];
if (!postData) {
animateSpinner(null, false);
return;
}
postData.fileName = file.name;
postPosition += 1;
if (postData.style_option) {
var styleSize = parseStyleOption(postData.style_option);
generateThumbnail(file, postData.mime_type, styleSize.width,
styleSize.height, function (blob) {
postData.file = blob;
uploadData(postData, processPost);
});
} else {
postData.file = file;
uploadData(postData, processPost);
}
}
// File signature error handling
if (_.isUndefined(data)) {
processError(I18n.t("errors.upload"));
renderFormError(ev, fileInput, I18n.t("general.file.upload_failure"));
noErrors = false;
}
if (data.status === "error") {
processError(jsonToValuesArray(data.errors));
else if (data.status === "error") {
renderFormError(ev, fileInput, jsonToValuesArray(data.errors));
noErrors = false;
} else {
inputsPosts.push(data);
}
processFile();
if(noErrors) {
// Use file input to pass file info on submit
cb(fileInput, data.asset_id);
var posts = data.posts;
var postPosition = 0;
processPost();
}
processFile(fileIndex+1);
});
}
processFile();
processFile(0);
};
}(this));

View file

@ -326,7 +326,7 @@ function processResult(ev, resultTypeEnum, forS3) {
startFileUpload(ev, ev.target);
} else {
// Local file uploading
animateSpinner();
animateSpinner(null, true, undefined, I18n.t("general.file.uploading"));
}
}
break;

View file

@ -360,13 +360,16 @@ function initEditableHandsOnTable(root) {
}
// Initialize comment form.
function initStepCommentForm($el) {
function initStepCommentForm(ev, $el) {
var $form = $el.find("ul form");
var $commentInput = $form.find("#comment_message");
$form.onSubmitValidator(textValidator, $commentInput);
$(".help-block", $form).addClass("hide");
$form.on("ajax:send", function (data) {
$form
.on("ajax:send", function (data) {
$("#comment_message", $form).attr("readonly", true);
})
.on("ajax:success", function (e, data) {
@ -450,7 +453,7 @@ function initStepCommentTabAjax() {
var parentNode = $this.parents("ul").parent();
target.html(data.html);
initStepCommentForm(parentNode);
initStepCommentForm(e, parentNode);
initStepCommentsLink(parentNode);
parentNode.find(".active").removeClass("active");
@ -598,7 +601,7 @@ function processStep(ev, editMode, forS3) {
startFileUpload(ev, ev.target);
} else {
// Local file uploading
animateSpinner();
animateSpinner(null, true, undefined, I18n.t("general.file.uploading"));
}
}
}

View file

@ -1,6 +1,19 @@
// Form validators. They'll find, render and focus error/s and
// prevent form submission.
// Calls specified validator along with the arguments on form submition
$.fn.onSubmitValidator = function(validatorCb) {
var params = Array.prototype.slice.call(arguments, 1);
var $form = $(this);
if ($form.length) {
$form.submit(function (ev) {
$form.clear_form_errors();
params.unshift(ev);
validatorCb.apply(this, params);
});
}
};
function textValidator(ev, textInput, canBeBlank = false, limitLength = true) {
var nameTooShort = $(textInput).val().length === 0;
var nameTooLong = $(textInput).val().length > 50;
@ -55,16 +68,10 @@ function checklistsValidator(ev, checklists, editMode) {
return noErrors;
}
$.fn.files_validator = function(fileTypeEnum) {
var $form = $(this);
if ($form.length) {
$form.submit(function (ev) {
$form.clear_form_errors();
var $fileInputs = $form.find("input[type=file]");
filesValidator(ev, $fileInputs, fileTypeEnum);
});
}
};
var FileTypeEnum = Object.freeze({
FILE: 0,
AVATAR: 1
});
function filesValidator(ev, fileInputs, fileTypeEnum) {
var filesValid = true;
@ -89,11 +96,6 @@ function filesPresentValidator(ev, fileInputs) {
return filesPresentValid;
}
var FileTypeEnum = Object.freeze({
FILE: 0,
AVATAR: 1
});
function filesSizeValidator(ev, fileInputs, fileTypeEnum) {
function getFileTooBigError(file) {
@ -132,4 +134,4 @@ function filesSizeValidator(ev, fileInputs, fileTypeEnum) {
// whether enough organization space is free
function enaughSpaceValidator(ev, fileInputs) {
return true;
}
}

View file

@ -74,7 +74,7 @@ function processFile(ev, forS3) {
startFileUpload(ev, ev.target);
} else {
// Local file uploading
animateSpinner();
animateSpinner(null, true, undefined, I18n.t("general.file.uploading"));
}
}
}

View file

@ -2,6 +2,8 @@ class AssetsController < ApplicationController
before_action :load_vars, except: [:signature]
before_action :check_read_permission, except: [:signature, :file_present]
# Validates asset and then generates S3 upload posts, because
# otherwise untracked files could be uploaded to S3
def signature
respond_to do |format|
format.json {
@ -19,7 +21,7 @@ class AssetsController < ApplicationController
validationAsset = Asset.new(asset_params)
end
if not validationAsset.valid?
if validationAsset.errors.any?
render json: {
status: 'error',
errors: validationAsset.errors

View file

@ -37,13 +37,8 @@ class StepsController < ApplicationController
if step_assets.size > 0
step_assets[:assets_attributes].each do |i, data|
# Ignore destroy requests on create
if data[:_destroy].nil?
if data[:id].present?
asset = Asset.find_by_id(data[:id])
else
# For validation purposses if no JS
asset = Asset.new(data)
end
if data[:_destroy].nil? and data[:id].present?
asset = Asset.find_by_id(data[:id])
asset.created_by = current_user
asset.last_modified_by = current_user
@step.assets << asset

View file

@ -7,6 +7,8 @@ class Users::RegistrationsController < Devise::RegistrationsController
redirect_to user.avatar.url(style.to_sym), status: 307
end
# Validates asset and then generates S3 upload posts, because
# otherwise untracked files could be uploaded to S3
def signature
respond_to do |format|
format.json {

View file

@ -1397,29 +1397,6 @@ en:
head_title: "Edit protocol"
no_keywords: "No keywords"
general:
update: "Update"
edit: "Edit"
cancel: "Cancel"
close: "Close"
check_all: "Check all"
no_comments: "No comments!"
more_comments: "More comments"
comment_placeholder: "Your Message"
module:
one: "task"
other: "tasks"
public: "public"
private: "private"
search: "Search"
file:
loading: "%{fileName} is loading..."
size_exceeded: "File size must be less than %{file_size} MB."
blank: "You didn't select any file"
text:
not_blank: "can't be blank"
length_too_long: "is too long (maximum is %{max_length} characters)"
mailer:
invitation_to_organization:
subject: "You have been invited to team"
@ -1469,8 +1446,30 @@ en:
# This section contains general words that can be used in any parts of
# application.
errors:
upload: "Upload error. Try again or contact the administrator."
general:
update: "Update"
edit: "Edit"
cancel: "Cancel"
close: "Close"
check_all: "Check all"
no_comments: "No comments!"
more_comments: "More comments"
comment_placeholder: "Your Message"
module:
one: "task"
other: "tasks"
public: "public"
private: "private"
search: "Search"
file:
size_exceeded: "File size must be less than %{file_size} MB."
blank: "You didn't select any file"
uploading: "If you leave this page, the file(s) that are currently uploading will not be saved! Are you sure you want to continue?"
upload_failure: "Upload connection error. Try again or contact the administrator."
text:
not_blank: "can't be blank"
length_too_long: "is too long (maximum is %{max_length} characters)"
busy: "The server is still processing your request. If you leave this page, the changes will be lost! Are you sure you want to continue?"
Add: "Add"
Asset: "File"