`);
-
- $itemElement.on('click', (e) => {
- e.preventDefault();
- userform.jumpToStep(step.id);
- });
-
- this.$el.find('.error-list').append($itemElement);
- };
-
- /**
- * @func removeStepLink
- * @param {object} step - FormStep instance.
- * @desc Removes a step link from the error container.
- */
- ErrorContainer.prototype.removeStepLink = function removeStepLink(fieldId) {
- const stepID = $(`#${fieldId}`).closest('.form-step').attr('id');
-
- this.$el.find(`#${stepID}-error-link`).remove();
-
- // Hide the error container if we've just removed the last error.
- if (this.$el.find('.error-list').is(':empty')) {
- 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 updateErrorMessage($input, message) {
- const inputID = $input.attr('id');
- let anchor = `#${inputID}`;
- const elementID = `${inputID}-top-error`;
- let messageElement = $(`#${elementID}`);
- let 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(() => {
- const anchorID = $(this).attr('id');
-
- if (!anchorID) {
- return;
- }
-
- anchor = `#${anchorID}`;
- });
-
- // 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.
- * @desc Creates a form step.
- */
- function FormStep(element) {
- const self = this;
-
- this.$el = element instanceof $ ? element : $(element);
-
- const userform = this.$el.closest('.userform').data('inst');
-
- // Find button for this step
- this.$elButton = $(`.step-button-wrapper[data-for='${this.$el.prop('id')}']`);
-
- // Has the step been viewed by the user?
- this.viewed = false;
-
- // Is the form step valid?
- // This value is used on form submission, which fails, if any of the steps are invalid.
- this.valid = false;
-
- // The internal id of the step. Used for getting the step from the UserForm.steps array.
- this.id = null;
-
- this.hide();
-
- if (CONSTANTS.DISPLAY_ERROR_MESSAGES_AT_TOP) {
- this.errorContainer = new ErrorContainer(this.$el.find('.error-container'));
-
- // Listen for errors on the UserForm.
- userform.$el.on('userform.form.error', (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, (i, error) => {
- self.errorContainer.updateErrorMessage($(error.element), error.message);
- });
- });
-
- // Listen for fields becoming valid
- userform.$el.on('userform.form.valid', (e, fieldId) => {
- self.errorContainer.removeErrorMessage(fieldId);
- });
- }
-
- // Ensure that page visibilty updates the step navigation
- this
- .$elButton
- .on('userform.field.hide userform.field.show', () => {
- userform.$el.trigger('userform.form.conditionalstep');
- });
-
- return this;
- }
-
- /**
- * Determine if this step is conditionally disabled
- *
- * @returns {Boolean}
- */
- // Because the element itself could be visible but 0 height, so check visibility of button
- FormStep.prototype.conditionallyHidden = function conditionallyHidden() {
- return !this.$elButton.find('button').is(':visible');
- };
-
- /**
- * @func ProgressBar
- * @constructor
- * @param {object} element
- * @return {object} - The Progress bar instance.
- * @desc Creates a progress bar.
- */
- function ProgressBar(element) {
- const self = this;
-
- this.$el = element instanceof $ ? element : $(element);
- this.$buttons = this.$el.find('.step-button-jump');
- this.$jsAlign = this.$el.find('.js-align');
- const userform = this.$el.closest('.userform').data('inst');
-
- // Update the progress bar when 'step' buttons are clicked.
- this.$buttons.each((i, stepButton) => {
- $(stepButton).on('click', (e) => {
+ init() {
+ this.dom.style.display = 'initial';
+ const buttons = this.buttons;
+ buttons.forEach((button) => {
+ button.addEventListener('click', (e) => {
e.preventDefault();
- const stepNumber = parseInt($(e.target).data('step'), 10);
- self.$el.trigger('userform.progress.changestep', stepNumber);
+ const stepNumber = parseInt(button.getAttribute('data-step'), 10);
+ this.userForm.jumpToStep(stepNumber - 1);
+ return false;
});
});
-
- // Update the progress bar when 'prev' and 'next' buttons are clicked.
- userform.$el.on('userform.form.changestep', (e, stepID) => {
- self.update(stepID);
+ this.userForm.dom.addEventListener('userform.form.changestep', (e) => {
+ this.update(e.detail.stepId);
});
-
- // Listen for steps being conditionally shown / hidden by display rules.
- // We need to update step related UI like the number of step buttons
- // and any text that shows the total number of steps.
- userform.$el.on('userform.form.conditionalstep', () => {
- // Update the step numbers on the buttons.
- const $visibleButtons = self.$buttons.filter(':visible');
-
- $visibleButtons.each((i, button) => {
- $(button).text(i + 1);
- });
-
- // Update the actual progress bar.
- self.$el.find('.progress-bar').attr('aria-valuemax', $visibleButtons.length);
-
- // Update any text that uses the total number of steps.
- self.$el.find('.total-step-number').text($visibleButtons.length);
- });
-
- // Spaces out the steps below progress bar evenly
- this.$jsAlign.each((index, button) => {
- const $button = $(button);
- const leftPercent = (100 / (self.$jsAlign.length - 1)) * index;
- const leftPercentCssValue = `${leftPercent}%`;
- const buttonOffset = -1 * ($button.innerWidth() / 2);
-
- $button.css({
- left: leftPercentCssValue,
- marginLeft: buttonOffset,
- });
-
- // First and last buttons are kept within userform-progress container
- if (index === self.$jsAlign.length - 1) {
- $button.css({ marginLeft: buttonOffset * 2 });
- } else if (index === 0) {
- $button.css({ marginLeft: 0 });
- }
- });
-
- return this;
+ this.update(0);
}
- /**
- * @func ProgressBar.update
- * @param {number} stepID - Zero based index of the new step.
- * @desc Update the progress element to show a new step.
- */
- ProgressBar.prototype.update = function update(stepID) {
- const $newStepElement = $(this.$el.parent('.userform').find('.form-step')[stepID]);
- let stepNumber = 0;
- let barWidth = (stepID / (this.$buttons.length - 1)) * 100;
+ update(stepId) {
+ const stepNumber = this.userForm.getCurrentStepID() + 1;
+ const newStep = this.userForm.getStep(stepId);
+ const newStepElement = newStep.step;
+ let barWidth = (stepId / (this.buttons.length - 1)) * 100;
- // Set the current step number.
- this.$buttons.each((i, button) => {
- if (i > stepID) {
- // Break the loop
- return false;
+ this.currentStepNumber.innerText = stepNumber;
+
+ this.dom.querySelectorAll('[aria-valuenow]').forEach((e) => {
+ e.setAttribute('aria-valuenow', stepNumber);
+ });
+
+ this.buttons.forEach((button) => {
+ const btn = button;
+ const parent = btn.parentNode;
+ if (parseInt(btn.getAttribute('data-step'), 10) === stepNumber
+ && isVisible(btn)) {
+ parent.classList.add('current');
+ parent.classList.add('viewed');
+
+ btn.disabled = false;
}
-
- if ($(button).is(':visible')) {
- stepNumber += 1;
- }
- return true;
+ parent.classList.remove('current');
});
- // Update elements that contain the current step number.
- this.$el.find('.current-step-number').each((i, element) => {
- $(element).text(stepNumber);
- });
-
- // Update aria attributes.
- this.$el.find('[aria-valuenow]').each((i, element) => {
- $(element).attr('aria-valuenow', stepNumber);
- });
-
- // Update the CSS classes on step buttons.
- this.$buttons.each((i, element) => {
- const $element = $(element);
- const $item = $element.parent();
-
- if (parseInt($element.data('step'), 10) === stepNumber && $element.is(':visible')) {
- $item.addClass('current viewed');
- $element.removeAttr('disabled');
-
- return;
- }
-
- $item.removeClass('current');
- });
-
- // Update the progress bar's title with the new step's title.
- this.$el.siblings('.progress-title').text($newStepElement.data('title'));
+ this.progressTitle.innerText = newStepElement.getAttribute('data-title');
// Update the width of the progress bar.
barWidth = barWidth ? `${barWidth}%` : '';
- this.$el.find('.progress-bar').width(barWidth);
- };
+ this.dom.querySelector('.progress-bar').style.width = barWidth;
+ }
+}
- /**
- * @func FormActions
- * @constructor
- * @param {object} element
- * @desc Creates the navigation and actions (Prev, Next, Submit buttons).
- */
- function FormActions(element) {
- const self = this;
+class FormStep {
+ constructor(step, userForm) {
+ this.step = step;
+ this.userForm = userForm;
+ this.viewed = false;
+ this.buttonHolder = null;
+ this.id = 0;
- this.$el = element instanceof $ ? element : $(element);
- const $elFormItself = this.$el.closest('.userform');
-
- this.userformInstance = $elFormItself.data('inst');
-
- this.$prevButton = this.$el.find('.step-button-prev');
- this.$nextButton = this.$el.find('.step-button-next');
-
- // Show the buttons.
- this.$prevButton.parent().attr('aria-hidden', false).show();
- this.$nextButton.parent().attr('aria-hidden', false).show();
-
- // Scroll up to the next page...
- const scrollUpFx = function () {
- const scrollTop = $elFormItself.offset();
- $('html, body').animate({ scrollTop: scrollTop.top }, 'slow');
- };
-
- // Bind the step navigation event listeners.
- this.$prevButton.on('click', (e) => {
- e.preventDefault();
- scrollUpFx();
- self.$el.trigger('userform.action.prev');
- });
- this.$nextButton.on('click', (e) => {
- e.preventDefault();
- scrollUpFx();
- self.$el.trigger('userform.action.next');
- });
-
- // Listen for changes to the current form step, or conditional pages,
- // so we can show hide buttons appropriately.
- this.userformInstance.$el.on('userform.form.changestep userform.form.conditionalstep', () => {
- self.update();
- });
-
- return this;
+ this.init();
}
- /**
- * @func FormActions.update
- * @param {number} stepID - Zero based ID of the current step.
- * @desc Updates the form actions element to reflect the current state of the page.
- */
- FormActions.prototype.update = function update() {
- const numberOfSteps = this.userformInstance.steps.length;
- const stepID = this.userformInstance.currentStep ? this.userformInstance.currentStep.id : 0;
+ init() {
+ const id = this.getHTMLId();
+ this.buttonHolder = document.querySelector(`.step-button-wrapper[data-for='${id}']`);
+ if (this.buttonHolder) {
+ ['userform.field.hide', 'userform.field.show'].forEach((action) => {
+ this.buttonHolder.addEventListener(action, () => {
+ this.userForm.dom.trigger('userform.form.conditionalstep');
+ });
+ });
+ }
+ }
+
+ setId(id) {
+ this.id = id;
+ }
+
+ getHTMLId() {
+ return this.step.getAttribute('id');
+ }
+
+ show() {
+ this.step.setAttribute('aria-hidden', false);
+ this.step.classList.remove('hide');
+ this.step.classList.add('viewed');
+ this.viewed = true;
+ }
+
+ hide() {
+ this.step.setAttribute('aria-hidden', true);
+ this.step.classList.add('hide');
+ }
+
+ conditionallyHidden() {
+ const button = this.buttonHolder.querySelector('button');
+ return !(button.style.display !== 'none' && button.visibility !== 'hidden' && !button.classList.contains('hide'));
+ }
+
+
+ getValidatorType(input) {
+ if (input.getAttribute('type') === 'email') {
+ return 'email';
+ }
+ if (input.getAttribute('type') === 'date') {
+ return 'date';
+ }
+ if (input.getAttribute('type') === 'file') {
+ return 'object';
+ }
+ if (input.classList.contains('numeric') || input.getAttribute('type') === 'numeric') {
+ return 'number';
+ }
+ return 'string';
+ }
+
+ getValidatorMessage(input) {
+ if (input.getAttribute('data-msg-required')) {
+ return input.getAttribute('data-msg-required');
+ }
+ return `${this.getFieldLabel(input)} is required`;
+ }
+
+ getHolderForField(input) {
+ return window.closest(input, '.field');
+ }
+
+ getFieldLabel(input) {
+ const holder = this.getHolderForField(input);
+ if (holder) {
+ const label = holder.querySelector('label.left, legend.left');
+ if (label) {
+ return label.innerText;
+ }
+ }
+ return input.getAttribute('name');
+ }
+
+ isInputNumeric(input) {
+ return this.getValidatorType(input) === 'number';
+ }
+
+ isInputFile(input) {
+ return input.getAttribute('type') === 'file';
+ }
+
+ getInputByName(name) {
+ return this.step.querySelector(`input[name="${name}"]`);
+ }
+
+ getValidationsDescriptors(onlyDirty) {
+ const descriptors = {};
+ const fields = this.step.querySelectorAll('input, textarea, select');
+
+ fields.forEach((field) => {
+ if (isVisible(field)
+ && (!onlyDirty || (onlyDirty && field.classList.contains(FOCUSED_CLASS)))
+ ) {
+ const label = this.getFieldLabel(field);
+ const holder = this.getHolderForField(field);
+
+ descriptors[field.getAttribute('name')] = {
+ title: label,
+ type: this.getValidatorType(field),
+ required: holder.classList.contains('requiredField'),
+ message: this.getValidatorMessage(field)
+ };
+
+ const min = field.getAttribute('data-rule-min');
+ const max = field.getAttribute('data-rule-max');
+ if (min !== null || max !== null) {
+ descriptors[field.getAttribute('name')].asyncValidator = function numericValidator(rule, value) {
+ return new Promise((resolve, reject) => {
+ if (min !== null && value < min) {
+ reject(`${label} cannot be less than ${min}`);
+ } else if (max !== null && value > max) {
+ reject(`${label} cannot be greater than ${max}`);
+ } else {
+ resolve();
+ }
+ });
+ };
+ }
+
+ const minL = field.getAttribute('data-rule-minlength');
+ const maxL = field.getAttribute('data-rule-maxlength');
+ if (minL !== null || maxL !== null) {
+ descriptors[field.getAttribute('name')].asyncValidator = function lengthValidator(rule, value) {
+ return new Promise((resolve, reject) => {
+ if (minL !== null && value.length < minL) {
+ reject(`${label} cannot be shorter than ${minL}`);
+ } else if (maxL !== null && value.length > maxL) {
+ reject(`${label} cannot be longer than ${maxL}`);
+ } else {
+ resolve();
+ }
+ });
+ };
+ }
+ }
+ });
+ return descriptors;
+ }
+
+ validate(onlyDirty) {
+ const descriptors = this.getValidationsDescriptors(onlyDirty);
+ if (Object.keys(descriptors).length) {
+ const validator = new Schema(descriptors);
+
+ const formData = new FormData(this.userForm.dom);
+ const data = {};
+ formData.forEach((value, key) => {
+ let sanitised = value;
+ const input = this.getInputByName(key);
+ if (sanitised && input && this.isInputNumeric(input)) {
+ sanitised = parseFloat(sanitised); // because FormData reads all the values as strings
+ }
+ data[key] = sanitised;
+ });
+
+ // now check for unselected checkboxes and radio buttons
+ const selectableFields = this.step.querySelectorAll('input[type="radio"],input[type="checkbox"]');
+ selectableFields.forEach((selectableField) => {
+ const fieldName = selectableField.getAttribute('name');
+ if (typeof data[fieldName] === 'undefined') {
+ data[fieldName] = '';
+ }
+ });
+
+ const promise = new Promise((resolve, reject) => {
+ validator.validate(data, (errors) => {
+ if (errors && errors.length) {
+ this.displayErrorMessages(errors);
+ reject(errors);
+ } else {
+ this.displayErrorMessages([]);
+ resolve();
+ }
+ });
+ });
+ return promise;
+ }
+
+ const promise = new Promise((resolve) => {
+ resolve();
+ });
+ return promise;
+ }
+
+ enableLiveValidation() {
+ const fields = this.step.querySelectorAll('input, textarea, select');
+ fields.forEach((field) => {
+ field.addEventListener('focusin', () => {
+ field.classList.add(FOCUSED_CLASS);
+ });
+
+ field.addEventListener('change', () => {
+ field.classList.add(DIRTY_CLASS);
+ });
+
+ field.addEventListener('focusout', () => {
+ this.validate(true).then(() => {
+ }).catch(() => {
+ });
+ });
+ });
+ }
+
+ displayErrorMessages(errors) {
+ const errorIds = [];
+
+ errors.forEach((error) => {
+ const fieldHolder = this.userForm.dom.querySelector(`#${error.field}`);
+ if (fieldHolder) {
+ let errorLabel = fieldHolder.querySelector('span.error');
+ if (!errorLabel) {
+ errorLabel = document.createElement('span');
+ errorLabel.classList.add('error');
+ errorLabel.setAttribute('data-id', error.field);
+ }
+ errorIds.push(error.field);
+ errorLabel.innerHTML = error.message;
+ fieldHolder.append(errorLabel);
+ }
+ });
+
+ // remove any thats not required
+ const messages = this.step.querySelectorAll('span.error');
+
+ messages.forEach((mesasge) => {
+ const id = mesasge.getAttribute('data-id');
+ if (errorIds.indexOf(id) === -1) {
+ mesasge.remove();
+ }
+ });
+ }
+}
+
+class FormActions {
+ constructor(dom, userForm) {
+ this.dom = dom;
+ this.userForm = userForm;
+ this.prevButton = dom.querySelector('.step-button-prev');
+ this.nextButton = dom.querySelector('.step-button-next');
+
+ this.init();
+ }
+
+ init() {
+ this.prevButton.addEventListener('click', (e) => {
+ e.preventDefault();
+ // scrollUpFx();
+ window.triggerDispatchEvent(this.userForm.dom, 'userform.action.prev');
+ });
+ this.nextButton.addEventListener('click', (e) => {
+ e.preventDefault();
+ // scrollUpFx();
+ window.triggerDispatchEvent(this.userForm.dom, 'userform.action.next');
+ });
+
+ this.update();
+
+ this.userForm.dom.addEventListener('userform.form.changestep', () => {
+ this.update();
+ });
+
+ this.userForm.dom.addEventListener('userform.form.conditionalstep', () => {
+ this.update();
+ });
+ }
+
+ update() {
+ const numberOfSteps = this.userForm.getNumberOfSteps();
+ const stepId = this.userForm.getCurrentStepID();
let i = null;
let lastStep = null;
-
- // Update the "Prev" button.
- this.$el.find('.step-button-prev')[stepID === 0 ? 'hide' : 'show']();
-
- // Find last step, skipping hidden ones
for (i = numberOfSteps - 1; i >= 0; i--) {
- lastStep = this.userformInstance.steps[i];
-
- // Skip if step is hidden
+ lastStep = this.userForm.getStep(i);
if (!lastStep.conditionallyHidden()) {
- // Update the "Next" button.
- this.$el.find('.step-button-next')[stepID >= i ? 'hide' : 'show']();
+ if (stepId >= i) {
+ this.nextButton.parentNode.classList.add('hide');
+ } else {
+ this.nextButton.parentNode.classList.remove('hide');
+ }
- // Update the "Actions".
- this.$el.find('.btn-toolbar')[stepID >= i ? 'show' : 'hide']();
+ if (stepId > 0 && stepId <= i) {
+ this.prevButton.parentNode.classList.remove('hide');
+ } else {
+ this.prevButton.parentNode.classList.add('hide');
+ }
+
+ if (stepId >= i) {
+ this.dom.querySelector('.btn-toolbar').classList.remove('hide');
+ } else {
+ this.dom.querySelector('.btn-toolbar').classList.add('hide');
+ }
- // Stop processing last step
break;
}
}
- };
+ }
+}
- /**
- * @func UserForm
- * @constructor
- * @param {object} element
- * @return {object} - The UserForm instance.
- * @desc The form
- */
- function UserForm(element) {
- const self = this;
-
- this.$el = element instanceof $ ? element : $(element);
+class UserForm {
+ constructor(form) {
+ this.dom = form;
+ this.CONSTANTS = {}; // Settings that come from the CMS.
this.steps = [];
+ this.progressBar = null;
+ this.actions = null;
+ this.currentStep = null;
- // Add an error container which displays a list of invalid steps on form submission.
- this.errorContainer = new ErrorContainer(this.$el.children('.error-container'));
-
- // Listen for events triggered by form steps.
- this.$el.on('userform.action.prev', () => {
- self.prevStep();
- });
- this.$el.on('userform.action.next', () => {
- self.nextStep();
- });
-
- // Listen for events triggered by the progress bar.
- this.$el.find('.userform-progress').on('userform.progress.changestep', (e, stepNumber) => {
- self.jumpToStep(stepNumber - 1);
- });
-
- // When a field becomes valid, remove errors from the error container.
- this.$el.on('userform.form.valid', (e, fieldId) => {
- self.errorContainer.removeStepLink(fieldId);
- });
-
- this.$el.validate(this.validationOptions);
-
- // Ensure checkbox groups are validated correctly
- this.$el.find('.optionset.requiredField input').each((a, field) => {
- $(field).rules('add', {
- required: true,
- });
- });
-
- return this;
+ this.CONSTANTS.ENABLE_LIVE_VALIDATION = this.dom.getAttribute('livevalidation') !== undefined;
+ this.CONSTANTS.DISPLAY_ERROR_MESSAGES_AT_TOP = this.dom.getAttribute('toperrors') !== undefined;
+ this.CONSTANTS.ENABLE_ARE_YOU_SURE = this.dom.getAttribute('enableareyousure') !== undefined;
}
- /*
- * Default options for step validation. These get extended in main().
- */
- UserForm.prototype.validationOptions = {
- ignore: ':hidden,ul',
- errorClass: 'error',
- errorElement: 'span',
- errorPlacement: (error, element) => {
- error.addClass('message');
+ init() {
+ this.initialiseFormSteps();
- if (element.is(':radio') || element.parents('.checkboxset').length > 0) {
- error.appendTo(element.closest('.middleColumn, .field'));
- } else if (element.parents('.checkbox').length > 0) {
- error.appendTo(element.closest('.field'));
- } else {
- error.insertAfter(element);
+ if (this.CONSTANTS.ENABLE_ARE_YOU_SURE) {
+ this.initAreYouSure();
+ }
+ }
+
+ initialiseFormSteps() {
+ const steps = this.dom.querySelectorAll('.form-step');
+
+ steps.forEach((stepDom) => {
+ const step = new FormStep(stepDom, this);
+ step.hide();
+ this.addStep(step);
+ if (this.CONSTANTS.ENABLE_LIVE_VALIDATION) {
+ step.enableLiveValidation();
}
- },
- invalidHandler: (event, validator) => {
- // setTimeout 0 so it runs after errorPlacement
- setTimeout(() => {
- validator.currentElements.filter('.error').first().focus();
- }, 0);
- },
- // Callback for handling the actual submit when the form is valid.
- // Submission in the jQuery.validate sence is handled at step level.
- // So when the final step is submitted we have to also check all previous steps are valid.
- submitHandler: (form) => {
- let isValid = true;
- const userform = $(form).closest('.userform').data('inst');
+ });
- // Validate the current step
- if (userform.currentStep) {
- userform.currentStep.valid = $(form).valid();
- }
+ this.setCurrentStep(this.steps[0]);
- // Check for invalid previous steps.
- $.each(userform.steps, (i, step) => {
- if (!step.valid && !step.conditionallyHidden()) {
- isValid = false;
- userform.errorContainer.addStepLink(step);
+ const progressBarDom = this.dom.querySelector('.userform-progress');
+ if (progressBarDom) {
+ this.progressBar = new ProgressBar(progressBarDom, this);
+ }
+
+ const stepNavigation = this.dom.querySelector('.step-navigation');
+ if (stepNavigation) {
+ this.formActions = new FormActions(stepNavigation, this);
+ this.formActions.update();
+ }
+
+ this.setUpPing();
+
+ this.dom.addEventListener('userform.action.next', () => {
+ this.nextStep();
+ });
+
+ this.dom.addEventListener('userform.action.prev', () => {
+ this.prevStep();
+ });
+
+ this.dom.addEventListener('submit', (e) => {
+ this.validateForm(e);
+ });
+ }
+
+
+ validateForm(e) {
+ e.preventDefault();
+ this.currentStep.validate()
+ .then((errors) => {
+ if (!errors) {
+ this.dom.submit();
}
- });
+ })
+ .catch(() => {});
+ }
- if (isValid) {
- // Remove required attributes on hidden fields
- const hiddenInputs = $(form).find('.field.requiredField.hide input');
- if (hiddenInputs.length > 0) {
- hiddenInputs.removeAttr('required aria-required data-rule-required').valid();
- }
-
- // When using the "are you sure?" plugin, ensure the form immediately submits.
- $(form).removeClass('dirty');
-
- form.submit();
- userform.$el.trigger('userform.form.submit');
- } else {
- userform.errorContainer.show();
- }
- },
- // When a field becomes valid.
- success: (error) => {
- const userform = $(error).closest('.userform').data('inst');
- const errorId = $(error).attr('id');
- const fieldId = errorId.substr(0, errorId.indexOf('-error')).replace(/[\\[\\]]/, '');
-
- // Remove square brackets since jQuery.validate.js uses idOrName,
- // which breaks further on when using a selector that end with
- // square brackets.
-
- error.remove();
-
- // Pass the field's ID with the event
- userform.$el.trigger('userform.form.valid', [fieldId]);
- },
- };
-
- /**
- * @func UserForm.addStep
- * @param {object} step - An instance of FormStep.
- * @desc Adds a step to the UserForm.
- */
- UserForm.prototype.addStep = function addStep(step) {
+ setCurrentStep(step) {
// Make sure we're dealing with a form step.
if (!(step instanceof FormStep)) {
return;
}
-
- // eslint-disable-next-line no-param-reassign
- step.id = this.steps.length;
-
- this.steps.push(step);
- };
-
- /**
- * @func UserForm.setCurrentStep
- * @param {object} step - An instance of FormStep.
- * @desc Sets the step the user is currently on.
- */
- UserForm.prototype.setCurrentStep = function setCurrentStep(step) {
- // Make sure we're dealing with a form step.
- if (!(step instanceof FormStep)) {
- return;
- }
-
this.currentStep = step;
this.currentStep.show();
+ }
- // Record the user has viewed the step.
- this.currentStep.viewed = true;
- this.currentStep.$el.addClass('viewed');
- };
+ addStep(step) {
+ if (!(step instanceof FormStep)) {
+ return;
+ }
+ step.setId(this.steps.length);
+ this.steps.push(step);
+ }
- /**
- * @func UserForm.jumpToStep
- * @param {number} stepNumber
- * @param {boolean} [direction] - Defaults to forward (true).
- * @desc Jumps to a specific form step.
- */
- UserForm.prototype.jumpToStep = function jumpToStep(stepNumber, direction) {
+ getNumberOfSteps() {
+ return this.steps.length;
+ }
+
+ getCurrentStepID() {
+ return this.currentStep.id ? this.currentStep.id : 0;
+ }
+
+ getStep(index) {
+ return this.steps[index];
+ }
+
+ nextStep() {
+ this.currentStep.validate().then(() => {
+ this.jumpToStep(this.steps.indexOf(this.currentStep) + 1, true);
+ }).catch(() => {});
+ }
+
+ prevStep() {
+ this.jumpToStep(this.steps.indexOf(this.currentStep) - 1, true);
+ }
+
+ jumpToStep(stepNumber, direction) {
const targetStep = this.steps[stepNumber];
- let isValid = false;
const forward = direction === undefined ? true : direction;
- // Make sure the target step exists.
if (targetStep === undefined) {
return;
}
- // Make sure the step we're trying to set as current is not
- // hidden by custom display rules. If it is then jump to the next step.
if (targetStep.conditionallyHidden()) {
if (forward) {
this.jumpToStep(stepNumber + 1, direction);
} else {
this.jumpToStep(stepNumber - 1, direction);
}
-
return;
}
- // Validate the form.
- // This well effectivly validate the current step and not the entire form.
- // This is because hidden fields are excluded from validation, and all fields
- // on all other steps, are currently hidden.
- isValid = this.$el.valid();
-
- // Set the 'valid' property on the current step.
- this.currentStep.valid = isValid;
-
- // Users can navigate to step's they've already viewed even if the current step is invalid.
- if (isValid === false && targetStep.viewed === false) {
- return;
+ if (this.currentStep) {
+ this.currentStep.hide();
}
- this.currentStep.hide();
this.setCurrentStep(targetStep);
- this.$el.trigger('userform.form.changestep', [targetStep.id]);
- };
-
- /**
- * @func UserForm.nextStep
- * @desc Advances the form to the next step.
- */
- UserForm.prototype.nextStep = function nextStep() {
- this.jumpToStep(this.steps.indexOf(this.currentStep) + 1, true);
- };
-
- /**
- * @func UserForm.prevStep
- * @desc Goes back one step (not bound to browser history).
- */
- UserForm.prototype.prevStep = function prevStep() {
- this.jumpToStep(this.steps.indexOf(this.currentStep) - 1, false);
- };
-
- /**
- * @func main
- * @desc Bootstraps the front-end.
- */
- function main(index, userformElement) {
- const $userform = $(userformElement);
-
- // If there's no userform, do nothing.
- if ($userform.length === 0) {
- return;
- }
-
- CONSTANTS.ENABLE_LIVE_VALIDATION = $userform.data('livevalidation') !== undefined;
- CONSTANTS.DISPLAY_ERROR_MESSAGES_AT_TOP = $userform.data('toperrors') !== undefined;
-
- // Extend the default validation options with conditional options
- // that are set by the user in the CMS.
- if (CONSTANTS.ENABLE_LIVE_VALIDATION === false) {
- $.extend(UserForm.prototype.validationOptions, {
- onfocusout: false,
- });
- }
-
- if (CONSTANTS.DISPLAY_ERROR_MESSAGES_AT_TOP) {
- $.extend(UserForm.prototype.validationOptions, {
- // Callback for custom code when an invalid form / step is submitted.
- invalidHandler: (event, validator) => {
- $userform.trigger('userform.form.error', [validator]);
- },
- onfocusout: false,
- });
- }
-
- // Display all the things that are hidden when JavaScript is disabled.
- $userform.find('.userform-progress, .step-navigation').attr('aria-hidden', false).show();
-
- // Extend classes with common functionality.
- $.extend(FormStep.prototype, commonMixin);
- $.extend(ErrorContainer.prototype, commonMixin);
-
- const userform = new UserForm($userform);
- $userform.data('inst', userform);
-
- // Conditionally hide field labels and use HTML5 placeholder instead.
- if (CONSTANTS.HIDE_FIELD_LABELS) {
- $userform.find('label.left').each(() => {
- const $label = $(this);
-
- $(`[name="${$label.attr('for')}"]`).attr('placeholder', $label.text());
- $label.remove();
- });
- }
-
- // Initialise the form steps.
- userform.$el.find('.form-step').each((i, element) => {
- const step = new FormStep(element);
-
- userform.addStep(step);
+ window.triggerDispatchEvent(this.dom, 'userform.form.changestep', {
+ stepId: targetStep.id
});
-
- userform.setCurrentStep(userform.steps[0]);
-
- // Initialise actions and progressbar
- const $progressEl = $userform.find('.userform-progress');
- if ($progressEl.length) {
- const progressBar = new ProgressBar($progressEl);
- progressBar.update(0);
- }
-
- const $formActionsEl = $userform.find('.step-navigation');
- if ($formActionsEl.length) {
- const formActions = new FormActions($formActionsEl);
- formActions.update();
- }
-
- // Enable jQuery UI datepickers
- $(document).on('click', 'input.text[data-showcalendar]', () => {
- const $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(() => {
- $.ajax({ url: 'UserDefinedFormController/ping' });
- }, 180 * 1000);
-
- // Bind a confirmation message when navigating away from a partially completed form.
- if (typeof $userform.areYouSure !== 'undefined') {
- $userform.areYouSure({
- message: i18n._t('UserForms.LEAVE_CONFIRMATION', 'You have unsaved changes!'),
- });
- }
}
- $('.userform').each(main);
+ setUpPing() {
+ // Make sure the form doesn't expire on the user. Pings every 3 mins.
+ window.setInterval(() => {
+ fetch('UserDefinedFormController/ping');
+ }, 180 * 1000);
+ }
+
+ doConfirm(e) {
+ const dirtyFields = this.dom.querySelectorAll(`.${DIRTY_CLASS}`);
+ if (dirtyFields.length === 0) {
+ return true;
+ }
+ if (navigator.userAgent.toLowerCase().match(/msie|chrome/)) {
+ if (window.hasUserFormsPropted) {
+ return true;
+ }
+ window.hasUserFormsPropted = true;
+ window.setTimeout(
+ () => {
+ window.hasUserFormsPropted = false;
+ },
+ 900
+ );
+ }
+ e.preventDefault();
+ if (typeof window.i18n !== 'undefined') {
+ event.returnValue = window.i18n._t('UserForms.LEAVE_CONFIRMATION', 'You have unsaved changes!');
+ } else {
+ event.returnValue = 'You have unsaved changes!';
+ }
+ return true;
+ }
+
+ initAreYouSure() {
+ const confirmFunction = this.doConfirm.bind(this);
+ this.dom.addEventListener('submit', (e) => {
+ window.removeEventListener('beforeunload', confirmFunction);
+ })
+ window.addEventListener('beforeunload', confirmFunction);
+ }
+}
+
+
+document.addEventListener('DOMContentLoaded', () => {
+ const forms = document.querySelectorAll('form.userform');
+ forms.forEach((form) => {
+ const userForm = new UserForm(form);
+ userForm.init();
+ });
});
diff --git a/client/src/styles/userforms.scss b/client/src/styles/userforms.scss
index e612cd4..8047335 100644
--- a/client/src/styles/userforms.scss
+++ b/client/src/styles/userforms.scss
@@ -15,6 +15,10 @@
.step-buttons {
margin-left: 0;
+ padding-left: 0;
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
position: relative;
}
@@ -28,8 +32,6 @@
}
.step-button-jump {
- position: absolute;
- top: 0;
opacity: .7;
}
}
diff --git a/code/Control/UserDefinedFormController.php b/code/Control/UserDefinedFormController.php
index 8d838dc..a891025 100644
--- a/code/Control/UserDefinedFormController.php
+++ b/code/Control/UserDefinedFormController.php
@@ -79,22 +79,11 @@ class UserDefinedFormController extends PageController
// load the jquery
if (!$page->config()->get('block_default_userforms_js')) {
- Requirements::javascript('silverstripe/userforms:client/dist/js/jquery.min.js');
- Requirements::javascript(
- 'silverstripe/userforms:client/dist/js/jquery-validation/jquery.validate.min.js'
- );
Requirements::javascript('silverstripe/admin:client/dist/js/i18n.js');
Requirements::add_i18n_javascript('silverstripe/userforms:client/lang');
Requirements::javascript('silverstripe/userforms:client/dist/js/userforms.js');
$this->addUserFormsValidatei18n();
-
- // Bind a confirmation message when navigating away from a partially completed form.
- if ($page::config()->get('enable_are_you_sure')) {
- Requirements::javascript(
- 'silverstripe/userforms:client/dist/js/jquery.are-you-sure/jquery.are-you-sure.js'
- );
- }
}
}
@@ -177,10 +166,16 @@ class UserDefinedFormController extends PageController
*/
public function Form()
{
+ $page = $this->data();
$form = UserForm::create($this, 'Form_' . $this->ID);
/** @skipUpgrade */
$form->setFormAction(Controller::join_links($this->Link(), 'Form'));
$this->generateConditionalJavascript();
+
+ if ($page::config()->get('enable_are_you_sure')) {
+ $form->setAttribute('enableareyousure', 1);
+ }
+
return $form;
}
@@ -212,14 +207,35 @@ class UserDefinedFormController extends PageController
$rules .= $this->buildWatchJS($watch);
}
+ // add the custom scripts thats used by the steps.
+ Requirements::customScript(<<ID);
}
@@ -626,10 +642,11 @@ JS
*/
protected function buildWatchJS($watch)
{
+
$result = '';
foreach ($watch as $key => $rule) {
- $events = implode(' ', $rule['events']);
- $selectors = implode(', ', $rule['selectors']);
+ $events = implode(',', $rule['events']);
+ $selectors = implode(',', $rule['selectors']);
$conjunction = $rule['conjunction'];
$operations = implode(" {$conjunction} ", $rule['operations']);
$target = $rule['targetFieldID'];
@@ -638,16 +655,21 @@ JS
$result .= <<isCheckBoxField();
$radioField = $formFieldWatch->isRadioField();
- $target = sprintf('$("%s")', $formFieldWatch->getSelectorFieldOnly());
+ $target = $formFieldWatch->getSelectorFieldOnly();
$fieldValue = Convert::raw2js($this->FieldValue);
$conditionOptions = [
@@ -174,7 +174,9 @@ class EditableCustomRule extends DataObject
switch ($this->ConditionOption) {
case 'IsNotBlank':
case 'IsBlank':
- $expression = ($checkboxField || $radioField) ? "!{$target}.is(\":checked\")" : "{$target}.val() == ''";
+ $expression = ($checkboxField || $radioField)
+ ? sprintf("document.querySelector(\"%s:checked\") !== null", $target)
+ : "document.querySelector(\"{$target}\").value == ''";
if ((string) $this->ConditionOption === 'IsNotBlank') {
//Negate
$expression = "!({$expression})";
@@ -185,9 +187,9 @@ class EditableCustomRule extends DataObject
if ($checkboxField) {
if ($formFieldWatch->isCheckBoxGroupField()) {
$expression = sprintf(
- "$.inArray('%s', %s.filter(':checked').map(function(){ return $(this).val();}).get()) > -1",
+ '[...document.querySelectorAll("%s:checked")].map(function(i) { return i ? i.getAttribute("value") : null; }).indexOf(\'%s\') > -1',
+ $target,
$fieldValue,
- $target
);
} else {
$expression = "{$target}.prop('checked')";
@@ -195,12 +197,12 @@ class EditableCustomRule extends DataObject
} elseif ($radioField) {
// We cannot simply get the value of the radio group, we need to find the checked option first.
$expression = sprintf(
- '%s.closest(".field, .control-group").find("input:checked").val() == "%s"',
+ 'closest(document.querySelector("%s"), ".field, .control-group").querySelector("input:checked").value == "%s"',
$target,
$fieldValue
);
} else {
- $expression = sprintf('%s.val() == "%s"', $target, $fieldValue);
+ $expression = sprintf('document.querySelector("%s").value == "%s"', $target, $fieldValue);
}
if ((string) $this->ConditionOption === 'ValueNot') {
@@ -213,7 +215,7 @@ class EditableCustomRule extends DataObject
case 'ValueGreaterThan':
case 'ValueGreaterThanEqual':
$expression = sprintf(
- '%s.val() %s parseFloat("%s")',
+ 'document.querySelector("%s").value %s parseFloat("%s")',
$target,
$conditionOptions[$this->ConditionOption],
$fieldValue
@@ -299,11 +301,11 @@ class EditableCustomRule extends DataObject
*/
public function toggleDisplayText($initialState, $invert = false)
{
- $action = strtolower($initialState ?? '') === 'hide' ? 'removeClass' : 'addClass';
+ $action = strtolower($initialState ?? '') === 'hide' ? 'remove' : 'add';
if ($invert) {
- $action = $action === 'removeClass' ? 'addClass' : 'removeClass';
+ $action = $action === 'remove' ? 'add' : 'remove';
}
- return sprintf('%s("hide")', $action);
+ return sprintf('classList.%s("hide")', $action);
}
/**
diff --git a/code/Model/EditableFormField.php b/code/Model/EditableFormField.php
index 4f60370..70ebe04 100755
--- a/code/Model/EditableFormField.php
+++ b/code/Model/EditableFormField.php
@@ -868,7 +868,7 @@ class EditableFormField extends DataObject
*/
public function getSelectorHolder()
{
- return sprintf('$("%s")', $this->getSelectorOnly());
+ return sprintf('document.querySelector("%s")', $this->getSelectorOnly());
}
/**
@@ -891,7 +891,7 @@ class EditableFormField extends DataObject
*/
public function getSelectorField(EditableCustomRule $rule, $forOnLoad = false)
{
- return sprintf("$(%s)", $this->getSelectorFieldOnly());
+ return sprintf("document.querySelector(%s)", $this->getSelectorFieldOnly());
}
/**
diff --git a/code/Model/EditableFormField/EditableFieldGroup.php b/code/Model/EditableFormField/EditableFieldGroup.php
index 93d46c3..a505c5d 100644
--- a/code/Model/EditableFormField/EditableFieldGroup.php
+++ b/code/Model/EditableFormField/EditableFieldGroup.php
@@ -48,7 +48,7 @@ class EditableFieldGroup extends EditableFormField
public function getCMSFields()
{
$this->beforeUpdateCMSFields(function (FieldList $fields) {
- $fields->removeByName(['MergeField', 'Default', 'Validation', 'DisplayRules']);
+ $fields->removeByName(['MergeField', 'Default', 'Validation']);
});
return parent::getCMSFields();
@@ -98,5 +98,10 @@ class EditableFieldGroup extends EditableFormField
if ($this->ExtraClass) {
$field->addExtraClass($this->ExtraClass);
}
+
+ // if ShowOnLoad is false hide the field
+ if (!$this->ShowOnLoad) {
+ $field->addExtraClass($this->ShowOnLoadNice());
+ }
}
}
diff --git a/package.json b/package.json
index 050be44..b8b40ea 100644
--- a/package.json
+++ b/package.json
@@ -37,10 +37,10 @@
"node-dir": "^0.1.17"
},
"dependencies": {
- "babel-preset-es2016": "^6.24.1",
"jquery": "^3.5.0",
- "jquery-validation": "^1.19.5",
- "jquery.are-you-sure": "^1.9.0",
+ "babel-preset-es2016": "^6.24.1",
+ "validator": "13.9.0",
+ "async-validator": "4.2.5",
"mime": "^1.4.1",
"qs": "^6.9.4",
"react": "^16.13.1",
diff --git a/templates/SilverStripe/UserForms/Form/Includes/UserFormProgress.ss b/templates/SilverStripe/UserForms/Form/Includes/UserFormProgress.ss
index 5bfb843..96210a7 100644
--- a/templates/SilverStripe/UserForms/Form/Includes/UserFormProgress.ss
+++ b/templates/SilverStripe/UserForms/Form/Includes/UserFormProgress.ss
@@ -1,19 +1,21 @@
<% if $Steps.Count > 1 %>
Page 1 of $Steps.Count
-
-
-
-
+
+
+
+
+
+
<% end_if %>
diff --git a/templates/SilverStripe/UserForms/Form/Includes/UserFormStepNav.ss b/templates/SilverStripe/UserForms/Form/Includes/UserFormStepNav.ss
index f7c9440..0b4fd6b 100644
--- a/templates/SilverStripe/UserForms/Form/Includes/UserFormStepNav.ss
+++ b/templates/SilverStripe/UserForms/Form/Includes/UserFormStepNav.ss
@@ -4,12 +4,12 @@
If JavaScript is disabled multi-step forms are displayed as a single page
so the 'prev' and 'next' button are not used. These buttons are made visible via JavaScript.
--%>
-