diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 3a53d73bf..913a4182f 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -162,4 +162,4 @@ $(document).ready(function(){ $('.tree-link a').each( function(){ truncateLongString( $(this), 30); }); -}); \ No newline at end of file +}); diff --git a/app/assets/javascripts/direct-upload.js b/app/assets/javascripts/direct-upload.js index aa5278567..86ece445d 100644 --- a/app/assets/javascripts/direct-upload.js +++ b/app/assets/javascripts/direct-upload.js @@ -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)); - diff --git a/app/assets/javascripts/my_modules/results.js b/app/assets/javascripts/my_modules/results.js index 0382515d8..9a1551639 100644 --- a/app/assets/javascripts/my_modules/results.js +++ b/app/assets/javascripts/my_modules/results.js @@ -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 diff --git a/app/assets/javascripts/protocols/steps.js b/app/assets/javascripts/protocols/steps.js index 5eb24c046..c6efbe7fe 100644 --- a/app/assets/javascripts/protocols/steps.js +++ b/app/assets/javascripts/protocols/steps.js @@ -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; + }); } diff --git a/app/assets/javascripts/results/result_assets.js b/app/assets/javascripts/results/result_assets.js index 518fb5db3..f4b1c7a64 100644 --- a/app/assets/javascripts/results/result_assets.js +++ b/app/assets/javascripts/results/result_assets.js @@ -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("" + errors["asset.file"] + ""); - } -} - // 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(); diff --git a/app/assets/javascripts/sitewide/draw_components.js b/app/assets/javascripts/sitewide/draw_components.js new file mode 100644 index 000000000..5633ee4ce --- /dev/null +++ b/app/assets/javascripts/sitewide/draw_components.js @@ -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); +} diff --git a/app/assets/javascripts/sitewide/form_errors.js b/app/assets/javascripts/sitewide/form_errors.js index 65ec92f71..9384725c4 100644 --- a/app/assets/javascripts/sitewide/form_errors.js +++ b/app/assets/javascripts/sitewide/form_errors.js @@ -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 = ''; - error_text += (_.map(messages, function(m) { - return m.charAt(0).toUpperCase() + m.slice(1); - })).join('
'); - error_text += '
'; - 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("" + errMsg + ""); + + // Add error message/s + errAttributes = _.isUndefined(errAttributes) ? "" : " " + errAttributes; + error_text = ($.makeArray(errMsgs).map(function(m) { + return m.strToErrorFormat(); + })).join("
"); + $errSpan = "" + error_text + ""; + $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"); } diff --git a/app/assets/javascripts/sitewide/form_validators.js.erb b/app/assets/javascripts/sitewide/form_validators.js.erb index db86d5a71..04032badb 100644 --- a/app/assets/javascripts/sitewide/form_validators.js.erb +++ b/app/assets/javascripts/sitewide/form_validators.js.erb @@ -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 diff --git a/app/assets/javascripts/sitewide/string_utils.js b/app/assets/javascripts/sitewide/string_utils.js index 67156d05c..cab16e8d4 100644 --- a/app/assets/javascripts/sitewide/string_utils.js +++ b/app/assets/javascripts/sitewide/string_utils.js @@ -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); -} \ No newline at end of file +} diff --git a/app/assets/javascripts/sitewide/utils.js b/app/assets/javascripts/sitewide/utils.js new file mode 100644 index 000000000..072cae556 --- /dev/null +++ b/app/assets/javascripts/sitewide/utils.js @@ -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; +} diff --git a/app/assets/javascripts/users/registrations/edit.js b/app/assets/javascripts/users/registrations/edit.js index c88c521f6..c90cd2de8 100644 --- a/app/assets/javascripts/users/registrations/edit.js +++ b/app/assets/javascripts/users/registrations/edit.js @@ -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 () { + }); } - - diff --git a/app/assets/javascripts/users/settings/organizations/add_user_modal.js b/app/assets/javascripts/users/settings/organizations/add_user_modal.js index 9c36ec7d7..4a64091db 100644 --- a/app/assets/javascripts/users/settings/organizations/add_user_modal.js +++ b/app/assets/javascripts/users/settings/organizations/add_user_modal.js @@ -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 diff --git a/app/controllers/assets_controller.rb b/app/controllers/assets_controller.rb index b72ca1d2f..2f3d16e20 100644 --- a/app/controllers/assets_controller.rb +++ b/app/controllers/assets_controller.rb @@ -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 diff --git a/app/controllers/result_assets_controller.rb b/app/controllers/result_assets_controller.rb index 73a0055f9..362559976 100644 --- a/app/controllers/result_assets_controller.rb +++ b/app/controllers/result_assets_controller.rb @@ -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} }) diff --git a/app/controllers/result_comments_controller.rb b/app/controllers/result_comments_controller.rb index 8285a0b60..767a7124e 100644 --- a/app/controllers/result_comments_controller.rb +++ b/app/controllers/result_comments_controller.rb @@ -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 diff --git a/app/controllers/steps_controller.rb b/app/controllers/steps_controller.rb index c3a1395cb..1af1eb456 100644 --- a/app/controllers/steps_controller.rb +++ b/app/controllers/steps_controller.rb @@ -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 diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb index 3ad72659b..67a8a26d4 100644 --- a/app/controllers/users/registrations_controller.rb +++ b/app/controllers/users/registrations_controller.rb @@ -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 diff --git a/app/helpers/assets_helper.rb b/app/helpers/assets_helper.rb index 4ead88e69..8a9bdbace 100644 --- a/app/helpers/assets_helper.rb +++ b/app/helpers/assets_helper.rb @@ -11,7 +11,7 @@ module AssetsHelper data-download-url='#{download_asset_path(asset)}' > - #{t("general.file_loading", fileName: asset.file_file_name)} + #{t("general.file.loading", fileName: asset.file_file_name)}