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

Inline form field validation #4322

Closed
wants to merge 26 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 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
9e85d43
Merge remote-tracking branch 'mine/form-field-validation' into wip/in…
bennothommo May 7, 2019
18142d6
Merge branch 'develop' into wip/inline-form-validation
bennothommo Jul 21, 2019
1dea729
Merge branch 'develop' into wip/inline-form-validation
LukeTowers Aug 16, 2020
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
3 changes: 3 additions & 0 deletions modules/backend/behaviors/FormController.php
Expand Up @@ -137,6 +137,9 @@ public function initForm($model, $context = null)
$config->model = $model;
$config->arrayName = class_basename($model);
$config->context = $context;
if (!isset($config->inlineValidation)) {
$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 The name of the field
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 = (bool) $config['inlineValidation'];
}
if (isset($config['valueFrom'])) {
$this->valueFrom = $config['valueFrom'];
}
Expand Down
9 changes: 9 additions & 0 deletions modules/backend/classes/FormTabs.php
Expand Up @@ -52,6 +52,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 @@ -109,6 +114,10 @@ protected function evalConfig($config)
$this->suppressTabs = $config['suppressTabs'];
}

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

if (array_key_exists('cssClass', $config)) {
$this->cssClass = $config['cssClass'];
}
Expand Down
1 change: 1 addition & 0 deletions modules/backend/models/user/fields.yaml
Expand Up @@ -16,6 +16,7 @@ tabs:
backend::lang.user.account: icon-user
backend::lang.user.groups: icon-users
backend::lang.user.permissions: icon-key
inlineValidation: true

fields:
login:
Expand Down
57 changes: 56 additions & 1 deletion modules/backend/widgets/Form.php
Expand Up @@ -71,6 +71,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 @@ -135,6 +141,7 @@ public function init()
'context',
'arrayName',
'isNested',
'inlineValidation'
]);

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

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

/*
Expand Down Expand Up @@ -456,6 +466,52 @@ 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;
$model->forceFill($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
];
}

/**
* Renders all fields of a tab in the target tab-pane.
*
Expand All @@ -478,7 +534,6 @@ public function onLazyLoadTab()
* Helper method to convert a field name to a valid ID attribute.
*
* @param $input
*
* @return string
*/
public function nameToId($input)
Expand Down
182 changes: 182 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 @@ -36,6 +37,7 @@
this.toggleEmptyTabs()
this.bindLazyTabs()
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 @@ -90,6 +92,35 @@
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 = []

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

return this.fieldValidationElementCache = fields
}

/*
* Bind dependant fields
*/
Expand Down Expand Up @@ -262,6 +293,157 @@
.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) {
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':
case 'number':
case 'textarea':
this._validateText($form, field)
break
case 'dropdown':
this._validateDropdown($form, field)
break
case 'balloon-selector':
this._validateBalloonSelector($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.prototype._validateDropdown = function ($form, field) {
var $elem = this.$el,
$field = $(field),
widget = this

var $innerField = $(field.querySelector('select'))
if (!$innerField) {
return
}

$innerField.on('change', function () {
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.prototype._validateBalloonSelector = function ($form, field) {
var $elem = this.$el,
$field = $(field),
widget = this

var $innerField = $(field.querySelector('input'));
if (!$innerField) {
return
}

$innerField.on('change', function () {
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