Enhancement: Implemented and/or display rules for UserForms

This commit is contained in:
Franco Springveldt 2017-04-28 10:22:15 +12:00 committed by Damian Mooyman
parent 916dbf42c7
commit a94f0e35aa
11 changed files with 439 additions and 213 deletions

View File

@ -6,6 +6,10 @@
* *
* @method EditableFormField Parent() * @method EditableFormField Parent()
* @package userforms * @package userforms
*
* @property string Display
* @property string ConditionOption
* @property string FieldValue
*/ */
class EditableCustomRule extends DataObject class EditableCustomRule extends DataObject
{ {
@ -147,4 +151,91 @@ class EditableCustomRule extends DataObject
{ {
return $this->canDelete($member); return $this->canDelete($member);
} }
/**
* Substitutes configured rule logic with it's JS equivalents and returns them as array elements
* @return array
*/
public function buildExpression()
{
/** @var EditableFormField $formFieldWatch */
$formFieldWatch = $this->ConditionField();
//Encapsulated the action to the object
$action = $formFieldWatch->getJsEventHandler();
// is this field a special option field
$checkboxField = $formFieldWatch->isCheckBoxField();
$radioField = $formFieldWatch->isRadioField();
$target = sprintf('$("%s")', $formFieldWatch->getSelectorFieldOnly());
$fieldValue = Convert::raw2js($this->FieldValue);
$conditionOptions = array(
'ValueLessThan' => '<',
'ValueLessThanEqual' => '<=',
'ValueGreaterThan' => '>',
'ValueGreaterThanEqual' => '>='
);
// and what should we evaluate
switch ($this->ConditionOption) {
case 'IsNotBlank':
case 'IsBlank':
$expression = ($checkboxField || $radioField) ? "!{$target}.is(\":checked\")" : "{$target}.val() == ''";
if ($this->ConditionOption == 'IsNotBlank') {
//Negate
$expression = "!({$expression})";
}
break;
case 'HasValue':
case 'ValueNot':
if ($checkboxField) {
if ($formFieldWatch->isCheckBoxGroupField()) {
$expression = sprintf("$.inArray('%s', %s.filter(':checked').map(function(){ return $(this).val();}).get()) > -1",
$fieldValue, $target);
} else {
$expression = "{$target}.prop('checked')";
}
} 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"',
$target, $fieldValue);
} else {
$expression = sprintf('%s.val() == "%s"', $target, $fieldValue);
}
if ($this->ConditionOption == 'ValueNot') {
//Negate
$expression = "!({$expression})";
}
break;
case 'ValueLessThan':
case 'ValueLessThanEqual':
case 'ValueGreaterThan':
case 'ValueGreaterThanEqual':
$expression = sprintf('%s.val() %s parseFloat("%s")', $target,
$conditionOptions[$this->ConditionOption], $fieldValue);
break;
default:
throw new LogicException("Unhandled rule {$this->ConditionOption}");
break;
}
$result = array(
'operation' => $expression,
'event' => $action,
);
return $result;
}
/**
* Returns the opposite of the show/hide pairs of strings
*
* @param string $text
*
* @return string
*/
public function toggleDisplayText($text)
{
return (strtolower($text) === 'show') ? 'hide' : 'show';
}
} }

View File

@ -454,183 +454,29 @@ class UserDefinedForm_Controller extends Page_Controller
$rules = ""; $rules = "";
$watch = array(); $watch = array();
$watchLoad = array();
if ($this->Fields()) { if ($this->Fields()) {
/** @var EditableFormField $field */
foreach ($this->Fields() as $field) { foreach ($this->Fields() as $field) {
$holderSelector = $field->getSelectorHolder(); if ($result = $field->formatDisplayRules()) {
$watch[] = $result;
// Is this Field Show by Default
if (!$field->ShowOnLoad) {
$default .= "{$holderSelector}.hide().trigger('userform.field.hide');\n";
}
// Check for field dependencies / default
foreach ($field->EffectiveDisplayRules() as $rule) {
// Get the field which is effected
$formFieldWatch = EditableFormField::get()->byId($rule->ConditionFieldID);
// Skip deleted fields
if (!$formFieldWatch) {
continue;
}
$fieldToWatch = $formFieldWatch->getSelectorField($rule);
$fieldToWatchOnLoad = $formFieldWatch->getSelectorField($rule, true);
// show or hide?
$view = ($rule->Display == 'Hide') ? 'hide' : 'show';
$opposite = ($view == "show") ? "hide" : "show";
// what action do we need to keep track of. Something nicer here maybe?
// @todo encapulsation
$action = "change";
if ($formFieldWatch instanceof EditableTextField) {
$action = "keyup";
}
// is this field a special option field
$checkboxField = false;
$radioField = false;
if (in_array($formFieldWatch->ClassName, array('EditableCheckboxGroupField', 'EditableCheckbox'))) {
$action = "click";
$checkboxField = true;
} elseif ($formFieldWatch->ClassName == "EditableRadioField") {
$radioField = true;
}
// and what should we evaluate
switch ($rule->ConditionOption) {
case 'IsNotBlank':
$expression = ($checkboxField || $radioField) ? '$(this).is(":checked")' :'$(this).val() != ""';
break;
case 'IsBlank':
$expression = ($checkboxField || $radioField) ? '!($(this).is(":checked"))' : '$(this).val() == ""';
break;
case 'HasValue':
if ($checkboxField) {
$expression = '$(this).prop("checked")';
} elseif ($radioField) {
// We cannot simply get the value of the radio group, we need to find the checked option first.
$expression = '$(this).parents(".field, .control-group").find("input:checked").val()=="'. $rule->FieldValue .'"';
} else {
$expression = '$(this).val() == "'. $rule->FieldValue .'"';
}
break;
case 'ValueLessThan':
$expression = '$(this).val() < parseFloat("'. $rule->FieldValue .'")';
break;
case 'ValueLessThanEqual':
$expression = '$(this).val() <= parseFloat("'. $rule->FieldValue .'")';
break;
case 'ValueGreaterThan':
$expression = '$(this).val() > parseFloat("'. $rule->FieldValue .'")';
break;
case 'ValueGreaterThanEqual':
$expression = '$(this).val() >= parseFloat("'. $rule->FieldValue .'")';
break;
default: // ==HasNotValue
if ($checkboxField) {
$expression = '!$(this).prop("checked")';
} elseif ($radioField) {
// We cannot simply get the value of the radio group, we need to find the checked option first.
$expression = '$(this).parents(".field, .control-group").find("input:checked").val()!="'. $rule->FieldValue .'"';
} else {
$expression = '$(this).val() != "'. $rule->FieldValue .'"';
}
break;
}
if (!isset($watch[$fieldToWatch])) {
$watch[$fieldToWatch] = array();
}
$watch[$fieldToWatch][] = array(
'expression' => $expression,
'holder_selector' => $holderSelector,
'view' => $view,
'opposite' => $opposite,
'action' => $action
);
$watchLoad[$fieldToWatchOnLoad] = true;
} }
} }
} }
if ($watch) { if ($watch) {
foreach ($watch as $key => $values) { $rules .= $this->buildWatchJS($watch);
$logic = array();
$actions = array();
foreach ($values as $rule) {
// Assign 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);
$rules .= $key.".each(function() {\n
$(this).data('userformConditions', function() {\n
$logic\n
}); \n
});\n";
foreach ($actions as $action) {
$rules .= $key.".$action(function() {
$(this).data('userformConditions').call(this);\n
});\n";
}
}
}
if ($watchLoad) {
foreach ($watchLoad as $key => $value) {
$rules .= $key.".each(function() {
$(this).data('userformConditions').call(this);\n
});\n";
}
} }
// Only add customScript if $default or $rules is defined // Only add customScript if $default or $rules is defined
if ($default || $rules) { if ($rules) {
Requirements::customScript(<<<JS Requirements::customScript(<<<JS
(function($) { (function($) {
$(document).ready(function() { $(document).ready(function() {
$default {$rules}
});
$rules })(jQuery);
})
})(jQuery);
JS JS
, 'UserFormsConditional'); , 'UserFormsConditional');
} }
} }
@ -897,4 +743,46 @@ JS
'Form' => '', 'Form' => '',
)); ));
} }
/**
* Outputs the required JS from the $watch input
*
* @param array $watch
*
* @return string
*/
protected function buildWatchJS($watch)
{
$result = '';
foreach ($watch as $key => $rule) {
$events = implode(' ', $rule['events']);
$selectors = implode(', ', $rule['selectors']);
$conjunction = $rule['conjunction'];
$operations = implode(" {$conjunction} ", $rule['operations']);
$target = $rule['targetFieldID'];
$initialState = $rule['initialState'];
$view = $rule['view'];
$opposite = $rule['opposite'];
$result .= <<<EOS
\n
//Initial state
$('{$target}').{$initialState}();
$('.userform').on('{$events}',
"{$selectors}",
function (){
if({$operations}) {
$('{$target}').{$view}();
} else {
$('{$target}').{$opposite}();
}
});
EOS;
}
return $result;
}
} }

View File

@ -14,6 +14,8 @@ class EditableCheckbox extends EditableFormField
private static $plural_name = 'Checkboxes'; private static $plural_name = 'Checkboxes';
protected $jsEventHandler = 'click';
private static $db = array( private static $db = array(
'CheckedDefault' => 'Boolean' // from CustomSettings 'CheckedDefault' => 'Boolean' // from CustomSettings
); );
@ -61,4 +63,8 @@ class EditableCheckbox extends EditableFormField
parent::migrateSettings($data); parent::migrateSettings($data);
} }
public function isCheckBoxField() {
return true;
}
} }

View File

@ -14,6 +14,8 @@ class EditableCheckboxGroupField extends EditableMultipleOptionField
private static $plural_name = "Checkbox Groups"; private static $plural_name = "Checkbox Groups";
protected $jsEventHandler = 'click';
public function getFormField() public function getFormField()
{ {
$field = new UserFormsCheckboxSetField($this->Name, $this->EscapedTitle, $this->getOptionsMap()); $field = new UserFormsCheckboxSetField($this->Name, $this->EscapedTitle, $this->getOptionsMap());
@ -59,4 +61,18 @@ class EditableCheckboxGroupField extends EditableMultipleOptionField
return "$(\"input[name='{$this->Name}[]']:first\")"; return "$(\"input[name='{$this->Name}[]']:first\")";
} }
} }
public function isCheckBoxField() {
return true;
}
public function getSelectorFieldOnly()
{
return "[name='{$this->Name}[]']";
}
public function isCheckBoxGroupField()
{
return true;
}
} }

View File

@ -14,6 +14,8 @@ use SilverStripe\Forms\SegmentField;
* @property int $Sort * @property int $Sort
* @property bool $Required * @property bool $Required
* @property string $CustomErrorMessage * @property string $CustomErrorMessage
* @property boolean $ShowOnLoad
* @property string $DisplayRulesConjunction
* @method UserDefinedForm Parent() Parent page * @method UserDefinedForm Parent() Parent page
* @method DataList DisplayRules() List of EditableCustomRule objects * @method DataList DisplayRules() List of EditableCustomRule objects
*/ */
@ -87,8 +89,10 @@ class EditableFormField extends DataObject
"RightTitle" => "Varchar(255)", // from CustomSettings "RightTitle" => "Varchar(255)", // from CustomSettings
"ShowOnLoad" => "Boolean(1)", // from CustomSettings "ShowOnLoad" => "Boolean(1)", // from CustomSettings
"ShowInSummary" => "Boolean", "ShowInSummary" => "Boolean",
'DisplayRulesConjunction' => 'Enum("And,Or","Or")',
); );
private static $defaults = array( private static $defaults = array(
'ShowOnLoad' => true, 'ShowOnLoad' => true,
); );
@ -125,6 +129,22 @@ class EditableFormField extends DataObject
*/ */
protected $readonly; protected $readonly;
/**
* Property holds the JS event which gets fired for this type of element
*
* @var string
*/
protected $jsEventHandler = 'change';
/**
* Returns the jsEventHandler property for the current object. Bearing in mind it could've been overridden.
* @return string
*/
public function getJsEventHandler()
{
return $this->jsEventHandler;
}
/** /**
* Set the visibility of an individual form field * Set the visibility of an individual form field
* *
@ -244,44 +264,36 @@ class EditableFormField extends DataObject
// Check display rules // Check display rules
if ($this->Required) { if ($this->Required) {
return new FieldList( return new FieldList(
LabelField::create(_t( LabelField::create(
'EditableFormField.DISPLAY_RULES_DISABLED', _t(
'Display rules are not enabled for required fields. ' . 'EditableFormField.DISPLAY_RULES_DISABLED',
'Please uncheck "Is this field Required?" under "Validation" to re-enable.' 'Display rules are not enabled for required fields. Please uncheck "Is this field Required?" under "Validation" to re-enable.'))
))->addExtraClass('message warning') ->addExtraClass('message warning'));
);
} }
$self = $this; $self = $this;
$allowedClasses = array_keys($this->getEditableFieldClasses(false)); $allowedClasses = array_keys($this->getEditableFieldClasses(false));
$editableColumns = new GridFieldEditableColumns(); $editableColumns = new GridFieldEditableColumns();
$editableColumns->setDisplayFields(array( $editableColumns->setDisplayFields(array(
'Display' => '', 'ConditionFieldID' => function ($record, $column, $grid) use ($allowedClasses, $self) {
'ConditionFieldID' => function ($record, $column, $grid) use ($allowedClasses, $self) { return DropdownField::create($column, '', EditableFormField::get()->filter(array(
return DropdownField::create( 'ParentID' => $self->ParentID,
$column, 'ClassName' => $allowedClasses,
'', ))->exclude(array(
EditableFormField::get() 'ID' => $self->ID,
->filter(array( ))->map('ID', 'Title'));
'ParentID' => $self->ParentID, },
'ClassName' => $allowedClasses 'ConditionOption' => function ($record, $column, $grid) {
)) $options = Config::inst()->get('EditableCustomRule', 'condition_options');
->exclude(array(
'ID' => $self->ID return DropdownField::create($column, '', $options);
)) },
->map('ID', 'Title') 'FieldValue' => function ($record, $column, $grid) {
); return TextField::create($column);
}, },
'ConditionOption' => function ($record, $column, $grid) { 'ParentID' => function ($record, $column, $grid) use ($self) {
$options = Config::inst()->get('EditableCustomRule', 'condition_options'); return HiddenField::create($column, '', $self->ID);
return DropdownField::create($column, '', $options); },
}, ));
'FieldValue' => function ($record, $column, $grid) {
return TextField::create($column);
},
'ParentID' => function ($record, $column, $grid) use ($self) {
return HiddenField::create($column, '', $self->ID);
}
));
// Custom rules // Custom rules
$customRulesConfig = GridFieldConfig::create() $customRulesConfig = GridFieldConfig::create()
@ -294,11 +306,20 @@ class EditableFormField extends DataObject
); );
return new FieldList( return new FieldList(
CheckboxField::create('ShowOnLoad') DropdownField::create('ShowOnLoad',
->setDescription(_t( _t('EditableFormField.INITIALVISIBILITY', 'Initial visibility'),
'EditableFormField.SHOWONLOAD', array(
'Initial visibility before processing these rules' 1 => 'Show',
)), 0 => 'Hide',
)
),
DropdownField::create('DisplayRulesConjunction',
_t('EditableFormField.DISPLAYIF', 'Toggle visibility when'),
array(
'Or' => _t('UserDefinedForm.SENDIFOR', 'Any conditions are true'),
'And' => _t('UserDefinedForm.SENDIFAND', 'All conditions are true'),
)
),
GridField::create( GridField::create(
'DisplayRules', 'DisplayRules',
_t('EditableFormField.CUSTOMRULES', 'Custom Rules'), _t('EditableFormField.CUSTOMRULES', 'Custom Rules'),
@ -489,6 +510,7 @@ class EditableFormField extends DataObject
{ {
$this->publish($fromStage, $toStage, $createNewVersion); $this->publish($fromStage, $toStage, $createNewVersion);
$seenIDs = array(); $seenIDs = array();
// Don't forget to publish the related custom rules... // Don't forget to publish the related custom rules...
@ -899,18 +921,38 @@ class EditableFormField extends DataObject
*/ */
public function getSelectorHolder() public function getSelectorHolder()
{ {
return "$(\"#{$this->Name}\")"; return sprintf('$("%s")', $this->getSelectorOnly());
}
/**
* Returns only the JS identifier of a string, less the $(), which can be inserted elsewhere, for example when you
* want to perform selections on multiple selectors
* @return string
*/
public function getSelectorOnly()
{
return "#{$this->Name}";
} }
/** /**
* Gets the JS expression for selecting the value for this field * Gets the JS expression for selecting the value for this field
* *
* @param EditableCustomRule $rule Custom rule this selector will be used with * @param EditableCustomRule $rule Custom rule this selector will be used with
* @param bool $forOnLoad Set to true if this will be invoked on load * @param bool $forOnLoad Set to true if this will be invoked on load
*
* @return string
*/ */
public function getSelectorField(EditableCustomRule $rule, $forOnLoad = false) public function getSelectorField(EditableCustomRule $rule, $forOnLoad = false)
{ {
return "$(\"input[name='{$this->Name}']\")"; return sprintf("$(%s)", $this->getSelectorFieldOnly());
}
/**
* @return string
*/
public function getSelectorFieldOnly()
{
return "[name='{$this->Name}']";
} }
@ -970,4 +1012,95 @@ class EditableFormField extends DataObject
} }
return $this->DisplayRules(); return $this->DisplayRules();
} }
/**
* Extracts info from DisplayRules into array so UserDefinedForm->buildWatchJS can run through it.
* @return array|null
*/
public function formatDisplayRules()
{
$holderSelector = $this->getSelectorOnly();
$result = array(
'targetFieldID' => $holderSelector,
'conjunction' => $this->DisplayRulesConjunctionNice(),
'selectors' => array(),
'events' => array(),
'operations' => array(),
'initialState' => $this->ShowOnLoadNice(),
'view' => array(),
'opposite' => array(),
);
// Check for field dependencies / default
/** @var EditableCustomRule $rule */
foreach ($this->EffectiveDisplayRules() as $rule) {
// Get the field which is effected
/** @var EditableFormField $formFieldWatch */
$formFieldWatch = EditableFormField::get()->byId($rule->ConditionFieldID);
// Skip deleted fields
if (! $formFieldWatch) {
continue;
}
$fieldToWatch = $formFieldWatch->getSelectorFieldOnly();
$expression = $rule->buildExpression();
if (! in_array($fieldToWatch, $result['selectors'])) {
$result['selectors'][] = $fieldToWatch;
}
if (! in_array($expression['event'], $result['events'])) {
$result['events'][] = $expression['event'];
}
$result['operations'][] = $expression['operation'];
//View/Show should read
$result['view'] = $rule->toggleDisplayText($result['initialState']);
$result['opposite'] = $rule->toggleDisplayText($result['view']);
}
return (count($result['selectors'])) ? $result : null;
}
/**
* Replaces the set DisplayRulesConjunction with their JS logical operators
* @return string
*/
public function DisplayRulesConjunctionNice()
{
return (strtolower($this->DisplayRulesConjunction) === 'or') ? '||' : '&&';
}
/**
* Replaces boolean ShowOnLoad with its JS string equivalent
* @return string
*/
public function ShowOnLoadNice()
{
return ($this->ShowOnLoad) ? 'show' : 'hide';
}
/**
* Returns whether this is of type EditableCheckBoxField
* @return bool
*/
public function isCheckBoxField()
{
return false;
}
/**
* Returns whether this is of type EditableRadioField
* @return bool
*/
public function isRadioField()
{
return false;
}
/**
* Determined is this is of type EditableCheckboxGroupField
* @return bool
*/
public function isCheckBoxGroupField()
{
return false;
}
} }

View File

@ -46,4 +46,8 @@ class EditableRadioField extends EditableMultipleOptionField
$first = $forOnLoad ? ':first' : ''; $first = $forOnLoad ? ':first' : '';
return "$(\"input[name='{$this->Name}']{$first}\")"; return "$(\"input[name='{$this->Name}']{$first}\")";
} }
public function isRadioField() {
return true;
}
} }

View File

@ -44,6 +44,8 @@ class EditableTextField extends EditableFormField
'url' => 'Home page' 'url' => 'Home page'
); );
protected $jsEventHandler = 'keyup';
private static $db = array( private static $db = array(
'MinLength' => 'Int', 'MinLength' => 'Int',
'MaxLength' => 'Int', 'MaxLength' => 'Int',

View File

@ -0,0 +1,31 @@
<?php
/**
* Class EditableCustomRulesTest
*/
class EditableCustomRuleTest extends SapphireTest
{
protected static $fixture_file = 'userforms/tests/EditableCustomRuleTest.yml';
public function testBuildExpression()
{
/** @var EditableCustomRule $rule1 */
$rule1 = $this->objFromFixture('EditableCustomRule', 'rule1');
$result1 = $rule1->buildExpression();
//Dropdowns expect change event
$this->assertEquals('change', $result1['event']);
$this->assertNotEmpty($result1['operation']);
//Check for equals sign
$this->assertContains('==', $result1['operation']);
/** @var EditableCustomRule $rule2 */
$rule2 = $this->objFromFixture('EditableCustomRule', 'rule2');
$result2 = $rule2->buildExpression();
//TextField expect change event
$this->assertEquals('keyup', $result2['event']);
$this->assertNotEmpty($result2['operation']);
//Check for greater than sign
$this->assertContains('>', $result2['operation']);
}
}

View File

@ -0,0 +1,30 @@
EditableFormField:
countryDropdown:
ClassName: EditableCountryDropdownField
Name: CountrySelection
Title: "Choose your country"
DisplayRulesConjunction: And
ShowOnLoad: false
irdNumberField:
ClassName: EditableTextField
Name: IRDNumber
Title: "Enter your IRD Number"
countryTextField:
ClassName: EditableTextField
Name: CountryTextSelection
Title: "Enter your country (2 digit prefix)"
DisplayRulesConjunction: And
ShowOnLoad: false
EditableCustomRule:
rule1:
Display: Show
ConditionOption: HasValue
FieldValue: NZ
ConditionField: =>EditableFormField.countryDropdown
Parent: =>EditableFormField.irdNumberField
rule2:
Display: Show
ConditionOption: ValueGreaterThan
FieldValue: 1
ConditionField: =>EditableFormField.countryTextField
Parent: =>EditableFormField.irdNumberField

View File

@ -204,4 +204,13 @@ class EditableFormFieldTest extends FunctionalTest
$this->assertEquals(10, $attributes['data-rule-minlength']); $this->assertEquals(10, $attributes['data-rule-minlength']);
$this->assertEquals(20, $attributes['data-rule-maxlength']); $this->assertEquals(20, $attributes['data-rule-maxlength']);
} }
public function testFormatDisplayRules()
{
/** @var EditableCheckbox $checkbox */
$checkbox = $this->objFromFixture('EditableFormField', 'irdNumberField');
$displayRules = $checkbox->formatDisplayRules();
$this->assertNotNull($displayRules);
$this->assertCount(1, $displayRules['operations']);
}
} }

View File

@ -1,9 +1,25 @@
EditableFormField:
irdNumberField:
ClassName: EditableTextField
Name: IRDNumber
Title: "Enter your IRD Number"
countryTextField:
ClassName: EditableTextField
Name: CountryTextSelection
Title: "Enter your country (2 digit prefix)"
DisplayRulesConjunction: And
ShowOnLoad: false
EditableCustomRule: EditableCustomRule:
rule1:
Display: Show
ConditionOption: HasValue
FieldValue: NZ
ConditionField: =>EditableFormField.countryTextField
Parent: =>EditableFormField.irdNumberField
rule-1: rule-1:
Display: Hide Display: Hide
ConditionOption: HasValue ConditionOption: HasValue
FieldValue: 6 FieldValue: 6
EditableOption: EditableOption:
option-1: option-1:
Name: Option1 Name: Option1