Step creation is now completelly validated on client-side also, to avoid front-end problems after server-side validation. This was needed as lots of issues were caused by this. Also step creation user experience is enriched and refactoring of related code was done.

This commit is contained in:
Matej Zrimšek 2016-07-13 18:47:06 +02:00
parent da735a51f4
commit e2f94caa96
11 changed files with 154 additions and 48 deletions

View file

@ -311,6 +311,7 @@ function startFileUpload(ev, btn) {
var origAssetId = assetInput ? assetInput.value : null; var origAssetId = assetInput ? assetInput.value : null;
var url = '/asset_signature.json'; var url = '/asset_signature.json';
animateSpinner();
$form.clear_form_errors(); $form.clear_form_errors();
directUpload(form, origAssetId, url, function (assetId) { directUpload(form, origAssetId, url, function (assetId) {

View file

@ -588,33 +588,89 @@ $("[data-action='new-step']").on("ajax:success", function(e, data) {
}); });
}); });
// Needed because Paperclip deletes files on server-side validation fail (after trying to save the model) function nameValidator(event) {
function stepValidator( event ) { var form = $(event.target.form);
var nameTooShort = $( "#step_name" ).val().length === 0; var nameTooShort = $( "#step_name" ).val().length === 0;
var nameTooLong = $( "#step_name" ).val().length > 50; var nameTooLong = $( "#step_name" ).val().length > 50;
var errMsg; var errMsg;
if (nameTooShort) { if (nameTooShort) {
errMsg = I18n.t("devise.names.length_too_short"); errMsg = I18n.t("devise.names.not_blank");
animateSpinner(null,false);
} else if (nameTooLong) { } else if (nameTooLong) {
errMsg = I18n.t("devise.names.length_too_long", { max_length: 50 }); errMsg = I18n.t("devise.names.length_too_long", { max_length: 50 });
animateSpinner(null,false); animateSpinner(null,false);
} }
if (!_.isUndefined(errMsg)) { var hasErrors = !_.isUndefined(errMsg);
if(!$("#step_name_error_msg").length) { if (hasErrors) {
$("<span id='step_name_error_msg' class='help-block'>" + errMsg + "</span>").insertAfter("#step_name"); renderError($("#step_name"), errMsg, form);
$(".form-group:has(> #step_name)").addClass("has-error");
$("#new-step-main-tab").addClass("has-error");
} else {
$("#step_name_error_msg").html(errMsg);
} }
animateSpinner(null,false); return !hasErrors;
$("#new-step-main-tab a").click();
event.preventDefault();
} }
clearBlankElements(event.target.form); function checklistsValidator(event, editMode) {
var form = event.target.form;
$(form).clear_form_errors();
var noErrors = true;
// For every visible (i.e. not removed) checklist
$(form).find(".nested_step_checklists[style!='display: none']").each(function() {
var checklistNameInput = $(this).find(".checklist_name");
var checklistNameEmpty = !checklistNameInput.val();
anyChecklistItemFilled = false;
// For every ckecklist item input
$(" .checklist-item-text", $(this)).each(function() {
if($(this).val()) {
anyChecklistItemFilled = true;
} else {
// Remove empty checklist item
$(this).closest("fieldset").remove();
}
})
if(checklistNameEmpty) {
if(anyChecklistItemFilled || editMode) {
// In edit mode, name can't be blank
var errMsg = I18n.t("devise.names.not_blank");
renderError(checklistNameInput, errMsg, $(form));
noErrors = false;
} else {
// Hide empty checklist
$(this).hide();
}
}
});
$(event.target).blur();
return noErrors;
}
// Needed because server-side validation failure clears locations of
// files to be uploaded and checklist's items etc. Also user
// experience is improved
function localStepValidator(event, editMode) {
if(!editMode) {
// Most td's disappear when editing step and not pressing on
// edit tab, so we can't use this function
clearBlankTables(event.target.form)
}
clearBlankFileForms(event.target.form);
var checklistsValid = checklistsValidator(event, editMode);
var nameValid = nameValidator(event);
var noErrors = checklistsValid && nameValid;
if(noErrors) {
// Validations passed, so animate spinner for possible file
// uploading
animateSpinner();
}
return noErrors;
}
function S3StepValidator(event, editMode) {
if(localStepValidator(event, editMode)) {
startFileUpload(event, event.target);
}
} }
// Expand all steps // Expand all steps
@ -675,8 +731,9 @@ function startFileUpload(ev, btn) {
var inputPos = 0; var inputPos = 0;
var inputPointer = 0; var inputPointer = 0;
animateSpinner();
$form.clear_form_errors(); $form.clear_form_errors();
clearBlankElements(form); clearBlankFileForms(form);
function processFile () { function processFile () {
var fileInput = fileInputs.get(inputPos); var fileInput = fileInputs.get(inputPos);
@ -723,18 +780,19 @@ function startFileUpload(ev, btn) {
ev.preventDefault(); ev.preventDefault();
} }
// Clear empty fields in steps // Remove empty file forms in step
function clearBlankElements(form) { function clearBlankFileForms(form) {
// Remove empty checklist items $(form).find("input[type='file']").each(function () {
$(form).find(".checklist-item-text").each(function () { if (!this.files[0]) {
if ($(this).val() === ""){
$(this).closest("fieldset").remove(); $(this).closest("fieldset").remove();
} }
}); });
}
// Remove empty file forms // Remove empty tables in step
$(form).find("input[type='file']").each(function () { function clearBlankTables(form) {
if (!this.files[0]) { $(form).find(".nested_step_tables").each(function () {
if (!$(this).find("td:not(:empty)").length) {
$(this).closest("fieldset").remove(); $(this).closest("fieldset").remove();
} }
}); });

View file

@ -14,6 +14,7 @@ $.fn.render_form_errors_input_group = function(model_name, errors) {
$.fn.render_form_errors_no_clear = function(model_name, errors, input_group) { $.fn.render_form_errors_no_clear = function(model_name, errors, input_group) {
var form = $(this); var form = $(this);
var firstErr = true;
$.each(errors, function(field, messages) { $.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'); var name = $(el).attr('name');
@ -33,10 +34,22 @@ $.fn.render_form_errors_no_clear = function(model_name, errors, input_group) {
} else { } else {
input.parent().append(error_text); input.parent().append(error_text);
} }
if(firstErr) {
// Focus and scroll to the first error
input.focus();
firstErr = false;
$('html, body').animate({
scrollTop: input.closest(".form-group").offset().top
- ($(".navbar-fixed-top").outerHeight(true)
+ $(".navbar-secondary").outerHeight(true))
}, 2000);
}
}); });
}; };
$.fn.clear_form_errors = function() { $.fn.clear_form_errors = function() {
$(this).find('.nav.nav-tabs li').removeClass('has-error');
$(this).find('.form-group').removeClass('has-error'); $(this).find('.form-group').removeClass('has-error');
$(this).find('span.help-block').remove(); $(this).find('span.help-block').remove();
}; };
@ -79,10 +92,39 @@ $.fn.add_upload_file_size_check = function(callback) {
} }
}; };
// Show error message and mark error element and, if present, mark
// and show the tab where the error occured.
// 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 renderError(nameInput, errMsg, form) {
var errMsgSpan = nameInput.next(".help-block");
if(!errMsgSpan.length) {
nameInput.after("<span class='help-block'>" + errMsg + "</span>");
nameInput.closest(".form-group").addClass("has-error");
} else {
errMsgSpan.html(errMsg);
}
tabsPropagateErrorClass($(form));
// Focus and scroll to the error if it is the first (most upper) one
if($(form).find(".form-group.has-error").length === 1) {
nameInput.focus();
$('html, body').animate({
scrollTop: nameInput.closest(".form-group").offset().top
- ($(".navbar-fixed-top").outerHeight(true)
+ $(".navbar-secondary").outerHeight(true))
}, 2000);
}
event.preventDefault();
}
// If any of tabs has errors, add has-error class to // If any of tabs has errors, add has-error class to
// parent tab navigation link // parent tab navigation link
function tabsPropagateErrorClass(parent) { function tabsPropagateErrorClass(parent) {
var contents = parent.find("div.tab-pane"); var contents = parent.find("div.tab-pane");
if(contents.length) {
_.each(contents, function(tab) { _.each(contents, function(tab) {
var $tab = $(tab); var $tab = $(tab);
var errorFields = $tab.find(".has-error"); var errorFields = $tab.find(".has-error");
@ -96,3 +138,4 @@ function tabsPropagateErrorClass(parent) {
}); });
$(".nav-tabs .has-error:first > a", parent).tab("show"); $(".nav-tabs .has-error:first > a", parent).tab("show");
} }
}

View file

@ -1,6 +1,7 @@
/* Extending Bootstrap */ /* Extending Bootstrap */
@import "colors";
/* navbar avatar image */ /* navbar avatar image */
.navbar-nav .avatar { .navbar-nav .avatar {
border-radius: 30px; border-radius: 30px;
@ -11,6 +12,7 @@
top: 5px; top: 5px;
} }
.bootstrap-tagsinput > .label { /* Active tab with error should retain error color if clicked on again */
line-height: 2.3; .nav-tabs > li.active.has-error > a {
color: $color-apple-blossom;
} }

View file

@ -8,7 +8,7 @@
<% end %> <% end %>
<hr> <hr>
<% if direct_upload %> <% if direct_upload %>
<%= f.submit t("result_assets.edit.update"), class: 'btn btn-primary', onclick: 'animateSpinner(); startFileUpload(event, this);' %> <%= f.submit t("result_assets.edit.update"), class: 'btn btn-primary', onclick: 'startFileUpload(event, this);' %>
<% else %> <% else %>
<%= f.submit t("result_assets.edit.update"), class: 'btn btn-primary', onclick: 'animateSpinner();' %> <%= f.submit t("result_assets.edit.update"), class: 'btn btn-primary', onclick: 'animateSpinner();' %>
<% end %> <% end %>

View file

@ -5,7 +5,7 @@
<%= ff.file_field :file %> <%= ff.file_field :file %>
<% end %> <% end %>
<% if direct_upload %> <% if direct_upload %>
<%= f.submit t("result_assets.new.create"), class: 'btn btn-primary', onclick: 'animateSpinner(); startFileUpload(event, this);' %> <%= f.submit t("result_assets.new.create"), class: 'btn btn-primary', onclick: 'startFileUpload(event, this);' %>
<% else %> <% else %>
<%= f.submit t("result_assets.new.create"), class: 'btn btn-primary', onclick: 'animateSpinner();' %> <%= f.submit t("result_assets.new.create"), class: 'btn btn-primary', onclick: 'animateSpinner();' %>
<% end %> <% end %>

View file

@ -5,9 +5,9 @@
<%= render partial: "empty_step.html.erb", locals: {step: @step, f: f} %> <%= render partial: "empty_step.html.erb", locals: {step: @step, f: f} %>
<hr> <hr>
<% if direct_upload %> <% if direct_upload %>
<%= f.submit t("protocols.steps.edit.edit_step"), class: 'btn btn-primary', onclick: 'animateSpinner(); startFileUpload(event, this);' %> <%= f.submit t("protocols.steps.edit.edit_step"), class: 'btn btn-primary', onclick: 'S3StepValidator(event, true);' %>
<% else %> <% else %>
<%= f.submit t("protocols.steps.edit.edit_step"), class: 'btn btn-primary', onclick: 'animateSpinner(); stepValidator(event);' %> <%= f.submit t("protocols.steps.edit.edit_step"), class: 'btn btn-primary', onclick: 'localStepValidator(event, true);' %>
<% end %> <% end %>
<a type="button" data-action="cancel-edit" class="btn btn-default" href="<%= step_path(id: @step, format: :json) %>" data-remote="true"> <a type="button" data-action="cancel-edit" class="btn btn-default" href="<%= step_path(id: @step, format: :json) %>" data-remote="true">
<%= t("general.cancel")%> <%= t("general.cancel")%>

View file

@ -12,6 +12,8 @@
<% if ff.object.file.exists? %> <% if ff.object.file.exists? %>
<% if !(ff.object.file.content_type =~ /^image/).nil? %> <% if !(ff.object.file.content_type =~ /^image/).nil? %>
<%= image_tag ff.object.file.url(:medium) %> <%= image_tag ff.object.file.url(:medium) %>
<br>
<%= ff.object.file_file_name %>
<% else %> <% else %>
<%= ff.object.file_file_name %> <%= ff.object.file_file_name %>
<% end %> <% end %>

View file

@ -9,7 +9,7 @@
</div> </div>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<%= ff.text_field :name, label: t("protocols.steps.new.checklist_name"), autofocus: true, placeholder: t("protocols.steps.new.checklist_name_placeholder") %> <%= ff.text_field :name, label: t("protocols.steps.new.checklist_name"), class: "checklist_name", autofocus: true, placeholder: t("protocols.steps.new.checklist_name_placeholder") %>
<%= ff.label t("protocols.steps.new.checklist_items") %> <%= ff.label t("protocols.steps.new.checklist_items") %>
<ul> <ul>
<%= ff.nested_fields_for :checklist_items, ordered_checklist_items(ff.object) do |chkItems| %> <%= ff.nested_fields_for :checklist_items, ordered_checklist_items(ff.object) do |chkItems| %>

View file

@ -5,9 +5,9 @@
<%= render partial: "empty_step.html.erb", locals: {step: @step, f: f} %> <%= render partial: "empty_step.html.erb", locals: {step: @step, f: f} %>
<hr> <hr>
<% if direct_upload %> <% if direct_upload %>
<%= f.submit t("protocols.steps.new.add_step"), class: 'btn btn-primary', onclick: 'animateSpinner(); startFileUpload(event, this);' %> <%= f.submit t("protocols.steps.new.add_step"), class: 'btn btn-primary', onclick: 'S3StepValidator(event, false);' %>
<% else %> <% else %>
<%= f.submit t("protocols.steps.new.add_step"), id: "create-step", class: 'btn btn-primary', onclick: 'animateSpinner(); stepValidator(event);' %> <%= f.submit t("protocols.steps.new.add_step"), id: "create-step", class: 'btn btn-primary', onclick: 'localStepValidator(event, false);' %>
<% end %> <% end %>
<button type="button" data-action="cancel-new" class="btn btn-default"> <button type="button" data-action="cancel-new" class="btn btn-default">
<%= t("general.cancel")%> <%= t("general.cancel")%>

View file

@ -19,7 +19,7 @@ en:
title: "Forgot your password?" title: "Forgot your password?"
submit: "Send me reset password instructions" submit: "Send me reset password instructions"
names: names:
length_too_short: "Name can't be blank" not_blank: "Name can't be blank"
length_too_long: "Name is too long (maximum is %{max_length} characters)" length_too_long: "Name is too long (maximum is %{max_length} characters)"
registrations: registrations:
password_changed: "Password successfully updated." password_changed: "Password successfully updated."