A lot of file uploading edge cases considered. File uploading is now actually redirected to S3 server, as before was not. Error functions changed and error output format specified, which should be used consistently throughout the application. Some other refactoring.

This commit is contained in:
Matej Zrimšek 2016-08-05 17:00:29 +02:00
parent b70f49f4e0
commit 951cf67b3d
32 changed files with 441 additions and 409 deletions

View file

@ -162,4 +162,4 @@ $(document).ready(function(){
$('.tree-link a').each( function(){
truncateLongString( $(this), 30);
});
});
});

View file

@ -1,14 +1,13 @@
(function (exports) {
// Edits (size, quality, parameters) image file for S3 server uploading
function generateThumbnail(origFile, type, max_width, max_height, cb) {
var img = new Image;
var canvas = document.createElement("canvas");
var ctx = canvas.getContext("2d");
// todo allow for different x/y ratio
canvas.width = max_width;
canvas.height = max_height;
img.src = URL.createObjectURL(origFile);
img.onload = function () {
var size;
@ -18,77 +17,76 @@
if (this.width > this.height) {
size = this.height;
offsetX = (this.width - this.height) / 2;
} else {
size = this.width;
offsetY = (this.height - this.width) / 2;
}
if(type === "image/jpeg") {
type = "image/jpg";
}
ctx.drawImage(this, offsetX, offsetY, size, size, 0, 0, canvas.width, canvas.height);
ctx.drawImage(this, offsetX, offsetY, size, size, 0, 0,
canvas.width, canvas.height);
canvas.toBlob(function (blob) {
cb(blob);
}, type, 0.8)
}, type, 0.8);
};
img.src = URL.createObjectURL(origFile);
}
function fetchUploadSignature(file, origId, signUrl, cb) {
// 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
function fetchUploadSignature(file, signUrl, cb) {
var csrfParam = $("meta[name=csrf-param]").attr("content");
var csrfToken = $("meta[name=csrf-token]").attr("content");
var xhr = new XMLHttpRequest;
var data = [];
data.push("file_name=" + file.name);
data.push("file_size=" + file.size);
data.push(csrfParam + "=" + encodeURIComponent(csrfToken));
var formData = new FormData();
formData.append("file", file);
if (origId) {
data.push("asset_id=" + origId);
}
xhr.open("POST", signUrl);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
xhr.send(data.join("&"));
xhr.onload = function () {
try {
var data = JSON.parse(xhr.responseText);
cb(data);
} catch (e) {
cb();
}
};
$.ajax({
url : signUrl,
type : 'POST',
data : formData,
async : false,
processData: false,
contentType: false,
complete : function(xhr) {
if (xhr.readyState === 4) { // complete
var data = JSON.parse(xhr.responseText);
cb(data);
} else if (xhr.readyState == 0) { // connection error
cb();
}
}
});
}
function uploadData(data, cb) {
// Upload file to S3 server
function uploadData(postData, cb) {
var xhr = new XMLHttpRequest;
var fd = new FormData();
var fields = data.fields;
var url = data.url;
var fields = postData.fields;
var url = postData.url;
for (var k in fields) {
fd.append(k, fields[k]);
}
fd.append("file", postData.file, postData.fileName);
fd.append("file", data.file, data.fileName);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) { // complete
cb();
} else if (xhr.readyState == 0) { // connection error
cb(I18n.t("errors.upload"));
}
}
xhr.open("POST", url);
xhr.send(fd);
xhr.onload = function () {
cb();
};
xhr.onerror = function (error) {
cb(I18n.t("errors.upload"));
};
}
var styleOptionRe = /(\d+)x(\d+)/i;
function parseStyleOption(option) {
@ -100,71 +98,76 @@
};
}
// Validates files on this server and uploads them to S3 server
exports.directUpload = function (ev, fileInputs, signUrl, cb) {
var noErrors = true;
var inputPointer = 0;
animateSpinner();
exports.directUpload = function (form, origId, signUrl, cb, cbErr, errKey) {
var $fileInputs = $(form).find("input[type=file]");
var file = $fileInputs.get(0).files[0];
function processFile () {
var fileInput = fileInputs.get(inputPointer);
if (!fileInput || !fileInput.files[0]) {
return;
}
var file = fileInput.files[0];
inputPointer++;
var isValid = filesValidator($fileInputs);
fetchUploadSignature(file, signUrl, function (data) {
if (!isValid) {
cbErr();
} else {
fetchUploadSignature(file, origId, signUrl, function (data) {
function processError(errMsgs) {
renderFormError(ev, fileInput, errMsgs);
noErrors = false;
}
function processPost(error) {
var postData = posts[postPosition];
// File post error handling
if (error) {
var errObj = {};
errKey = errKey|| "asset.file";
errObj[errKey] = [error];
cbErr(errObj);
isValid = false;
return;
processError(error);
}
var postData = posts[postPosition];
if (!postData) {
cb(data.asset_id);
isValid = false;
animateSpinner(null, false);
return;
}
postData.fileName = file.name;
postPosition += 1;
var styleSize;
if (postData.style_option) {
styleSize = parseStyleOption(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 {
} else {
postData.file = file;
uploadData(postData, processPost);
}
}
if (!data || data.status === 'error') {
cbErr(data && data.errors);
isValid = false;
return;
// File signature error handling
if (_.isUndefined(data)) {
processError(I18n.t("errors.upload"));
}
if (data.status === "error") {
processError(jsonToValuesArray(data.errors));
}
var posts = data.posts;
var postPosition = 0;
processFile();
if(noErrors) {
// Use file input to pass file info on submit
cb(fileInput, data.asset_id);
processPost();
var posts = data.posts;
var postPosition = 0;
processPost();
}
});
}
return isValid;
processFile();
};
}(this));

View file

@ -89,9 +89,9 @@ function initResultCommentsLink($el) {
var listItem = moreBtn.parents('li');
$(data.html).insertBefore(listItem);
if (data.results_number < data.per_page) {
moreBtn.remove();
moreBtn.remove();
} else {
moreBtn.attr("href", data.more_url);
moreBtn.attr("href", data.more_url);
}
}
});
@ -302,39 +302,70 @@ function showTutorial() {
return tutorialModuleId == currentModuleId;
}
var ResultTypeEnum = Object.freeze({
FILE: 0,
TABLE: 1,
TEXT: 2,
COMMENT: 3
});
function processResult(ev, resultTypeEnum, forS3) {
var $form = $(ev.target.form);
$form.clear_form_errors();
switch(resultTypeEnum) {
case ResultTypeEnum.FILE:
var $nameInput = $form.find("#result_name");
var nameValid = textValidator(ev, $nameInput, true);
var $fileInput = $form.find("#result_asset_attributes_file");
var filesValid = filesValidator(ev, $fileInput, FileTypeEnum.FILE);
if(nameValid && filesValid) {
if(forS3) {
// Redirects file uploading to S3
startFileUpload(ev, ev.target);
} else {
// Local file uploading
animateSpinner();
}
}
break;
case ResultTypeEnum.TABLE:
var $nameInput = $form.find("#result_name");
var nameValid = textValidator(ev, $nameInput, true);
break;
case ResultTypeEnum.TEXT:
var $nameInput = $form.find("#result_name");
var nameValid = textValidator(ev, $nameInput, true);
var $textInput = $form.find("#result_result_text_attributes_text");
textValidator(ev, $textInput, false, false);
break;
case ResultTypeEnum.COMMENT:
var $commentInput = $form.find("#comment_message");
var commentValid = textValidator(ev, $commentInput, false, false);
break;
}
}
// S3 direct uploading
function startFileUpload(ev, btn) {
var form = btn.form;
var $form = $(form);
var assetInput = $form.find("input[name='result[asset_attributes][id]']").get(0);
var fileInput = $form.find("input[type=file]").get(0);
var origAssetId = assetInput ? assetInput.value : null;
var $form = $(btn.form);
var $editFileInput = $form.find("input[name='result[asset_attributes][id]']").get(0);
var $fileInput = $form.find("input[type=file]");
var url = '/asset_signature.json';
$form.clear_form_errors();
animateSpinner();
var noErrors = directUpload(form, origAssetId, url, function (assetId) {
// edit mode - input field has to be removed
if (assetInput) {
assetInput.value = assetId;
directUpload(ev, $fileInput, url, function (fileInput, fileId) {
if ($editFileInput) {
// edit mode - input field has to be removed
$editFileInput.value = fileId;
$(fileInput).remove();
// create mode
} else {
// create mode
fileInput.type = "hidden";
fileInput.name = "result[asset_attributes][id]";
fileInput.value = assetId;
fileInput.name = fileInput.name.replace("[file]", "[id]");
fileInput.value = fileId;
}
btn.onclick = null;
$(btn).click();
}, function (errors) {
showResultFormErrors($form, errors);
});
return noErrors;
}
// This checks if the ctarget param exist in the

View file

@ -201,7 +201,8 @@ function formEditAjax($form) {
renderTable($(this));
});
animateSpinner(null, false);
var $stepImgs = $new_step.find("img");
reloadImagesHack($stepImgs);
})
.on("ajax:error", function(e, xhr, status, error) {
$(this).after(xhr.responseJSON.html);
@ -220,8 +221,6 @@ function formEditAjax($form) {
$form.find("[data-role='step-hot-table']").each(function() {
renderTable($(this));
});
animateSpinner(null, false);
});
}
@ -247,7 +246,8 @@ function formNewAjax($form) {
$(this).handsontable("render");
});
animateSpinner(null, false);
var $stepImgs = $new_step.find("img");
reloadImagesHack($stepImgs);
})
.on("ajax:error", function(e, xhr, status, error) {
$(this).after(xhr.responseJSON.html);
@ -260,8 +260,6 @@ function formNewAjax($form) {
formCallback($form);
formNewAjax($form);
applyCancelOnNew();
animateSpinner(null, false);
});
}
@ -283,7 +281,6 @@ function toggleButtons(show) {
// Also hide "no steps" label if no steps are present
$("[data-role='no-steps-text']").hide();
}
}
@ -580,7 +577,7 @@ $("[data-action='new-step']").on("ajax:success", function(e, data) {
// Needed because server-side validation failure clears locations of
// files to be uploaded and checklist's items etc. Also user
// experience is improved
function stepValidator(ev, editMode, forS3) {
function processStep(ev, editMode, forS3) {
var $form = $(ev.target.form);
$form.clear_form_errors();
@ -589,20 +586,18 @@ function stepValidator(ev, editMode, forS3) {
removeBlankFileForms($form);
var $fileInputs = $form.find("input[type=file]");
var filesValid = filesValidator(ev, $fileInputs);
var filesValid = filesValidator(ev, $fileInputs, FileTypeEnum.FILE);
var $checklists = $form.find(".nested_step_checklists");
var checklistsValid = checklistsValidator(ev, $checklists, editMode);
var $nameInput = $form.find("#step_name");
var nameValid = nameValidator(ev, $nameInput);
var nameValid = textValidator(ev, $nameInput);
if(filesValid && checklistsValid && nameValid) {
if(forS3) {
// Needed to redirect uploaded files to S3
// Redirects file uploading to S3
startFileUpload(ev, ev.target);
} else {
// Files are saved locally
// Validations passed, so animate spinner for possible file uploading
// (startFileUpload already calls it)
// Local file uploading
animateSpinner();
}
}
@ -659,54 +654,13 @@ function renderTable(table) {
// S3 direct uploading
function startFileUpload(ev, btn) {
var form = btn.form;
var $form = $(form);
var fileInputs = $form.find("input[type=file]");
var $form = $(btn.form);
var $fileInputs = $form.find("input[type=file]");
var url = '/asset_signature.json';
var inputPos = 0;
var inputPointer = 0;
animateSpinner();
function processFile () {
var fileInput = fileInputs.get(inputPos);
inputPos += 1;
inputPointer += 1;
if (!fileInput) {
btn.onclick = null;
$(btn).click();
return false;
}
return directUpload(form, null, url, function (assetId) {
fileInput.type = "hidden";
fileInput.name = fileInput.name.replace("[file]", "[id]");
fileInput.value = assetId;
inputPointer -= 1;
processFile();
}, function (errors) {
var assetErrorMsg;
for (var c in errors) {
if (/^asset\./.test(c)) {
assetErrorMsg = errors[c];
break;
}
}
if (assetErrorMsg) {
var el = $form.find("input[type=file]").get(inputPointer - 1);
var $el = $(el);
$form.clear_form_errors();
renderFormError(e, $el, assetErrorMsg);
} else {
tabsPropagateErrorClass($form);
}
});
}
var noErrors = processFile();
return noErrors;
directUpload(ev, $fileInputs, url, function (fileInput, fileId) {
fileInput.type = "hidden";
fileInput.name = fileInput.name.replace("[file]", "[id]");
fileInput.value = fileId;
});
}

View file

@ -3,7 +3,6 @@ $("#new-result-asset").on("ajax:success", function(e, data) {
var $form = $(data.html);
$("#results").prepend($form);
$form.files_validator();
formAjaxResultAsset($form);
// Cancel button
@ -18,7 +17,7 @@ $("#new-result-asset").on("ajax:success", function(e, data) {
});
$("#new-result-asset").on("ajax:error", function(e, xhr, status, error) {
//TODO: Add error handling
// TODO
});
// Edit result asset button behaviour
@ -30,7 +29,6 @@ function applyEditResultAssetCallback() {
$result.after($form);
$result.remove();
$form.files_validator();
formAjaxResultAsset($form);
// Cancel button
@ -47,46 +45,31 @@ function applyEditResultAssetCallback() {
});
$(".edit-result-asset").on("ajax:error", function(e, xhr, status, error) {
//TODO: Add error handling
// TODO
});
}
function showResultFormErrors($form, errors) {
$form.render_form_errors("result", errors);
if (errors["asset.file"]) {
var $el = $form.find("input[type=file]");
$el.closest(".form-group").addClass("has-error");
$el.parent().append("<span class='help-block'>" + errors["asset.file"] + "</span>");
}
}
// Apply ajax callback to form
function formAjaxResultAsset($form) {
$form
.on("ajax:success", function(e, data) {
$form.after(data.html);
var newResult = $form.next();
initFormSubmitLinks(newResult);
$(this).remove();
applyEditResultAssetCallback();
applyCollapseLinkCallBack();
if (data.status === "ok") {
$form.after(data.html);
var newResult = $form.next();
initFormSubmitLinks(newResult);
$(this).remove();
applyEditResultAssetCallback();
applyCollapseLinkCallBack();
toggleResultEditButtons(true);
initResultCommentTabAjax();
expandResult(newResult);
toggleResultEditButtons(true);
initResultCommentTabAjax();
expandResult(newResult);
} else if (data.status === 'error') {
showResultFormErrors($form, data.errors);
}
animateSpinner(null, false);
var $resultImg = newResult.find("img");
reloadImagesHack($resultImg);
})
.on("ajax:error", function() {
animateSpinner(null, false);
.on("ajax:error", function(e, data) {
$form.render_form_errors("result", data.errors, true, e);
});
}
applyEditResultAssetCallback();

View file

@ -0,0 +1,21 @@
// By adding unique attribute to image's src,
// we force browser to reload/update cached image
function reloadImage(img) {
var src = $(img).attr("src");
src = src.split("?", 1);
src += "?timestamp=" + new Date().getTime();
$(img).attr("src", src);
}
// Hack for image retrieval after upload (403 is
// thrown, mostly Chrome issue, and hence image
// isn't retrieved)
function reloadImagesHack(imgs) {
setTimeout(function() {
if(imgs.length) {
imgs.each(function() {
reloadImage($(this));
});
}
}, 1000);
}

View file

@ -1,61 +1,53 @@
// Define AJAX methods for handling errors on forms
$.fn.render_form_errors = function(model_name, errors, clear) {
if (clear || clear === undefined) {
// Render errors specified in JSON format for many form elements
$.fn.render_form_errors = function(modelName, errors, clear = true, ev) {
if (clear || _.isUndefined(clear)) {
this.clear_form_errors();
}
$(this).render_form_errors_no_clear(model_name, errors, false);
};
$.fn.render_form_errors_input_group = function(model_name, errors) {
this.clear_form_errors();
$(this).render_form_errors_no_clear(model_name, errors, true);
};
$.fn.render_form_errors_no_clear = function(model_name, errors, input_group) {
var form = $(this);
$.each(errors, function(field, messages) {
input = $(_.filter(form.find('input, select, textarea'), function(el) {
$input = $(_.filter(form.find('input, select, textarea'), function(el) {
var name = $(el).attr('name');
if (name) {
return name.match(new RegExp(model_name + '\\[' + field + '\\(?'));
return name.match(new RegExp(modelName + '\\[' + field + '\\(?'));
}
return false;
}));
input.closest('.form-group').addClass('has-error');
var error_text = '<span class="help-block">';
error_text += (_.map(messages, function(m) {
return m.charAt(0).toUpperCase() + m.slice(1);
})).join('<br />');
error_text += '</span>';
if (input_group) {
input.closest('.form-group').append(error_text);
} else {
input.parent().append(error_text);
}
renderFormError(ev, $input, messages);
});
};
// Show error message and mark error input (if errMsg is defined)
// and, if present, mark and show the tab where the error occured,
// and go to the input, if it is the most upper one or if errMsg is
// undefined
// NOTE: Similar to $.fn.render_form_errors, except here we process
// one error at a time, which is not read from the form but is
// specified manually.
function renderFormError(ev, nameInput, errMsg, errAttributes) {
if(!_.isUndefined(errMsg)) {
var $errMsgSpan = $(nameInput).next(".help-block");
errAttributes = _.isUndefined(errAttributes) ? "" : " " + errAttributes;
if (!$errMsgSpan.length) {
$(nameInput).closest(".form-group").addClass("has-error");
// Render errors specified in array of strings format (or string if
// just one error) for a single form element
//
// Show error message/s and mark error input (if errMsgs is defined)
// and, if present, mark and show the tab where the error occured and
// focus/scroll to the error input, if it is the first one to be
// specified or if errMsgs is undefined
function renderFormError(ev, input, errMsgs, errAttributes) {
if (!_.isUndefined(errMsgs)) {
// Mark error form group
$formGroup = $(input).closest(".form-group");
if (!$formGroup.hasClass("has-error")) {
$formGroup.addClass("has-error");
}
$(nameInput).after("<span class='help-block'" + errAttributes + ">" + errMsg + "</span>");
// Add error message/s
errAttributes = _.isUndefined(errAttributes) ? "" : " " + errAttributes;
error_text = ($.makeArray(errMsgs).map(function(m) {
return m.strToErrorFormat();
})).join("<br />");
$errSpan = "<span class='help-block'" + errAttributes + ">" + error_text + "</span>";
$formGroup.append($errSpan);
}
$form = $(nameInput).closest("form");
$tab = $(nameInput).closest(".tab-pane");
$form = $(input).closest("form");
$tab = $(input).closest(".tab-pane");
if ($tab.length) {
// Mark error tab
tabsPropagateErrorClass($form);
$parent = $tab;
} else {
@ -63,19 +55,19 @@ function renderFormError(ev, nameInput, errMsg, errAttributes) {
}
// Focus and scroll to the error if it is the first (most upper) one
if ($parent.find(".form-group.has-error").length === 1 || _.isUndefined(errMsg)) {
goToFormElement(nameInput);
if ($parent.find(".form-group.has-error").length === 1 || _.isUndefined(errMsgs)) {
goToFormElement(input);
}
// Don't submit form
ev.preventDefault();
ev.stopPropagation();
// Remove spinner if present
animateSpinner(null, false);
if(!_.isUndefined(ev)) {
// Don't submit form
ev.preventDefault();
ev.stopPropagation();
}
}
// If any of tabs (if exist) has errors, mark parent tab
// navigation link and show the tab (if not already)
// If any of form tabs (if exist) has errors, mark it and
// and show the first erroneous tab
function tabsPropagateErrorClass($form) {
var $contents = $form.find("div.tab-pane");
_.each($contents, function(tab) {
@ -89,5 +81,5 @@ function tabsPropagateErrorClass($form) {
}
}
});
$(".nav-tabs .has-error:first:not(.active) > a", $form).tab("show");
$form.find(".nav-tabs .has-error:first > a", $form).tab("show");
}

View file

@ -1,19 +1,19 @@
// Form validators. They'll find, render and focus error/s and
// prevent form submission.
function nameValidator(ev, $nameInput) {
var nameTooShort = $nameInput.val().length === 0;
var nameTooLong = $nameInput.val().length > 50;
function textValidator(ev, textInput, canBeBlank = false, limitLength = true) {
var nameTooShort = $(textInput).val().length === 0;
var nameTooLong = $(textInput).val().length > 50;
var errMsg;
if (nameTooShort) {
errMsg = I18n.t("devise.names.not_blank");
} else if (nameTooLong) {
errMsg = I18n.t("devise.names.length_too_long", { max_length: 50 });
if (!canBeBlank && nameTooShort) {
errMsg = I18n.t("general.text.not_blank");
} else if (limitLength && nameTooLong) {
errMsg = I18n.t("general.text.length_too_long", { max_length: 50 });
}
var hasErrors = !_.isUndefined(errMsg);
if (hasErrors) {
renderFormError(ev, $nameInput, errMsg);
renderFormError(ev, $(textInput), errMsg);
}
return !hasErrors;
}
@ -41,7 +41,7 @@ function checklistsValidator(ev, checklists, editMode) {
if (!$checklistNameInput.val()) {
if (anyChecklistItemFilled || editMode) {
// In edit mode, checklist's name can't be blank
var errMsg = I18n.t("devise.names.not_blank");
var errMsg = I18n.t("general.text.not_blank");
renderFormError(ev, $checklistNameInput, errMsg);
noErrors = false;
} else {
@ -55,62 +55,77 @@ function checklistsValidator(ev, checklists, editMode) {
return noErrors;
}
// Add JavaScript client-side upload file checking
// Callback function can be provided to be called
// any time at least one file size is not valid
$.fn.files_validator = function(callback) {
$.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, callback);
filesValidator(ev, $fileInputs, fileTypeEnum);
});
}
};
function filesValidator(ev, fileInputs, callback) {
var filesSizeValid = true;
function filesValidator(ev, fileInputs, fileTypeEnum) {
var filesValid = true;
if (fileInputs.length) {
var filesSizeValid = filesSizeValidator(ev, fileInputs);
var filesPresentValid = filesPresentValidator(ev, fileInputs);
var filesSizeValid = filesSizeValidator(ev, fileInputs, fileTypeEnum);
// TODO File content check
if (!filesSizeValid && callback) {
callback();
}
filesValid = filesPresentValid && filesSizeValid;
}
return filesSizeValid;
return filesValid;
}
function filesSizeValidator(ev, fileInputs) {
function filesPresentValidator(ev, fileInputs) {
var filesPresentValid = true;
_.each(fileInputs, function(fileInput) {
if (!fileInput.files[0]) {
assetError = I18n.t("general.file.blank");
renderFormError(ev, fileInput, assetError, "data-error='file-missing'");
filesPresentValid = false;
}
});
return filesPresentValid;
}
var FileTypeEnum = Object.freeze({
FILE: 0,
AVATAR: 1
});
function filesSizeValidator(ev, fileInputs, fileTypeEnum) {
function getFileTooBigError(file) {
if (!file) {
return ;
}
var size = parseInt(file.size);
<% sizeLimit = FILE_SIZE_LIMIT %>;
if (size > <%= sizeLimit.megabytes %>) {
return "<%= I18n.t 'general.file_size_exceeded', file_size: sizeLimit %>".strToFormFormat();
<% avatarSizerLimit = AVATAR_SIZE_LIMIT %>;
<% fileSizeLimit = FILE_SIZE_LIMIT %>;
if (fileTypeEnum == FileTypeEnum.FILE && size > <%= fileSizeLimit.megabytes %>) {
return "<%= I18n.t 'general.file.size_exceeded', file_size: fileSizeLimit %>".strToErrorFormat();
} else if (fileTypeEnum == FileTypeEnum.AVATAR && size > <%= avatarSizerLimit.megabytes %>) {
return "<%= I18n.t 'general.file.size_exceeded', file_size: avatarSizerLimit %>".strToErrorFormat();
}
};
// Check if any file exceeds allowed size limit
var fileSizeValid = true;
var filesSizeValid = true;
_.each(fileInputs, function(fileInput) {
var file = fileInput.files[0];
var assetError = getFileTooBigError(file);
var input = $(fileInput);
if (assetError) {
renderFormError(ev, input, assetError, "data-error='file-size'");
fileSizeValid = false;
renderFormError(ev, fileInput, assetError, "data-error='file-size'");
filesSizeValid = false;
}
});
if(fileSizeValid) {
if(filesSizeValid) {
// Check if there is enough free space for the files
fileSizeValid = enaughSpaceValidator(ev, fileInputs);
filesSizeValid = enaughSpaceValidator(ev, fileInputs);
}
return fileSizeValid;
return filesSizeValid;
}
// Overriden in billing module for checking

View file

@ -14,12 +14,12 @@ function truncateLongString( el, chars ) {
}
}
// Usefull for converting locals messages to form
// Usefull for converting locals messages to error
// format (i.e. lower cased capital and no dot)
String.prototype.strToFormFormat = function() {
String.prototype.strToErrorFormat = function() {
var length = this.length;
if (this[length - 1] === ".") {
length -= 1;
}
return this.charAt(0).toLowerCase() + this.slice(1, length);
}
}

View file

@ -0,0 +1,12 @@
// Converts JSON data received from the server
// to flat array of values
function jsonToValuesArray(jsonData) {
errMsgs =[];
for (var key in jsonData) {
var values = jsonData[key];
$.each(values, function(idx, val) {
errMsgs.push(val);
});
}
return errMsgs;
}

View file

@ -31,28 +31,29 @@ var forms = $("form[data-for]");
// Add "edit form" listeners
forms
.find("[data-action='edit']").click(function() {
var form = $(this).closest("form");
var $form = $(this).closest("form");
// First, hide all form edits
_.each(forms, function(form) {
toggleFormVisibility($(form), false);
toggleFormVisibility($form, false);
});
// Then, edit the current form
toggleFormVisibility(form, true);
toggleFormVisibility($form, true);
});
// Add "cancel form" listeners
forms
.find("[data-action='cancel']").click(function() {
var form = $(this).closest("form");
var $form = $(this).closest("form");
// Hide the edit portion of the form
toggleFormVisibility(form, false);
toggleFormVisibility($form, false);
});
// Add form submit listeners
forms
.not("[data-for='avatar']")
.on("ajax:success", function(ev, data, status) {
// Simply reload the page
location.reload();
@ -62,52 +63,28 @@ forms
$(this).render_form_errors("user", data.responseJSON);
});
// Add upload file size checking
$("form[data-for='avatar']").files_validator();
function processFile(ev, forS3) {
var $form = $(ev.target.form);
$form.clear_form_errors();
var $fileInput = $form.find("input[type=file]");
if(filesValidator(ev, $fileInput, FileTypeEnum.AVATAR)) {
if(forS3) {
// Redirects file uploading to S3
startFileUpload(ev, ev.target);
} else {
// Local file uploading
animateSpinner();
}
}
}
// S3 direct uploading
function startFileUpload(ev, btn) {
var form = btn.form;
var $form = $(form);
var fileInput = $form.find("input[type=file]").get(0);
var $form = $(btn.form);
var $fileInput = $form.find("input[type=file]");
var url = "/avatar_signature.json";
$form.clear_form_errors();
animateSpinner($form);
var noErrors = directUpload(form, null, url, function (assetId) {
var file = fileInput.files[0];
fileInput.type = "hidden";
fileInput.name = fileInput.name.replace("[avatar]", "[avatar_file_name]");
fileInput.value = file.name;
$("#user_change_avatar").remove();
btn.onclick = null;
$(btn).click();
animateSpinner($form, false);
}, function (errors) {
$form.render_form_errors("user", errors);
var avatarErrorMsg;
animateSpinner($form, false);
for (var c in errors) {
if (/^avatar/.test(c)) {
avatarErrorMsg = errors[c];
break;
}
}
if (avatarErrorMsg) {
var $el = $form.find("input[type=file]");
$form.clear_form_errors();
renderFormError(ev, $el, avatarErrorMsg);
}
}, "avatar");
return noErrors;
directUpload(ev, $fileInput, url, function () {
});
}

View file

@ -119,7 +119,7 @@ modal
})
.on("ajax:error", inviteExistingForm.selector, function(ev, data, status) {
// Display form errors
inviteExistingForm.render_form_errors_input_group("", data.responseJSON);
inviteExistingForm.render_form_errors("", data.responseJSON);
});
// Update values & enable "invite" button

View file

@ -6,26 +6,28 @@ class AssetsController < ApplicationController
respond_to do |format|
format.json {
if params[:asset_id]
asset = Asset.find_by_id params[:asset_id]
validationAsset = nil
if asset_params[:asset_id]
asset = Asset.find_by_id asset_params[:asset_id]
asset.file.destroy
asset.file_empty params[:file_name], params[:file_size]
asset.file_empty asset_params[:file].original_filename, asset_params[:file].size()
validationAsset = asset
else
asset = Asset.new_empty params[:file_name], params[:file_size]
# We can't verify file content (spoofing) of an empty
# file, so we use dummy validationAsset instead
asset = Asset.new_empty asset_params[:file].original_filename, asset_params[:file].size()
validationAsset = Asset.new(asset_params)
end
if not asset.valid?
errors = Hash[asset.errors.map{|k,v| ["asset.#{k}",v]}]
if not validationAsset.valid?
render json: {
status: 'error',
errors: errors
}
errors: validationAsset.errors
} , status: :bad_request
else
asset.save!
posts = generate_upload_posts asset
render json: {
asset_id: asset.id,
posts: posts
@ -149,4 +151,11 @@ class AssetsController < ApplicationController
posts
end
def asset_params
params.permit(
:asset_id,
:file
)
end
end

View file

@ -111,11 +111,18 @@ class ResultAssetsController < ApplicationController
update_params = result_params
previous_size = @result.space_taken
@result.asset.last_modified_by = current_user
if update_params.key? :asset_attributes
asset = Asset.find_by_id(update_params[:asset_attributes][:id])
asset.created_by = current_user
asset.last_modified_by = current_user
@result.asset = asset
end
@result.last_modified_by = current_user
@result.assign_attributes(update_params)
success_flash = t("result_assets.update.success_flash",
module: @my_module.name)
if @result.archived_changed?(from: false, to: true)
saved = @result.archive(current_user)
success_flash = t("result_assets.archive.success_flash",
@ -140,8 +147,6 @@ class ResultAssetsController < ApplicationController
saved = @result.save
if saved then
@result.reload
# Release organization's space taken due to
# previous asset being removed
org = @result.my_module.experiment.project.organization
@ -175,7 +180,6 @@ class ResultAssetsController < ApplicationController
}
format.json {
render json: {
status: 'ok',
html: render_to_string({
partial: "my_modules/result.html.erb", locals: {result: @result}
})

View file

@ -84,7 +84,7 @@ class ResultCommentsController < ApplicationController
format.html { render :new }
format.json {
render json: {
errors: @comment.errors.to_hash(true)
errors: @comment.errors
}
}
end

View file

@ -33,12 +33,21 @@ class StepsController < ApplicationController
step_data = step_params.except(:assets_attributes)
step_assets = step_params.slice(:assets_attributes)
@step = Step.new(step_data)
if step_assets.size > 0
step_assets[:assets_attributes].each do |i, data|
asset = Asset.find_by_id(data[:id])
asset.created_by = current_user
asset.last_modified_by = current_user
@step.assets << asset
# 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
asset.created_by = current_user
asset.last_modified_by = current_user
@step.assets << asset
end
end
end
else
@ -151,10 +160,10 @@ class StepsController < ApplicationController
if step_assets.include? :assets_attributes
step_assets[:assets_attributes].each do |i, data|
asset_id = data[:id]
asset = Asset.find_by_id(asset_id)
asset = Asset.find_by_id(data[:id])
unless @step.assets.include? asset or not asset
asset.created_by = current_user
asset.last_modified_by = current_user
@step.assets << asset
end
@ -203,18 +212,13 @@ class StepsController < ApplicationController
format.json {
render json: {
html: render_to_string({
partial: "steps/step.html.erb", locals: {step: @step}
})}, status: :ok
partial: "step.html.erb", locals: {step: @step}
})}
}
else
format.json {
render json: {
html: render_to_string({
partial: "edit.html.erb",
locals: {
direct_upload: @direct_upload
}
})}, status: :bad_request
render json: @step.errors,
status: :bad_request
}
end
end
@ -533,14 +537,13 @@ class StepsController < ApplicationController
attr_params = update_params[key]
for pos, attrs in params[key] do
assetExists = Asset.exists?(attrs[:id])
if attrs[:_destroy] == "1"
attr_params[pos] = {id: attrs[:id], _destroy: "1"}
params[key].delete(pos)
else
if has_destroy_params(params[key][pos])
attr_params[pos] = {id: attrs[:id]}
extract_destroy_params(params[key][pos], attr_params[pos])
end
params[key].delete(pos) if assetExists
elsif has_destroy_params(params[key][pos])
attr_params[pos] = {id: attrs[:id]} if assetExists
extract_destroy_params(params[key][pos], attr_params[pos])
end
end
end

View file

@ -1,5 +1,4 @@
class Users::RegistrationsController < Devise::RegistrationsController
before_action :load_paperclip_vars
def avatar
@ -14,13 +13,18 @@ class Users::RegistrationsController < Devise::RegistrationsController
# Changed avatar values are only used for pre-generating S3 key
# and user object is not persisted with this values.
current_user.empty_avatar params[:file_name], params[:file_size]
current_user.empty_avatar avatar_params[:file].original_filename, avatar_params[:file].size()
unless current_user.valid?
validationAsset = Asset.new(avatar_params)
unless current_user.valid? and validationAsset.valid?
if validationAsset.errors[:file].any?
# Add file content error
current_user.errors[:avatar] << validationAsset.errors[:file].first
end
render json: {
status: 'error',
errors: current_user.errors
}
}, status: :bad_request
else
render json: {
posts: generate_upload_posts
@ -107,7 +111,7 @@ class Users::RegistrationsController < Devise::RegistrationsController
format.json {
flash.keep
sign_in resource_name, resource, bypass: true
render json: { status: :ok }
render json: {}
}
else
clean_up_passwords resource
@ -116,7 +120,7 @@ class Users::RegistrationsController < Devise::RegistrationsController
}
format.json {
render json: self.resource.errors,
status: :unprocessable_entity
status: :bad_request
}
end
end
@ -189,6 +193,14 @@ class Users::RegistrationsController < Devise::RegistrationsController
)
end
def avatar_params
params.permit(
:file
)
end
# Generates posts for uploading files (many sizes of same file)
# to S3 server
def generate_upload_posts
posts = []
file_size = current_user.avatar_file_size
@ -237,4 +249,5 @@ class Users::RegistrationsController < Devise::RegistrationsController
def after_inactive_sign_up_path_for(resource)
new_user_session_path
end
end

View file

@ -11,7 +11,7 @@ module AssetsHelper
data-download-url='#{download_asset_path(asset)}'
>
<span class='asset-loading-spinner' id='asset-loading-spinner-#{asset.id}'></span>
#{t("general.file_loading", fileName: asset.file_file_name)}
#{t("general.file.loading", fileName: asset.file_file_name)}
</span>
<script type='text/javascript'>
$('#asset-loading-spinner-#{asset.id}').spin(

View file

@ -42,6 +42,10 @@ class Asset < ActiveRecord::Base
has_many :report_elements, inverse_of: :asset, dependent: :destroy
has_one :asset_text_datum, inverse_of: :asset, dependent: :destroy
# Specific file errors propagate to "fire" error hash key,
# so use just these errors
after_validation :filter_paperclip_errors
attr_accessor :file_content, :file_info, :preview_cached
def file_empty(name, size)
@ -263,6 +267,14 @@ class Asset < ActiveRecord::Base
private
def filter_paperclip_errors
if errors.size > 1
temp_errors = errors[:file]
errors.clear()
errors.set(:file, temp_errors)
end
end
def file_changed?
previous_changes.present? and
(

View file

@ -80,9 +80,10 @@ class User < ActiveRecord::Base
has_many :archived_protocols, class_name: 'Protocol', foreign_key: 'archived_by_id', inverse_of: :archived_by
has_many :restored_protocols, class_name: 'Protocol', foreign_key: 'restored_by_id', inverse_of: :restored_by
# Prevents repetition of errors after validation (e.g. if file_size
# exceeds limits, 2 same errors will be shown)
after_validation :clean_paperclip_errors
# If other errors besides parameter "avatar" exist,
# they will propagate to "avatar" also, so remove them
# and put all other (more specific ones) in it
after_validation :filter_paperclip_errors
def name
full_name
@ -160,8 +161,18 @@ class User < ActiveRecord::Base
self.avatar_file_size = size.to_i
end
def clean_paperclip_errors
errors.delete(:avatar)
def filter_paperclip_errors
if errors.key? :avatar
errors.delete(:avatar)
messages = []
errors.each do |attribute, error|
errors.full_messages_for(attribute).each do |message|
messages << message.split(' ').drop(1).join(' ')
end
end
errors.clear()
errors.set(:avatar, messages)
end
end
# Whether user is active (= confirmed) or not

View file

@ -7,11 +7,7 @@
<%= ff.file_field :file %>
<% end %>
<hr>
<% if direct_upload %>
<%= f.submit t("result_assets.edit.update"), class: 'btn btn-primary', onclick: 'startFileUpload(event, this);' %>
<% else %>
<%= f.submit t("result_assets.edit.update"), class: 'btn btn-primary', onclick: 'animateSpinner();' %>
<% end %>
<%= f.submit t("result_assets.edit.update"), class: 'btn btn-primary', onclick: "processResult(event, ResultTypeEnum.FILE, #{direct_upload});" %>
<button type="button" class="btn btn-default cancel-edit">
<%= t("general.cancel")%>
</button>

View file

@ -4,11 +4,7 @@
<%= f.fields_for :asset do |ff| %>
<%= ff.file_field :file %>
<% end %>
<% if direct_upload %>
<%= f.submit t("result_assets.new.create"), class: 'btn btn-primary', onclick: 'startFileUpload(event, this);' %>
<% else %>
<%= f.submit t("result_assets.new.create"), class: 'btn btn-primary', onclick: 'animateSpinner();' %>
<% end %>
<%= f.submit t("result_assets.new.create"), class: 'btn btn-primary', onclick: "processResult(event, ResultTypeEnum.FILE, #{direct_upload});" %>
<button type="button" class="btn btn-default cancel-new">
<%= t("general.cancel")%>
</button>

View file

@ -19,7 +19,7 @@
<li>
<hr>
<%= bootstrap_form_for :comment, url: { format: :json }, method: :post, remote: true do |f| %>
<%= f.text_field :message, hide_label: true, placeholder: t("general.comment_placeholder"), append: f.submit("+"), help: '.' %>
<%= f.text_field :message, hide_label: true, placeholder: t("general.comment_placeholder"), append: f.submit("+", onclick: "processResult(event, ResultTypeEnum.COMMENT);"), help: '.' %>
<% end %>
</li>
</ul>

View file

@ -9,7 +9,7 @@
<% end %>
</div>
<hr>
<%= f.submit t("result_tables.edit.update"), class: 'btn btn-primary' %>
<%= f.submit t("result_tables.edit.update"), class: 'btn btn-primary', onclick: "processResult(event, ResultTypeEnum.TABLE);" %>
<button type="button" class="btn btn-default cancel-edit">
<%= t("general.cancel")%>
</button>

View file

@ -8,7 +8,7 @@
</div>
<% end %>
</div>
<%= f.submit t("result_tables.new.create"), class: 'btn btn-primary' %>
<%= f.submit t("result_tables.new.create"), class: 'btn btn-primary', onclick: "processResult(event, ResultTypeEnum.TABLE);" %>
<button type="button" class="btn btn-default cancel-new">
<%= t("general.cancel")%>
</button>

View file

@ -5,7 +5,7 @@
<%= ff.text_area :text, style: "margin-top: 10px;" %><br />
<% end %>
<hr>
<%= f.submit t("result_texts.edit.update"), class: 'btn btn-primary' %>
<%= f.submit t("result_texts.edit.update"), class: 'btn btn-primary', onclick: "processResult(event, ResultTypeEnum.TEXT);" %>
<button type="button" class="btn btn-default cancel-edit">
<%= t("general.cancel")%>
</button>

View file

@ -4,7 +4,7 @@
<%= f.fields_for :result_text do |ff| %>
<%= ff.text_area :text, style: "margin-top: 10px;" %><br />
<% end %>
<%= f.submit t("result_texts.new.create"), class: 'btn btn-primary' %>
<%= f.submit t("result_texts.new.create"), class: 'btn btn-primary', onclick: "processResult(event, ResultTypeEnum.TEXT);" %>
<button type="button" class="btn btn-default cancel-new">
<%= t("general.cancel")%>
</button>

View file

@ -4,7 +4,7 @@
<hr>
<%= render partial: "empty_step.html.erb", locals: {step: @step, f: f} %>
<hr>
<%= f.submit t("protocols.steps.edit.edit_step"), class: 'btn btn-primary', onclick: "stepValidator(event, true, #{direct_upload});" %>
<%= f.submit t("protocols.steps.edit.edit_step"), class: 'btn btn-primary', onclick: "processStep(event, true, #{direct_upload});" %>
<a type="button" data-action="cancel-edit" class="btn btn-default" href="<%= step_path(id: @step, format: :json) %>" data-remote="true">
<%= t("general.cancel")%>
</a>

View file

@ -4,7 +4,7 @@
<hr>
<%= render partial: "empty_step.html.erb", locals: {step: @step, f: f} %>
<hr>
<%= f.submit t("protocols.steps.new.add_step"), class: 'btn btn-primary', onclick: "stepValidator(event, false, #{direct_upload});" %>
<%= f.submit t("protocols.steps.new.add_step"), class: 'btn btn-primary', onclick: "processStep(event, false, #{direct_upload});" %>
<button type="button" data-action="cancel-new" class="btn btn-default">
<%= t("general.cancel")%>
</button>

View file

@ -28,11 +28,7 @@
</div>
<div>
<a href="#" class="btn btn-default" data-action="cancel"><%=t "general.cancel" %></a>
<% if @direct_upload %>
<%= f.submit t("users.registrations.edit.avatar_submit"), class: "btn btn-primary", onclick: 'startFileUpload(event, this);' %>
<% else %>
<%= f.submit t("users.registrations.edit.avatar_submit"), class: "btn btn-primary" %>
<% end %>
<%= f.submit t("users.registrations.edit.avatar_submit"), class: 'btn btn-primary', onclick: "processFile(event, #{@direct_upload});" %>
</div>
</div>
</div>

View file

@ -24,6 +24,9 @@ TAG_COLORS = [
# Maximum uploaded file size in MB
FILE_SIZE_LIMIT = 50
# Maximum uploaded avatar size in MB
AVATAR_SIZE_LIMIT = 0.2
SEARCH_LIMIT = 20
SHOW_ALL_RESULTS = -1

View file

@ -18,9 +18,6 @@ en:
head_title: "Forgot password"
title: "Forgot your password?"
submit: "Send me reset password instructions"
names:
not_blank: "name can't be blank"
length_too_long: "name is too long (maximum is %{max_length} characters)"
registrations:
password_changed: "Password successfully updated."
sessions:
@ -1415,9 +1412,13 @@ en:
public: "public"
private: "private"
search: "Search"
file_size_exceeded: "File size must be less than %{file_size} MB."
file_blank: "Please select a file."
file_loading: "%{fileName} is loading..."
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: