diff --git a/code/forms/UserForm.php b/code/forms/UserForm.php index ce21a3a..7787f12 100644 --- a/code/forms/UserForm.php +++ b/code/forms/UserForm.php @@ -44,6 +44,13 @@ class UserForm extends Form { return $this->controller->LastEdited; } + /** + * @return boolean + */ + public function getDisplayErrorMessagesAtTop() { + return $this->controller->DisplayErrorMessagesAtTop; + } + /** * @return array */ @@ -66,11 +73,18 @@ class UserForm extends Form { public function getFormSteps() { $steps = new ArrayList(); - foreach ($this->controller->Fields()->filter('ClassName', 'EditableFormStep') as $step) { - $steps->push(array( - 'Title' => $step->Title, - 'Fields' => $this->getFormFields($step) - )); + foreach ($this->controller->Fields() as $field) { + if ($field instanceof EditableFormStep) { + $steps->push($field->getFormField()); + continue; + } + + if(empty($steps->last())) { + trigger_error('Missing first step in form', E_USER_WARNING); + $steps->push(CompositeField::create()); + } + + $steps->last()->push($field->getFormField()); } return $steps; @@ -81,18 +95,12 @@ class UserForm extends Form { * by using {@link updateFormFields()} on an {@link Extension} subclass which * is applied to this controller. * - * @param EditableFormStep $parent - * * @return FieldList */ - public function getFormFields($parent = null) { - if(!$parent) { - $parent = $this->controller; - } - + public function getFormFields() { $fields = new FieldList(); - foreach($parent->Fields() as $editableField) { + foreach($this->controller->Fields() as $editableField) { // get the raw form field from the editable version $field = $editableField->getFormField(); diff --git a/code/model/UserDefinedForm.php b/code/model/UserDefinedForm.php index a271286..9e3e2d1 100755 --- a/code/model/UserDefinedForm.php +++ b/code/model/UserDefinedForm.php @@ -367,26 +367,10 @@ class UserDefinedForm_Controller extends Page_Controller { $form = UserForm::create($this); $this->generateConditionalJavascript(); - $this->generateValidationJavascript($form); return $form; } - /** - * Build jQuery validation script and require as a custom script - * - * @param UserForm $form - */ - public function generateValidationJavascript(UserForm $form) { - // set the custom script for this form - Requirements::customScript( - $this - ->customise(array('Form' => $form)) - ->renderWith('ValidationScript'), - 'UserFormsValidation' - ); - } - /** * Generate the javascript for the conditional field show / hiding logic. * diff --git a/code/model/editableformfields/EditableFormStep.php b/code/model/editableformfields/EditableFormStep.php index c0b666f..242422e 100644 --- a/code/model/editableformfields/EditableFormStep.php +++ b/code/model/editableformfields/EditableFormStep.php @@ -18,14 +18,6 @@ class EditableFormStep extends EditableFormField { */ private static $plural_name = 'Steps'; - /** - * @config - * @var array - */ - private static $has_many = array( - 'Fields' => 'EditableFormField' - ); - /** * @return FieldList */ @@ -45,7 +37,7 @@ class EditableFormStep extends EditableFormField { * @return FormField */ public function getFormField() { - return false; + return CompositeField::create()->setTitle($this->Title); } /** diff --git a/javascript/UserForm.js b/javascript/UserForm.js index 28d88aa..df5baa3 100644 --- a/javascript/UserForm.js +++ b/javascript/UserForm.js @@ -5,18 +5,43 @@ jQuery(function ($) { // Settings that come from the CMS. var CONSTANTS = { - ERROR_CONTAINER_ID: '', - ENABLE_LIVE_VALIDATION: false, - DISPLAY_ERROR_MESSAGES_AT_TOP: false, - HIDE_FIELD_LABELS: false, - MESSAGES: {} + FORM_ID: 'UserForm_Form', // $Form.FormName.JS + ERROR_CONTAINER_ID: '', // $ErrorContainerID.JS + ENABLE_LIVE_VALIDATION: false, // $EnableLiveValidation + DISPLAY_ERROR_MESSAGES_AT_TOP: false, // $DisplayErrorMessagesAtTop + HIDE_FIELD_LABELS: false, // $HideFieldLabels + MESSAGES: {} // var meaasges + }; + + // TODO + // var messages = {<% loop $Fields %><% if $ErrorMessage && not $SetsOwnError %><% if $ClassName == 'EditableCheckboxGroupField' %> + // '{$Name.JS}[]': '{$ErrorMessage.JS}'<% if not Last %>,<% end_if %><% else %> + // '{$Name.JS}': '{$ErrorMessage.JS}'<% if not Last %>,<% end_if %><% end_if %><% end_if %><% end_loop %> + // }; + + // Common functions that extend multiple classes. + var commonMixin = { + /** + * @func show + * @desc Show the form step. Looks after aria attributes too. + */ + show: function () { + this.$el.attr('aria-hidden', false).show(); + }, + /** + * @func hide + * @desc Hide the form step. Looks after aria attributes too. + */ + hide: function () { + this.$el.attr('aria-hidden', true).hide(); + } }; /** * @func UserForm * @constructor - * @param object element - * @return object - The UserForm instance. + * @param {object} element + * @return {object} - The UserForm instance. * @desc The form */ function UserForm(element) { @@ -38,12 +63,53 @@ jQuery(function ($) { self.jumpToStep(stepNumber - 1); }); + this.$el.validate(this.validationOptions); + return this; } + /* + * Default options for step validation. These get extended in main(). + */ + UserForm.prototype.validationOptions = { + ignore: ':hidden', + errorClass: 'required', + errorElement: 'span', + errorPlacement: function (error, element) { + error.addClass('message'); + + if(element.is(':radio') || element.parents('.checkboxset').length > 0) { + error.insertAfter(element.closest('ul')); + } else { + error.insertAfter(element); + } + }, + success: function (error) { + var errorId = $(error).attr('id'); + + error.remove(); + + if (CONSTANTS.DISPLAY_ERROR_MESSAGES_AT_TOP) { + // Pass the field's ID with the event. + $('.userform').trigger('userform.form.valid', [errorId.substr(0, errorId.indexOf('-error'))]); + } + }, + messages: CONSTANTS.MESSAGES, + rules: { + // TODO + // <% loop $Fields %> + // <% if $Validation %><% if ClassName == EditableCheckboxGroupField %> + // '{$Name.JS}[]': {$ValidationJSON.RAW}, + // <% else %> + // '{$Name.JS}': {$ValidationJSON.RAW}, + // <% end_if %><% end_if %> + // <% end_loop %> + } + }; + /** * @func UserForm.addStep - * @param object step - An instance of FormStep. + * @param {object} step - An instance of FormStep. * @desc Adds a step to the UserForm. */ UserForm.prototype.addStep = function (step) { @@ -57,7 +123,7 @@ jQuery(function ($) { /** * @func UserForm.setCurrentStep - * @param object step - An instance of FormStep. + * @param {object} step - An instance of FormStep. * @desc Sets the step the user is currently on. */ UserForm.prototype.setCurrentStep = function (step) { @@ -76,7 +142,7 @@ jQuery(function ($) { /** * @func UserForm.jumpToStep - * @param number stepNumber + * @param {number} stepNumber * @desc Jumps to a specific form step. */ UserForm.prototype.jumpToStep = function (stepNumber) { @@ -116,11 +182,105 @@ jQuery(function ($) { this.jumpToStep(this.steps.indexOf(this.currentStep) - 1); }; + /** + * @func ErrorContainer + * @constructor + * @param {object} element - The error container element. + * @return {object} - The ErrorContainer instance. + * @desc Creates an error container. Used to display step error messages at the top. + */ + function ErrorContainer(element) { + this.$el = element instanceof jQuery ? element : $(element); + + // Set the error container's heading. + this.$el.find('h4').text(ss.i18n._t('UserForms.ERROR_CONTAINER_HEADER', 'Please correct the following errors and try again:')); + + return this; + } + + /** + * @func hasErrors + * @return boolean + * @desc Checks if the error container has any error messages. + */ + ErrorContainer.prototype.hasErrors = function () { + return this.$el.find('.error-list').children().length > 0; + }; + + /** + * @func removeErrorMessage + * @desc Removes an error message from the error container. + */ + ErrorContainer.prototype.removeErrorMessage = function (fieldId) { + this.$el.find('#' + fieldId + '-top-error').remove(); + + // If there are no more error then hide the container. + if (!this.hasErrors()) { + this.hide(); + } + }; + + /** + * @func ErrorContainer.updateErrorMessage + * @param {object} $input - The jQuery input object which contains the field to validate. + * @param {object} message - The error message to display (html escaped). + * @desc Update an error message (displayed at the top of the form). + */ + ErrorContainer.prototype.updateErrorMessage = function ($input, message) { + var inputID = $input.attr('id'), + anchor = '#' + inputID, + elementID = inputID + '-top-error', + messageElement = $('#' + elementID), + describedBy = $input.attr('aria-describedby'); + + // The 'message' param will be an empty string if the field is valid. + if (!message) { + // Style issues as fixed if they already exist + messageElement.addClass('fixed'); + return; + } + + messageElement.removeClass('fixed'); + + this.show(); + + if (messageElement.length === 1) { + // Update the existing error message. + messageElement.show().find('a').html(message); + } else { + // Generate better link to field + $input.closest('.field[id]').each(function(){ + anchor = '#' + $(this).attr('id'); + }); + + // Add a new error message + messageElement = $('
  • '); + messageElement + .attr('id', elementID) + .find('a') + .attr('href', location.pathname + location.search + anchor) + .html(message); + + this.$el.find('ul').append(messageElement); + + // link back to original input via aria + // Respect existing non-error aria-describedby + if (!describedBy) { + describedBy = elementID; + } else if (!describedBy.match(new RegExp('\\b' + elementID + '\\b'))) { + // Add to end of list if not already present + describedBy += " " + elementID; + } + + $input.attr('aria-describedby', describedBy); + } + }; + /** * @func FormStep * @constructor - * @param object element - * @return object - The FormStep instance. + * @param {object} element + * @return {object} - The FormStep instance. * @desc Creates a form step. */ function FormStep(element) { @@ -143,71 +303,36 @@ jQuery(function ($) { self.$el.trigger('userform.step.next'); }); - // Set up validation for the step. - this.$el.validate(this.validationOptions); + if (CONSTANTS.DISPLAY_ERROR_MESSAGES_AT_TOP) { + this.errorContainer = new ErrorContainer(this.$el.find('.error-container')); + + // Listen for errors on the UserForm. + this.$el.closest('.userform').on('userform.form.error', function (e, validator) { + // The step only cares about errors if it's currently visible. + if (!self.$el.is(':visible')) { + return; + } + + // Add or update each error in the list. + $.each(validator.errorList, function (i, error) { + self.errorContainer.updateErrorMessage($(error.element), error.message); + }); + }); + + // Listen for fields becoming valid + this.$el.closest('.userform').on('userform.form.valid', function (e, fieldId) { + self.errorContainer.removeErrorMessage(fieldId); + }); + } return this; } - /* - * Default options for step validation. These get extended in main(). - */ - FormStep.prototype.validationOptions = { - ignore: ':hidden', - errorClass: 'required', - errorElement: 'span', - errorPlacement: function (error, element) { - error.addClass('message'); - - if(element.is(':radio') || element.parents('.checkboxset').length > 0) { - error.insertAfter(element.closest('ul')); - } else { - error.insertAfter(element); - } - - if (CONSTANTS.DISPLAY_ERROR_MESSAGES_AT_TOP) { - // TODO - //applyTopErrorMessage(element, error.html()); - } - - }, - success: function (error) { - error.remove(); - }, - messages: CONSTANTS.MESSAGES, - rules: { - // TODO - // <% loop $Fields %> - // <% if $Validation %><% if ClassName == EditableCheckboxGroupField %> - // '{$Name.JS}[]': {$ValidationJSON.RAW}, - // <% else %> - // '{$Name.JS}': {$ValidationJSON.RAW}, - // <% end_if %><% end_if %> - // <% end_loop %> - } - }; - - /** - * @func FormStep.show - * @desc Show the form step. Looks after aria attributes too. - */ - FormStep.prototype.show = function () { - this.$el.attr('aria-hidden', false).show(); - }; - - /** - * @func FormStep.hide - * @desc Hide the form step. Looks after aria attributes too. - */ - FormStep.prototype.hide = function () { - this.$el.attr('aria-hidden', true).hide(); - }; - /** * @func ProgressBar * @constructor - * @param object element - * @return object - The Progress bar instance. + * @param {object} element + * @return {object} - The Progress bar instance. * @desc Creates a progress bar. */ function ProgressBar(element) { @@ -236,7 +361,7 @@ jQuery(function ($) { /** * @func ProgressBar.update - * @param number newStep + * @param {number} newStep * @desc Update the progress element to show a new step. */ ProgressBar.prototype.update = function (newStep) { @@ -274,13 +399,17 @@ jQuery(function ($) { * @desc Bootstraps the front-end. */ function main() { - var userform = new UserForm($('.userform')), - progressBar = new ProgressBar($('#userform-progress')); + var userform = null, + progressBar = null; + + // Extend classes with common functionality. + $.extend(FormStep.prototype, commonMixin); + $.extend(ErrorContainer.prototype, commonMixin); // Extend the default validation options with conditional options // that are set by the user in the CMS. if (CONSTANTS.ENABLE_LIVE_VALIDATION) { - $.extend(FormStep.prototype.validationOptions, { + $.extend(UserForm.prototype.validationOptions, { onfocusout: function (element) { this.element(element); } @@ -288,24 +417,28 @@ jQuery(function ($) { } if (CONSTANTS.DISPLAY_ERROR_MESSAGES_AT_TOP) { - $.extend(FormStep.prototype.validationOptions, { + $.extend(UserForm.prototype.validationOptions, { + // Callback for custom code when an invalid form / step is submitted. invalidHandler: function (event, validator) { - var errorList = $('#' + CONSTANTS.ERROR_CONTAINER_ID + ' ul'); - - // Update the error list with errors from the validator. - // We do this because top messages are not part of the regular - // error message life cycle, which jquery.validate handles for us. - errorList.empty(); - - $.each(validator.errorList, function () { - // TODO - //applyTopErrorMessage($(this.element), this.message); - }); + $('.userform').trigger('userform.form.error', [validator]); }, onfocusout: false }); } + userform = new UserForm($('.userform')); + progressBar = new ProgressBar($('#userform-progress')); + + // Conditionally hide field labels and use HTML5 placeholder instead. + if (CONSTANTS.HIDE_FIELD_LABELS) { + $('#' + CONSTANTS.FORM_ID + ' label.left').each(function () { + var $label = $(this); + + $('[name="' + $label.attr('for') + '"]').attr('placeholder', $label.text()); + $label.remove(); + }); + } + // Display all the things that are hidden when JavaScript is disabled. $.each(['#userform-progress', '.step-navigation'], function (i, selector) { $(selector).attr('aria-hidden', false).show(); @@ -327,6 +460,17 @@ jQuery(function ($) { userform.$el.children('.Actions').attr('aria-hidden', true).hide(); } + // Enable jQuery UI datepickers + $(document).on('click', 'input.text[data-showcalendar]', function() { + var $element = $(this); + + $element.ssDatepicker(); + + if($element.data('datepicker')) { + $element.datepicker('show'); + } + }); + // Make sure the form doesn't expire on the user. Pings every 3 mins. setInterval(function () { $.ajax({ url: 'UserDefinedForm_Controller/ping' }); diff --git a/templates/UserForm.ss b/templates/UserForm.ss index 692e385..8aa1154 100644 --- a/templates/UserForm.ss +++ b/templates/UserForm.ss @@ -5,7 +5,7 @@ <% if $Message %>

    $Message

    <% else %> - + <% end_if %>
    @@ -13,10 +13,21 @@ <% loop $FormSteps %>
    + <% if $Top.DisplayErrorMessagesAtTop %> + + <% end_if %> +

    $Title

    - <% loop $Fields %> + + <% loop $Children %> $FieldHolder <% end_loop %> + <% include UserFormStepNav ContainingPage=$Top %>
    <% end_loop %> diff --git a/templates/ValidationScript.ss b/templates/ValidationScript.ss deleted file mode 100644 index 2e02cb8..0000000 --- a/templates/ValidationScript.ss +++ /dev/null @@ -1,157 +0,0 @@ -(function($) { - $(document).ready(function() { - var formId = "{$Form.FormName.JS}", - errorContainerId = "{$ErrorContainerID.JS}", - errorContainer = $('

      '); - - var messages = {<% loop $Fields %><% if $ErrorMessage && not $SetsOwnError %><% if $ClassName == 'EditableCheckboxGroupField' %> - '{$Name.JS}[]': '{$ErrorMessage.JS}'<% if not Last %>,<% end_if %><% else %> - '{$Name.JS}': '{$ErrorMessage.JS}'<% if not Last %>,<% end_if %><% end_if %><% end_if %><% end_loop %> - }; - - $(document).on("click", "input.text[data-showcalendar]", function() { - $(this).ssDatepicker(); - - if($(this).data('datepicker')) { - $(this).datepicker('show'); - } - }); - - $("#" + formId).validate({ - ignore: ':hidden', - errorClass: "required", - errorElement: "span", - errorPlacement: function(error, element) { - error.addClass('message'); - - if(element.is(":radio") || element.parents(".checkboxset").length > 0) { - error.insertAfter(element.closest("ul")); - } else { - error.insertAfter(element); - } - - <% if $DisplayErrorMessagesAtTop %> - applyTopErrorMessage(element, error.html()); - <% end_if %> - }, - success: function (error) { - error.remove(); - }, - messages: messages, - rules: { - <% loop $Fields %> - <% if $Validation %><% if ClassName == EditableCheckboxGroupField %> - '{$Name.JS}[]': {$ValidationJSON.RAW}, - <% else %> - '{$Name.JS}': {$ValidationJSON.RAW}, - <% end_if %><% end_if %> - <% end_loop %> - } - - /* - * Conditional options. - * Using leading commas so we don't get a trailing comma on - * the last option. Trailing commas can break IE. - */ - <% if $EnableLiveValidation %> - // Enable live validation - ,onfocusout: function (element) { this.element(element); } - <% end_if %> - - <% if $DisplayErrorMessagesAtTop %> - ,invalidHandler: function (event, validator) { - var errorList = $('#' + errorContainerId + ' ul'); - - // Update the error list with errors from the validator. - // We do this because top messages are not part of the regular - // error message life cycle, which jquery.validate handles for us. - errorList.empty(); - - $.each(validator.errorList, function () { - applyTopErrorMessage($(this.element), this.message); - }); - } - ,onfocusout: false - <% end_if %> - }); - - <% if $HideFieldLabels %> - // Hide field labels (use HTML5 placeholder instead) - $("#" + formId + "label.left").each(function() { - $("#"+$(this).attr("for")) - .attr("placeholder", $(this).text()); - $(this).remove(); - }); - Placeholders.init(); - <% end_if %> - - <% if $DisplayErrorMessagesAtTop %> - /** - * @applyTopErrorMessage - * @param {jQuery} input - The jQuery input object which contains the field to validate - * @param {string} message - The error message to display (html escaped) - * @desc Update an error message (displayed at the top of the form). - */ - function applyTopErrorMessage(input, message) { - var inputID = input.attr('id'), - anchor = '#' + inputID, - elementID = inputID + '-top-error', - errorContainer = $('#' + errorContainerId), - messageElement = $('#' + elementID), - describedBy = input.attr('aria-describedby'); - - // The 'message' param will be an empty string if the field is valid. - if (!message) { - // Style issues as fixed if they already exist - messageElement.addClass('fixed'); - return; - } - - messageElement.removeClass('fixed'); - errorContainer.show(); - - if (messageElement.length === 1) { - // Update the existing error message. - messageElement.show().find('a').html(message); - } else { - // Generate better link to field - input.closest('.field[id]').each(function(){ - anchor = '#' + $(this).attr('id'); - }); - - // Add a new error message - messageElement = $('
    • '); - messageElement - .attr('id', elementID) - .find('a') - .attr('href', location.pathname + location.search + anchor) - .html(message); - errorContainer - .find('ul') - .append(messageElement); - - // link back to original input via aria - // Respect existing non-error aria-describedby - if ( !describedBy ) { - describedBy = elementID; - } else if ( !describedBy.match( new RegExp( "\\b" + elementID + "\\b" ) ) ) { - // Add to end of list if not already present - describedBy += " " + elementID; - } - input.attr( "aria-describedby", describedBy ); - } - } - - // Build container - errorContainer - .hide() - .attr('id', errorContainerId) - .find('h4') - .text(ss.i18n._t( - "UserForms.ERROR_CONTAINER_HEADER", - "Please correct the following errors and try again:" - )); - $('#' + formId).prepend(errorContainer); - <% end_if %> - }); -})(jQuery);