API support for conditional steps

This commit is contained in:
David Craig 2015-08-17 10:43:51 +12:00 committed by Damian Mooyman
parent 864ce0f5b7
commit 9907ac604f
10 changed files with 321 additions and 74 deletions

View File

@ -16,11 +16,13 @@ class UserFormValidator extends RequiredFields {
// Current nesting // Current nesting
$stack = array(); $stack = array();
$conditionalStep = false; // Is the current step conditional?
foreach($fields as $field) { foreach($fields as $field) {
if($field instanceof EditableFormStep) { if($field instanceof EditableFormStep) {
// Page at top level, or after another page is ok // Page at top level, or after another page is ok
if(empty($stack) || (count($stack) === 1 && $stack[0] instanceof EditableFormStep)) { if(empty($stack) || (count($stack) === 1 && $stack[0] instanceof EditableFormStep)) {
$stack = array($field); $stack = array($field);
$conditionalStep = $field->DisplayRules()->count() > 0;
continue; continue;
} }
@ -101,6 +103,22 @@ class UserFormValidator extends RequiredFields {
// Unnest group // Unnest group
array_pop($stack); array_pop($stack);
} }
// Normal field type
if($conditionalStep && $field->Required) {
$this->validationError(
'FormFields',
_t(
"UserFormValidator.CONDITIONAL_REQUIRED",
"Required field '{name}' cannot be placed within a conditional page",
array(
'name' => $field->CMSTitle
)
),
'error'
);
return false;
}
} }
return true; return true;

View File

@ -395,7 +395,7 @@ class UserDefinedForm_Controller extends Page_Controller {
// Is this Field Show by Default // Is this Field Show by Default
if(!$field->ShowOnLoad) { if(!$field->ShowOnLoad) {
$default .= "{$holderSelector}.hide();\n"; $default .= "{$holderSelector}.hide().trigger('userform.field.hide');\n";
} }
// Check for field dependencies / default // Check for field dependencies / default
@ -503,16 +503,26 @@ class UserDefinedForm_Controller extends Page_Controller {
$actions = array(); $actions = array();
foreach($values as $rule) { foreach($values as $rule) {
// Register conditional behaviour with an element, so it can be triggered from many places. // Assign action
$logic[] = sprintf(
'if(%s) { %s.%s(); } else { %2$s.%s(); }',
$rule['expression'],
$rule['holder_selector'],
$rule['view'],
$rule['opposite']
);
$actions[$rule['action']] = $rule['action']; $actions[$rule['action']] = $rule['action'];
// Assign behaviour
$expression = $rule['expression'];
$holder = $rule['holder_selector'];
$view = $rule['view']; // hide or show
$opposite = $rule['opposite'];
// Generated javascript for triggering visibility
$logic[] = <<<"EOS"
if({$expression}) {
{$holder}
.{$view}()
.trigger('userform.field.{$view}');
} else {
{$holder}
.{$opposite}()
.trigger('userform.field.{$opposite}');
}
EOS;
} }
$logic = implode("\n", $logic); $logic = implode("\n", $logic);

View File

@ -554,7 +554,8 @@ class EditableFormField extends DataObject {
*/ */
public function getFieldValidationOptions() { public function getFieldValidationOptions() {
$fields = new FieldList( $fields = new FieldList(
CheckboxField::create('Required', _t('EditableFormField.REQUIRED', 'Is this field Required?')), CheckboxField::create('Required', _t('EditableFormField.REQUIRED', 'Is this field Required?'))
->setDescription(_t('EditableFormField.REQUIRED_DESCRIPTION', 'Please note that conditional fields can\'t be required')),
TextField::create('CustomErrorMessage', _t('EditableFormField.CUSTOMERROR','Custom Error Message')) TextField::create('CustomErrorMessage', _t('EditableFormField.CUSTOMERROR','Custom Error Message'))
); );
@ -675,7 +676,7 @@ class EditableFormField extends DataObject {
!$data[$this->Name] || !$data[$this->Name] ||
!$formField->validate($form->getValidator()) !$formField->validate($form->getValidator())
) { ) {
$form->addErrorMessage($this->Name, $this->getErrorMessage()->HTML(), 'bad', false); $form->addErrorMessage($this->Name, $this->getErrorMessage()->HTML(), 'error', false);
} }
} }
@ -779,4 +780,12 @@ class EditableFormField extends DataObject {
asort($editableFieldClasses); asort($editableFieldClasses);
return $editableFieldClasses; return $editableFieldClasses;
} }
/**
* @return EditableFormFieldValidator
*/
public function getCMSValidator() {
return EditableFormFieldValidator::create()
->setRecord($this);
}
} }

View File

@ -0,0 +1,61 @@
<?php
class EditableFormFieldValidator extends RequiredFields {
/**
*
* @var EditableFormField
*/
protected $record = null;
/**
*
* @param EditableFormField $record
* @return $this
*/
public function setRecord($record) {
$this->record = $record;
return $this;
}
/*
* @return EditableFormField
*/
public function getRecord() {
return $this->record;
}
public function php($data) {
if(!parent::php($data)) {
return false;
}
// Skip unsaved records
if(!$this->record || !$this->record->exists()) {
return true;
}
// Skip validation if not required
if(empty($data['Required'])) {
return;
}
// Skip validation if no rules
$count = EditableCustomRule::get()->filter('ParentID', $this->record->ID)->count();
if($count == 0) {
return true;
}
// Both required = true and rules > 0 should error
$this->validationError(
'Required_Error',
_t(
"EditableFormFieldValidator.REQUIRED_ERROR",
"Form fields cannot be required and have conditional display rules."
),
'error'
);
return false;
}
}

View File

@ -24,7 +24,7 @@ class EditableFormStep extends EditableFormField {
public function getCMSFields() { public function getCMSFields() {
$fields = parent::getCMSFields(); $fields = parent::getCMSFields();
$fields->removeByName(array('MergeField', 'Default', 'Validation', 'DisplayRules')); $fields->removeByName(array('MergeField', 'Default', 'Validation'));
return $fields; return $fields;
} }
@ -74,4 +74,13 @@ class EditableFormStep extends EditableFormField {
) )
); );
} }
/**
* Get the JS expression for selecting the holder for this field
*
* @return string
*/
public function getSelectorHolder() {
return "$(\".step-button-wrapper[data-for='{$this->Name}']\")";
}
} }

View File

@ -44,10 +44,10 @@ jQuery(function ($) {
this.errorContainer = new ErrorContainer(this.$el.children('.error-container')); this.errorContainer = new ErrorContainer(this.$el.children('.error-container'));
// Listen for events triggered my form steps. // Listen for events triggered my form steps.
this.$el.on('userform.step.prev', function (e) { this.$el.on('userform.action.prev', function (e) {
self.prevStep(); self.prevStep();
}); });
this.$el.on('userform.step.next', function (e) { this.$el.on('userform.action.next', function (e) {
self.nextStep(); self.nextStep();
}); });
@ -88,12 +88,14 @@ jQuery(function ($) {
submitHandler: function (form, e) { submitHandler: function (form, e) {
var isValid = true; var isValid = true;
// Validate the final step. // validate the current step
userform.steps[userform.steps.length - 1].valid = $(form).valid(); if(userform.currentStep) {
userform.currentStep.valid = $(form).valid();
}
// Check for invalid previous steps. // Check for invalid previous steps.
$.each(userform.steps, function (i, step) { $.each(userform.steps, function (i, step) {
if (!step.valid) { if (!step.valid && !step.conditionallyHidden()) {
isValid = false; isValid = false;
userform.errorContainer.addStepLink(step); userform.errorContainer.addStepLink(step);
} }
@ -154,17 +156,31 @@ jQuery(function ($) {
/** /**
* @func UserForm.jumpToStep * @func UserForm.jumpToStep
* @param {number} stepNumber * @param {number} stepNumber
* @param {boolean} [direction] - Defaults to forward (true).
* @desc Jumps to a specific form step. * @desc Jumps to a specific form step.
*/ */
UserForm.prototype.jumpToStep = function (stepNumber) { UserForm.prototype.jumpToStep = function (stepNumber, direction) {
var targetStep = this.steps[stepNumber], var targetStep = this.steps[stepNumber],
isValid = false; isValid = false,
forward = direction === void 0 ? true : direction;
// Make sure the target step exists. // Make sure the target step exists.
if (targetStep === void 0) { if (targetStep === void 0) {
return; 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);
} else {
this.jumpToStep(stepNumber - 1);
}
return;
}
// Validate the form. // Validate the form.
// This well effectivly validate the current step and not the entire 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 // This is because hidden fields are excluded from validation, and all fields
@ -172,7 +188,7 @@ jQuery(function ($) {
isValid = this.$el.valid(); isValid = this.$el.valid();
// Set the 'valid' property on the current step. // Set the 'valid' property on the current step.
this.steps[stepNumber - 1 >= 0 ? stepNumber - 1 : 0].valid = isValid; this.currentStep.valid = isValid;
// Users can navigate to step's they've already viewed even if the current step is invalid. // Users can navigate to step's they've already viewed even if the current step is invalid.
if (isValid === false && targetStep.viewed === false) { if (isValid === false && targetStep.viewed === false) {
@ -182,7 +198,7 @@ jQuery(function ($) {
this.currentStep.hide(); this.currentStep.hide();
this.setCurrentStep(targetStep); this.setCurrentStep(targetStep);
this.$el.trigger('userform.form.changestep', [stepNumber]); this.$el.trigger('userform.form.changestep', [targetStep.id]);
}; };
/** /**
@ -190,7 +206,7 @@ jQuery(function ($) {
* @desc Advances the form to the next step. * @desc Advances the form to the next step.
*/ */
UserForm.prototype.nextStep = function () { UserForm.prototype.nextStep = function () {
this.jumpToStep(this.steps.indexOf(this.currentStep) + 1); this.jumpToStep(this.steps.indexOf(this.currentStep) + 1, true);
}; };
/** /**
@ -198,7 +214,7 @@ jQuery(function ($) {
* @desc Goes back one step (not bound to browser history). * @desc Goes back one step (not bound to browser history).
*/ */
UserForm.prototype.prevStep = function () { UserForm.prototype.prevStep = function () {
this.jumpToStep(this.steps.indexOf(this.currentStep) - 1); this.jumpToStep(this.steps.indexOf(this.currentStep) - 1, false);
}; };
/** /**
@ -349,7 +365,10 @@ jQuery(function ($) {
var self = this; var self = this;
this.$el = element instanceof jQuery ? element : $(element); this.$el = element instanceof jQuery ? element : $(element);
// Find button for this step
this.$elButton = $(".step-button-wrapper[data-for='" + this.$el.prop('id') + "']");
// Has the step been viewed by the user? // Has the step been viewed by the user?
this.viewed = false; this.viewed = false;
@ -362,16 +381,6 @@ jQuery(function ($) {
this.hide(); this.hide();
// Bind the step navigation event listeners.
this.$el.find('.step-button-prev').on('click', function (e) {
e.preventDefault();
self.$el.trigger('userform.step.prev');
});
this.$el.find('.step-button-next').on('click', function (e) {
e.preventDefault();
self.$el.trigger('userform.step.next');
});
if (CONSTANTS.DISPLAY_ERROR_MESSAGES_AT_TOP) { if (CONSTANTS.DISPLAY_ERROR_MESSAGES_AT_TOP) {
this.errorContainer = new ErrorContainer(this.$el.find('.error-container')); this.errorContainer = new ErrorContainer(this.$el.find('.error-container'));
@ -394,8 +403,28 @@ jQuery(function ($) {
}); });
} }
// Ensure that page visibilty updates the step navigation
this
.$elButton
.on('userform.field.hide userform.field.show', function(){
userform.$el.trigger('userform.form.conditionalstep');
});
return this; return this;
} }
/**
* Determine if this step is conditionally disabled
*
* @returns {Boolean}
*/
FormStep.prototype.conditionallyHidden = function(){
// Because the element itself could be visible but 0 height, so check visibility of button
return ! this
.$elButton
.find('button')
.is(':visible');
};
/** /**
* @func ProgressBar * @func ProgressBar
@ -420,8 +449,26 @@ jQuery(function ($) {
}); });
// Update the progress bar when 'prev' and 'next' buttons are clicked. // Update the progress bar when 'prev' and 'next' buttons are clicked.
userform.$el.on('userform.form.changestep', function (e, newStep) { userform.$el.on('userform.form.changestep', function (e, stepID) {
self.update(newStep + 1); self.update(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', function () {
// Update the step numbers on the buttons.
var $visibleButtons = self.$buttons.filter(':visible');
$visibleButtons.each(function (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 // Spaces out the steps below progress bar evenly
@ -440,27 +487,39 @@ jQuery(function ($) {
} }
}); });
this.update(1); this.update(0);
return this; return this;
} }
/** /**
* @func ProgressBar.update * @func ProgressBar.update
* @param {number} newStep * @param {number} stepID - Zero based index of the new step.
* @desc Update the progress element to show a new step. * @desc Update the progress element to show a new step.
*/ */
ProgressBar.prototype.update = function (newStep) { ProgressBar.prototype.update = function (stepID) {
var $newStepElement = $($('.form-step')[newStep - 1]); var $newStepElement = $($('.form-step')[stepID]),
stepNumber = 0;
// Set the current step number.
this.$buttons.each(function (i, button) {
if (i > stepID) {
return false; // break the loop
}
if ($(button).is(':visible')) {
stepNumber += 1;
}
});
// Update elements that contain the current step number. // Update elements that contain the current step number.
this.$el.find('.current-step-number').each(function (i, element) { this.$el.find('.current-step-number').each(function (i, element) {
$(element).text(newStep); $(element).text(stepNumber);
}); });
// Update aria attributes. // Update aria attributes.
this.$el.find('[aria-valuenow]').each(function (i, element) { this.$el.find('[aria-valuenow]').each(function (i, element) {
$(element).attr('aria-valuenow', newStep); $(element).attr('aria-valuenow', stepNumber);
}); });
// Update the CSS classes on step buttons. // Update the CSS classes on step buttons.
@ -468,7 +527,7 @@ jQuery(function ($) {
var $element = $(element), var $element = $(element),
$item = $element.parent(); $item = $element.parent();
if (parseInt($element.text(), 10) === newStep) { if (parseInt($element.text(), 10) === stepNumber && $element.is(':visible')) {
$item.addClass('current viewed'); $item.addClass('current viewed');
$element.removeAttr('disabled'); $element.removeAttr('disabled');
@ -482,7 +541,72 @@ jQuery(function ($) {
this.$el.find('.progress-title').text($newStepElement.data('title')); this.$el.find('.progress-title').text($newStepElement.data('title'));
// Update the width of the progress bar. // Update the width of the progress bar.
this.$el.find('.progress-bar').width((newStep - 1) / (this.$buttons.length - 1) * 100 + '%'); this.$el.find('.progress-bar').width(stepID / (this.$buttons.length - 1) * 100 + '%');
};
/**
* @func FormActions
* @constructor
* @param {object} element
* @desc Creates the navigation and actions (Prev, Next, Submit buttons).
*/
function FormActions (element) {
var self = this;
this.$el = element instanceof jQuery ? element : $(element);
// Bind the step navigation event listeners.
this.$el.find('.step-button-prev').on('click', function (e) {
e.preventDefault();
self.$el.trigger('userform.action.prev');
});
this.$el.find('.step-button-next').on('click', function (e) {
e.preventDefault();
self.$el.trigger('userform.action.next');
});
// Listen for changes to the current form step, or conditional pages,
// so we can show hide buttons appropriatly.
userform.$el.on('userform.form.changestep userform.form.conditionalstep', function () {
self.update();
});
this.update();
return this;
}
/**
* @func FormAcrions.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 () {
var numberOfSteps = userform.steps.length,
stepID = userform.currentStep.id,
i, lastStep;
// 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 = userform.steps[i];
// Skip if step is hidden
if(lastStep.conditionallyHidden()) {
continue;
}
// Update the "Next" button.
this.$el.find('.step-button-next')[stepID >= i ? 'hide' : 'show']();
// Update the "Actions".
this.$el.find('.Actions')[stepID >= i ? 'show' : 'hide']();
// Stop processing last step
break;
}
}; };
/** /**
@ -491,6 +615,7 @@ jQuery(function ($) {
*/ */
function main() { function main() {
var progressBar = null, var progressBar = null,
formActions = null,
$userform = $('.userform'); $userform = $('.userform');
CONSTANTS.ENABLE_LIVE_VALIDATION = $userform.data('livevalidation') !== void 0; CONSTANTS.ENABLE_LIVE_VALIDATION = $userform.data('livevalidation') !== void 0;
@ -523,7 +648,6 @@ jQuery(function ($) {
$.extend(ErrorContainer.prototype, commonMixin); $.extend(ErrorContainer.prototype, commonMixin);
userform = new UserForm($userform); userform = new UserForm($userform);
progressBar = new ProgressBar($('#userform-progress'));
// Conditionally hide field labels and use HTML5 placeholder instead. // Conditionally hide field labels and use HTML5 placeholder instead.
if (CONSTANTS.HIDE_FIELD_LABELS) { if (CONSTANTS.HIDE_FIELD_LABELS) {
@ -543,6 +667,10 @@ jQuery(function ($) {
}); });
userform.setCurrentStep(userform.steps[0]); userform.setCurrentStep(userform.steps[0]);
// Initialise actions and progressbar
progressBar = new ProgressBar($('#userform-progress'));
formActions = new FormActions($('#step-navigation'));
// Hide the form-wide actions on multi-step forms. // Hide the form-wide actions on multi-step forms.
// Because JavaScript is enabled we'll use the actions contained // Because JavaScript is enabled we'll use the actions contained

View File

@ -1,16 +1,17 @@
<% if $Steps.Count > 1 %> <% if $Steps.Count > 1 %>
<div id="userform-progress" class="userform-progress" aria-hidden="true" style="display:none;"> <div id="userform-progress" class="userform-progress" aria-hidden="true" style="display:none;">
<h2 class="progress-title"></h2> <h2 class="progress-title"></h2>
<p>Step <span class="current-step-number">1</span> of $NumberOfSteps.Count</p> <p>Step <span class="current-step-number">1</span> of <span class="total-step-number">$Steps.Count</span></p>
<div class="progress"> <div class="progress">
<div class="progress-bar" role="progressbar" aria-valuenow="1" aria-valuemin="1" aria-valuemax="$NumberOfSteps.Count"></div> <div class="progress-bar" role="progressbar" aria-valuenow="1" aria-valuemin="1" aria-valuemax="$Steps.Count"></div>
</div> </div>
<nav> <nav>
<ul class="step-buttons"> <ul class="step-buttons">
<% loop $Steps %> <% loop $Steps %>
<li class="step-button-wrapper<% if $First %> current<% end_if %>"> <li class="step-button-wrapper<% if $First %> current<% end_if %>" data-for="$Name">
<button class="step-button-jump js-align" disabled="disabled">$Pos</button><!-- Remove js-align class to remove javascript positioning --> <%-- Remove js-align class to remove javascript positioning --%>
</li> <button class="step-button-jump js-align" disabled="disabled">$Pos</button>
</li>
<% end_loop %> <% end_loop %>
</ul> </ul>
</nav> </nav>

View File

@ -1,21 +1,17 @@
<% if $Form.Steps.Count > 1 %> <nav id="step-navigation" class="step-navigation" aria-hidden="true" style="display:none;">
<nav class="step-navigation" aria-hidden="true" style="display:none;"> <ul class="step-buttons">
<ul class="step-buttons"> <li class="step-button-wrapper">
<% if $StepNumber > 1 %> <button class="step-button-prev">Prev</button>
<li class="step-button-wrapper"> </li>
<button class="step-button-prev">Prev</button> <li class="step-button-wrapper">
</li> <button class="step-button-next">Next</button>
<% end_if %> </li>
<% if $Actions %>
<% if $StepNumber < $Form.Steps.Count %> <li class="step-button-wrapper Actions">
<li class="step-button-wrapper"> <% loop $Actions %>
<button class="step-button-next">Next</button> $Field
</li> <% end_loop %>
<% else_if $Form.Actions %><% loop $Form.Actions %> </li>
<li class="step-button-wrapper"> <% end_if %>
$Field </ul>
</li> </nav>
<% end_loop %><% end_if %>
</ul>
</nav>
<% end_if %>

View File

@ -17,6 +17,22 @@
<div class="clear"><!-- --></div> <div class="clear"><!-- --></div>
</fieldset> </fieldset>
<%--
Include step navigation if it's a multi-page form.
The markup inside this include is hidden by default and displayed if JavaScript is enabled.
--%>
<% if $Steps.Count > 1 %>
<% include UserFormStepNav %>
<% end_if %>
<%--
When JavaScript is disabled, multi-page forms are diaplayed as a single page form,
and these actions are used instead of the step navigation include.
These actions are hidden by JavaScript on multi-page forms.
--%>
<% if $Actions %> <% if $Actions %>
<div class="Actions"> <div class="Actions">
<% loop $Actions %> <% loop $Actions %>

View File

@ -7,10 +7,9 @@
</div> </div>
</fieldset> </fieldset>
<% end_if %> <% end_if %>
<% loop $Children %> <% loop $Children %>
$FieldHolder $FieldHolder
<% end_loop %> <% end_loop %>
<% include UserFormStepNav %>
</fieldset> </fieldset>