Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Inline form field validation #4034

Closed
wants to merge 23 commits into from
Closed
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b8b13de
Add validation bindings on text form fields in Form Widget
bennothommo Jan 4, 2019
1e3e49c
Add initial functionality to validate field in context of form contents
bennothommo Jan 4, 2019
0ecc793
Code style changes as requested by @LukeTowers
Jan 4, 2019
1c6af3b
Add inline error styling (icon with tooltip)
Jan 6, 2019
42bcf7e
Show inline error message for text input and textarea fields
Jan 6, 2019
28604d8
Remove semi-colons
Jan 6, 2019
9c081ba
Check that a field exists in field div before attaching listeners
Jan 6, 2019
f8678d9
Remove more semi-colons
Jan 6, 2019
0e96c71
Remove unused reference
bennothommo Jan 7, 2019
87c1bf5
Change to inline error messages appearing underneath field(s)
bennothommo Jan 9, 2019
57d24ef
Remove feedback color on help block to make inline error more visible
bennothommo Jan 9, 2019
5e14b7a
Remove semi-colons
bennothommo Jan 9, 2019
ef5a227
Allow inline validation to be toggled at a form, section and field level
bennothommo Jan 9, 2019
bfbfee3
Enable inline validation on main fields in administrator form
bennothommo Jan 9, 2019
8617232
Remove semi-colons
bennothommo Jan 9, 2019
9e8c666
Change boolval() calls to bool type-casting
bennothommo Jan 10, 2019
0a3462a
Add bindings for select dropdowns
bennothommo Jan 11, 2019
9ef8ddd
Merge branch 'develop' into form-field-validation
Jan 21, 2019
30dfc58
Allow definition of inline validation flag in form fields YAML file
Jan 21, 2019
9981c88
Allow form fields to be invidually specified for inline validation
Jan 21, 2019
106afbf
Bypass mass assignment restrictions when validating full data
Jan 21, 2019
b25117e
Add balloon selector validation binding
Jan 21, 2019
f7fa917
Merge branch 'develop' into form-field-validation
bennothommo Apr 3, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions modules/backend/behaviors/FormController.php
Expand Up @@ -133,6 +133,7 @@ public function initForm($model, $context = null)
$config->model = $model;
$config->arrayName = class_basename($model);
$config->context = $context;
$config->inlineValidation = $this->getConfig("{$context}[inlineValidation]", false);

/*
* Form Widget with extensibility
Expand Down
9 changes: 8 additions & 1 deletion modules/backend/classes/FormField.php
Expand Up @@ -176,6 +176,11 @@ class FormField
*/
public $preset;

/**
* @var bool Specifies if this field should use inline validation.
*/
public $inlineValidation = null;

/**
* Constructor.
* @param string $fieldName
Expand Down Expand Up @@ -329,7 +334,9 @@ protected function evalConfig($config)
if (isset($config['containerAttributes'])) {
$this->attributes($config['containerAttributes'], 'container');
}

if (isset($config['inlineValidation'])) {
$this->inlineValidation = boolval($config['inlineValidation']);
bennothommo marked this conversation as resolved.
Show resolved Hide resolved
}
if (isset($config['valueFrom'])) {
$this->valueFrom = $config['valueFrom'];
}
Expand Down
21 changes: 15 additions & 6 deletions modules/backend/classes/FormTabs.php
Expand Up @@ -31,12 +31,12 @@ class FormTabs implements IteratorAggregate, ArrayAccess
* @var string Default tab label to use when none is specified.
*/
public $defaultTab = 'backend::lang.form.undefined_tab';

/**
* @var array List of icons for their corresponding tabs.
*/
public $icons = [];

/**
* @var bool Should these tabs stretch to the bottom of the page layout.
*/
Expand All @@ -47,6 +47,11 @@ class FormTabs implements IteratorAggregate, ArrayAccess
*/
public $suppressTabs = false;

/**
* @var boolean If set to TRUE, this section will use inline validation for fields.
*/
public $inlineValidation = false;

/**
* @var string Specifies a CSS class to attach to the tab container.
*/
Expand Down Expand Up @@ -86,11 +91,11 @@ protected function evalConfig($config)
if (array_key_exists('defaultTab', $config)) {
$this->defaultTab = $config['defaultTab'];
}

if (array_key_exists('icons', $config)) {
$this->icons = $config['icons'];
}

if (array_key_exists('stretch', $config)) {
$this->stretch = $config['stretch'];
}
Expand All @@ -99,6 +104,10 @@ protected function evalConfig($config)
$this->suppressTabs = $config['suppressTabs'];
}

if (array_key_exists('inlineValidation', $config)) {
$this->inlineValidation = boolval($config['inlineValidation']);
}

if (array_key_exists('cssClass', $config)) {
$this->cssClass = $config['cssClass'];
}
Expand Down Expand Up @@ -182,7 +191,7 @@ public function getAllFields()

return $tablessFields;
}

/**
* Returns an icon for the tab based on the tab's name.
* @param string $name
Expand All @@ -194,7 +203,7 @@ public function getIcon($name)
return $this->icons[$name];
}
}

/**
* Returns a tab pane CSS class.
* @param string $index
Expand Down
3 changes: 2 additions & 1 deletion modules/backend/models/user/fields.yaml
Expand Up @@ -16,7 +16,8 @@ tabs:
backend::lang.user.account: icon-user
backend::lang.user.groups: icon-users
backend::lang.user.permissions: icon-key

inlineValidation: true

fields:
login:
span: left
Expand Down
54 changes: 53 additions & 1 deletion modules/backend/widgets/Form.php
Expand Up @@ -70,6 +70,12 @@ class Form extends WidgetBase
*/
public $isNested = false;

/**
* @var boolean If set to TRUE, this form or section will use inline validation for
* fields.
*/
public $inlineValidation = false;

//
// Object properties
//
Expand Down Expand Up @@ -134,6 +140,7 @@ public function init()
'context',
'arrayName',
'isNested',
'inlineValidation'
]);

$this->widgetManager = WidgetManager::instance();
Expand Down Expand Up @@ -204,6 +211,7 @@ public function render($options = [])

$targetPartial = 'section';
$extraVars['renderSection'] = $section;
$extraVars['inlineValidation'] = $this->allTabs->{$section}->inlineValidation ?? false;
}

/*
Expand Down Expand Up @@ -240,7 +248,7 @@ public function render($options = [])
public function renderField($field, $options = [])
{
$this->prepareVars();

if (is_string($field)) {
if (!isset($this->allFields[$field])) {
throw new ApplicationException(Lang::get(
Expand Down Expand Up @@ -452,6 +460,50 @@ public function onRefresh()
return $result;
}

/**
* Validates a single field in the context of the entire form contents.
*
* @return array `valid` (bool) whether the field input is valid
* `message` (string) the validation error message, if any
*/
public function onValidateField()
{
$saveData = $this->getSaveData();
$field = input('fieldName');
$valid = true;
$message = null;

// Check that the model has a validation method. This should be provided by
// the October\Rain\Database\Traits\Validation trait.
if (!method_exists($this->model, 'validate')) {
return;
}

$className = get_class($this->model);
$model = new $className($saveData);
try {
$success = $model->validate();
} catch (\Exception $e) {
$success = false;
}

if (!$success) {
$errors = $model->errors()->toArray() ?? [
$field => $e->getMessage()
];

if (isset($errors[$field])) {
$valid = false;
$message = $errors[$field][0];
}
}

return [
'valid' => $valid,
'message' => $message
];
}

/**
* Creates a flat array of form fields from the configuration.
* Also slots fields in to their respective tabs.
Expand Down
125 changes: 125 additions & 0 deletions modules/backend/widgets/form/assets/js/october.form.js
Expand Up @@ -12,6 +12,7 @@
this.$el = $(element)
this.options = options || {}
this.fieldElementCache = null
this.fieldValidationElementCache = null

/*
* Throttle dependency updating
Expand All @@ -35,6 +36,7 @@
this.bindCheckboxlist()
this.toggleEmptyTabs()
this.bindCollapsibleSections()
this.bindValidation()

this.$el.on('oc.triggerOn.afterUpdate', this.proxy(this.toggleEmptyTabs))
this.$el.one('dispose-control', this.proxy(this.dispose))
Expand Down Expand Up @@ -89,6 +91,36 @@
return this.fieldElementCache = form.find('[data-field-name]').not(nestedFields)
}

/*
* Get all fields elements that belong to this form or are part of a section of this form that
* has inline validation enabled, or have inline validation enabled for the field explicitly.
* Strip out any fields that are explicitly not to be validated, or are part of a nested form.
*/
FormWidget.prototype.getFieldElementsToValidate = function() {
if (this.fieldValidationElementCache !== null) {
return this.fieldValidationElementCache
}

var $form = this.$el,
nestedFields = $form.find('[data-control="formwidget"] [data-field-name]'),
fields = []

// Find forms and sections that are marked for inline validation
if ($form.data('inline-validation') !== undefined && $form.data('inline-validation') !== 0) {
$form.find('[data-field-name]').not(nestedFields).not('[data-inline-validation="0"]').each(function () {
var $field = $(this);

if ($field.data('inline-validation') !== undefined && $field.data('inline-validation') === 0) {
return
}

fields.push(this)
})
}

return this.fieldValidationElementCache = fields;
}

/*
* Bind dependant fields
*/
Expand Down Expand Up @@ -220,6 +252,99 @@
.nextUntil('.section-field').hide()
}

/*
* Bind validation checks on available fields, triggered when
* the fields is blurred or changed
*/
FormWidget.prototype.bindValidation = function () {
var fields = this.getFieldElementsToValidate(),
$form = this.$form

for (var i in fields) {
var field = fields[i]

if (!field || !field.className) {
continue
}

var fieldClasses = field.className.split(/\s+/).map(function (className) {
Copy link
Contributor

@w20k w20k May 4, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bennothommo I think this part is overly complicated :)
If I've understood it correctly you need to find the field which has a class corresponded to the Regexp - /^([a-z\-]+)-field$/ and that's it.

Two options: either use indexOf() to cut down all those not needed fields or change Regex to be more accurate (before&after spaces, dirty try: (\s+)?([a-z\-]+)-field(\s+)? ).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@w20k Yeah I did initially have just a regex, but I thought this way was more readable, albeit a bit more complex. Your regex works just as well though. :)

var fieldClass = className.match(/^([a-z\-]+)-field$/)
if (fieldClass) {
return fieldClass[1]
} else {
return null
}
}).filter(function (className) {
return (className != null)
})

if (fieldClasses.length === 0) {
continue
}

// Load validator depending on field type
switch (fieldClasses[0]) {
case 'text':
case 'password':
bennothommo marked this conversation as resolved.
Show resolved Hide resolved
case 'number':
case 'textarea':
this._validateText($form, field)
break
}
}
}

FormWidget.prototype.fieldResponseHandler = function ($form, $field, data, status) {
if (status !== 'success') {
return
}

if (data.valid !== undefined && data.valid === false) {
this.showFieldError($field, data.message)
}
}

FormWidget.prototype.showFieldError = function ($field, message) {
$field.addClass('has-error')

var $error = $('<div class="validation-error"></div>')
$error.text(message)

$field.append($error)
}


FormWidget.prototype.clearFieldError = function ($field) {
$field.removeClass('has-error')
$field.find('.validation-error').remove()
}

FormWidget.prototype._validateText = function ($form, field) {
var $elem = this.$el,
$field = $(field),
widget = this

var innerField = field.querySelector('input,textarea')
if (!innerField) {
return
}

innerField.addEventListener('blur', function (ev) {
widget.clearFieldError($field)

$elem.request('onValidateField', {
data: {
fieldId: field.id,
fieldName: field.dataset.fieldName
},
form: $form,
success: function (data, status, jqXHR) {
widget.fieldResponseHandler($form, $field, data, status, jqXHR)
}
})
})
}

FormWidget.DEFAULTS = {
refreshHandler: null,
refreshData: {}
Expand Down
5 changes: 4 additions & 1 deletion modules/backend/widgets/form/partials/_field-container.htm
Expand Up @@ -3,7 +3,10 @@
<?php if ($depends = $this->getFieldDepends($field)): ?>data-field-depends="<?= $depends ?>"<?php endif ?>
data-field-name="<?= $field->fieldName ?>"
<?= $field->getAttributes('container') ?>
<?php if (isset($field->inlineValidation)): ?>
data-inline-validation="<?= ($field->inlineValidation === true) ? 1 : 0 ?>"
<?php endif; ?>
id="<?= $field->getId('group') ?>"><?=
/* Must be on the same line for :empty selector */
trim($this->makePartial('field', ['field' => $field]))
?></div>
?></div>
3 changes: 3 additions & 0 deletions modules/backend/widgets/form/partials/_form-container.htm
@@ -1,6 +1,9 @@
<div
data-control="formwidget"
data-refresh-handler="<?= $this->getEventHandler('onRefresh') ?>"
<?php if ($this->inlineValidation): ?>
data-inline-validation="1"
<?php endif; ?>
class="form-widget form-elements layout"
role="form"
id="<?= $this->getId() ?>">
Expand Down
5 changes: 4 additions & 1 deletion modules/backend/widgets/form/partials/_section-container.htm
@@ -1,6 +1,9 @@
<div
data-control="formwidget"
data-refresh-handler="<?= $this->getEventHandler('onRefresh') ?>"
<?php if (isset($inlineValidation)): ?>
data-inline-validation="<?= ($inlineValidation === true) ? 1 : 0 ?>"
<?php endif; ?>
class="layout-row"
role="form"
id="<?= $this->getId($renderSection.'Container') ?>">
Expand All @@ -17,4 +20,4 @@
<?= $this->makePartial('section', ['tabs' => $secondaryTabs]) ?>
<?php endif ?>

</div>
</div>