diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md
index e322267398..4a91767446 100644
--- a/framework/CHANGELOG.md
+++ b/framework/CHANGELOG.md
@@ -258,6 +258,9 @@ Yii Framework 2 Change Log
- Chg #4591: `yii\helpers\Url::to()` will no longer prefix relative URLs with the base URL (qiangxue)
- Chg #4595: `yii\widgets\LinkPager`'s `nextPageLabel`, `prevPageLabel`, `firstPageLabel`, `lastPageLabel` are now taking `false` instead of `null` for "no label" (samdark)
- Chg #4911: Changed callback signature used in `yii\base\ArrayableTrait::fields()` from `function ($field, $model) {` to `function ($model, $field) {` (samdark)
+- Chg #4955: Replaced callbacks with events for `ActiveForm` (qiangxue)
+ - Removed `beforeValidate()`, `beforeValidateAll()`, `afterValidate()`, `afterValidateAll()`, `ajaxBeforeSend()` and `ajaxComplete()` from `ActiveForm`.
+ - Added `beforeValidate`, `afterValidate`, `beforeSubmit`, `ajaxBeforeSend` and `ajaxComplete` events to `yii.activeForm`.
- Chg: Replaced `clearAll()` and `clearAllAssignments()` in `yii\rbac\ManagerInterface` with `removeAll()`, `removeAllRoles()`, `removeAllPermissions()`, `removeAllRules()` and `removeAllAssignments()` (qiangxue)
- Chg: Added `$user` as the first parameter of `yii\rbac\Rule::execute()` (qiangxue)
- Chg: `yii\grid\DataColumn::getDataCellValue()` visibility is now `public` to allow accessing the value from a GridView directly (cebe)
diff --git a/framework/UPGRADE.md b/framework/UPGRADE.md
index 88389412e9..24309306df 100644
--- a/framework/UPGRADE.md
+++ b/framework/UPGRADE.md
@@ -213,3 +213,18 @@ new ones save the following code as `convert.php` that should be placed in the s
* `Html::radio()`, `Html::checkbox()`, `Html::radioList()`, `Html::checkboxList()` no longer generate the container
tag around each radio/checkbox when you specify labels for them. You should manually render such container tags,
or set the `item` option for `Html::radioList()`, `Html::checkboxList()` to generate the container tags.
+
+* `beforeValidate()`, `beforeValidateAll()`, `afterValidate()`, `afterValidateAll()`, `ajaxBeforeSend()` and `ajaxComplete()`
+ are removed from `ActiveForm`. The same functionality is now achieved via JavaScript event mechanism. For example,
+ if you want to do something before performing validation on the client side, you can write the following
+ JavaScript code:
+
+ ```js
+ $('#myform').on('beforeValidate', function (event, messages, deferreds, attribute) {
+ if (attribute === undefined) {
+ // the event is triggered when submitting the form
+ } elseif (attribute.id === 'something') {
+ // the event is triggered before validating "something"
+ }
+ });
+ ```
diff --git a/framework/assets/yii.activeForm.js b/framework/assets/yii.activeForm.js
index a7a4f9013e..8d1429ad75 100644
--- a/framework/assets/yii.activeForm.js
+++ b/framework/assets/yii.activeForm.js
@@ -22,6 +22,66 @@
}
};
+ var events = {
+ /**
+ * beforeValidate event is triggered before validating the whole form and each attribute.
+ * The signature of the event handler should be:
+ * function (event, messages, deferreds, attribute)
+ * where
+ * - event: an Event object. You can set event.isValid to be false to stop validating the form or attribute
+ * - messages: error messages. When attribute is undefined, this parameter is an associative array
+ * with keys being attribute IDs and values being error messages for the corresponding attributes.
+ * When attribute is given, this parameter is an array of the error messages for that attribute.
+ * - deferreds: an array of Deferred objects. You can use deferreds.add(callback) to add a new deferred validation.
+ * - attribute: an attribute object. Please refer to attributeDefaults for the structure.
+ * If this is undefined, it means the event is triggered before validating the whole form.
+ * Otherwise it means the event is triggered before validating the specified attribute.
+ */
+ beforeValidate: 'beforeValidate',
+ /**
+ * afterValidate event is triggered after validating the whole form and each attribute.
+ * The signature of the event handler should be:
+ * function (event, messages, attribute)
+ * where
+ * - event: an Event object.
+ * - messages: error messages. When attribute is undefined, this parameter is an associative array
+ * with keys being attribute IDs and values being error messages for the corresponding attributes.
+ * When attribute is given, this parameter is an array of the error messages for that attribute.
+ * If the array length is greater than 0, it means the attribute has validation errors.
+ * - attribute: an attribute object. Please refer to attributeDefaults for the structure.
+ * If this is undefined, it means the event is triggered before validating the whole form.
+ * Otherwise it means the event is triggered before validating the specified attribute.
+ */
+ afterValidate: 'afterValidate',
+ /**
+ * beforeSubmit event is triggered before submitting the form (after all validations pass).
+ * The signature of the event handler should be:
+ * function (event)
+ * where event is an Event object.
+ */
+ beforeSubmit: 'beforeSubmit',
+ /**
+ * ajaxBeforeSend event is triggered before sending an AJAX request for AJAX-based validation.
+ * The signature of the event handler should be:
+ * function (event, jqXHR, settings)
+ * where
+ * - event: an Event object.
+ * - jqXHR: a jqXHR object
+ * - settings: the settings for the AJAX request
+ */
+ ajaxBeforeSend: 'ajaxBeforeSend',
+ /**
+ * ajaxComplete event is triggered after completing an AJAX request for AJAX-based validation.
+ * The signature of the event handler should be:
+ * function (event, jqXHR, textStatus)
+ * where
+ * - event: an Event object.
+ * - jqXHR: a jqXHR object
+ * - settings: the status of the request ("success", "notmodified", "error", "timeout", "abort", or "parsererror").
+ */
+ ajaxComplete: 'ajaxComplete'
+ };
+
// NOTE: If you change any of these defaults, make sure you update yii\widgets\ActiveForm::getClientOptions() as well
var defaults = {
// whether to encode the error summary
@@ -41,28 +101,7 @@
// the type of data that you're expecting back from the server
ajaxDataType: 'json',
// the URL for performing AJAX-based validation. If not set, it will use the the form's action
- validationUrl: undefined,
- // a callback that is called before submitting the form. The signature of the callback should be:
- // function ($form) { ...return false to cancel submission...}
- beforeSubmit: undefined,
- // a callback that is called before validating each attribute. The signature of the callback should be:
- // function ($form, attribute, messages) { ...return false to cancel the validation...}
- beforeValidate: undefined,
- // a callback that is called before validation starts (This callback is only called when the form is submitted). This signature of the callback should be:
- // function($form, data) { ...return false to cancel the validation...}
- beforeValidateAll: undefined,
- // a callback that is called after an attribute is validated. The signature of the callback should be:
- // function ($form, attribute, messages)
- afterValidate: undefined,
- // a callback that is called after all validation has run (This callback is only called when the form is submitted). The signature of the callback should be:
- // function ($form, data, messages)
- afterValidateAll: undefined,
- // a pre-request callback function on AJAX-based validation. The signature of the callback should be:
- // function ($form, jqXHR, textStatus)
- ajaxBeforeSend: undefined,
- // a function to be called when the request finishes on AJAX-based validation. The signature of the callback should be:
- // function ($form, jqXHR, textStatus)
- ajaxComplete: undefined
+ validationUrl: undefined
};
// NOTE: If you change any of these defaults, make sure you update yii\widgets\ActiveField::getClientOptions() as well
@@ -151,7 +190,7 @@
var $form = $(this),
attributes = $form.data('yiiActiveForm').attributes,
index = -1,
- attribute;
+ attribute = undefined;
$.each(attributes, function (i) {
if (attributes[i]['id'] == id) {
index = i;
@@ -168,7 +207,8 @@
// find an attribute config based on the specified attribute ID
find: function (id) {
- var attributes = $(this).data('yiiActiveForm').attributes, result;
+ var attributes = $(this).data('yiiActiveForm').attributes,
+ result = undefined;
$.each(attributes, function (i) {
if (attributes[i]['id'] == id) {
result = attributes[i];
@@ -189,66 +229,120 @@
return this.data('yiiActiveForm');
},
+ validate: function () {
+ var $form = $(this),
+ data = $form.data('yiiActiveForm'),
+ needAjaxValidation = false,
+ messages = {},
+ deferreds = deferredArray();
+
+ if (data.submitting) {
+ var event = $.Event(events.beforeValidate, {'isValid': true});
+ $form.trigger(event, [messages, deferreds]);
+ if (!event.isValid) {
+ data.submitting = false;
+ return;
+ }
+ }
+
+ // client-side validation
+ $.each(data.attributes, function () {
+ // perform validation only if the form is being submitted or if an attribute is pending validation
+ if (data.submitting || this.status === 2 || this.status === 3) {
+ var msg = messages[this.id];
+ if (msg === undefined) {
+ msg = [];
+ messages[this.id] = msg;
+ }
+ var event = $.Event(events.beforeValidate, {'isValid': true});
+ $form.trigger(event, [msg, deferreds, this]);
+ if (event.isValid) {
+ if (this.validate) {
+ this.validate(this, getValue($form, this), msg, deferreds);
+ }
+ if (this.enableAjaxValidation) {
+ needAjaxValidation = true;
+ }
+ }
+ }
+ });
+
+ // ajax validation
+ $.when.apply(this, deferreds).always(function() {
+ // Remove empty message arrays
+ for (var i in messages) {
+ if (0 === messages[i].length) {
+ delete messages[i];
+ }
+ }
+ if (needAjaxValidation && (!data.submitting || $.isEmptyObject(messages))) {
+ // Perform ajax validation when at least one input needs it.
+ // If the validation is triggered by form submission, ajax validation
+ // should be done only when all inputs pass client validation
+ var $button = data.submitObject,
+ extData = '&' + data.settings.ajaxParam + '=' + $form.prop('id');
+ if ($button && $button.length && $button.prop('name')) {
+ extData += '&' + $button.prop('name') + '=' + $button.prop('value');
+ }
+ $.ajax({
+ url: data.settings.validationUrl,
+ type: $form.prop('method'),
+ data: $form.serialize() + extData,
+ dataType: data.settings.ajaxDataType,
+ complete: function (jqXHR, textStatus) {
+ $form.trigger(events.ajaxComplete, [jqXHR, textStatus]);
+ },
+ beforeSend: function (jqXHR, settings) {
+ $form.trigger(events.ajaxBeforeSend, [jqXHR, settings]);
+ },
+ success: function (msgs) {
+ if (msgs !== null && typeof msgs === 'object') {
+ $.each(data.attributes, function () {
+ if (!this.enableAjaxValidation) {
+ delete msgs[this.id];
+ }
+ });
+ updateInputs($form, $.extend(messages, msgs));
+ } else {
+ updateInputs($form, messages);
+ }
+ },
+ error: function () {
+ data.submitting = false;
+ }
+ });
+ } else if (data.submitting) {
+ // delay callback so that the form can be submitted without problem
+ setTimeout(function () {
+ updateInputs($form, messages);
+ }, 200);
+ } else {
+ updateInputs($form, messages);
+ }
+ });
+ },
+
submitForm: function () {
var $form = $(this),
data = $form.data('yiiActiveForm');
- if (data.validated) {
- if (data.settings.beforeSubmit !== undefined) {
- if (data.settings.beforeSubmit($form) == false) {
- data.validated = false;
- data.submitting = false;
- return false;
- }
- }
- // continue submitting the form since validation passes
- return true;
- }
- if (data.settings.timer !== undefined) {
- clearTimeout(data.settings.timer);
- }
- data.submitting = true;
-
- if (data.settings.beforeValidateAll && !data.settings.beforeValidateAll($form, data)) {
- data.submitting = false;
+ if (data.validated) {
+ var event = $.Event(events.beforeSubmit, {'isValid': true});
+ $form.trigger(event, [$form]);
+ if (!event.isValid) {
+ data.validated = false;
+ data.submitting = false;
+ return false;
+ }
+ return true; // continue submitting the form since validation passes
+ } else {
+ if (data.settings.timer !== undefined) {
+ clearTimeout(data.settings.timer);
+ }
+ data.submitting = true;
+ methods.validate.call($form);
return false;
}
- validate($form, function (messages) {
- var errors = [];
- $.each(data.attributes, function () {
- if (updateInput($form, this, messages)) {
- errors.push(this.input);
- }
- });
-
- if (data.settings.afterValidateAll) {
- data.settings.afterValidateAll($form, data, messages);
- }
-
- updateSummary($form, messages);
- if (errors.length) {
- var top = $form.find(errors.join(',')).first().offset().top;
- var wtop = $(window).scrollTop();
- if (top < wtop || top > wtop + $(window).height) {
- $(window).scrollTop(top);
- }
- } else {
- data.validated = true;
- var $button = data.submitObject || $form.find(':submit:first');
- // TODO: if the submission is caused by "change" event, it will not work
- if ($button.length) {
- $button.click();
- } else {
- // no submit button in the form
- $form.submit();
- }
- return;
- }
- data.submitting = false;
- }, function () {
- data.submitting = false;
- });
- return false;
},
resetForm: function () {
@@ -275,31 +369,6 @@
}
};
- var watchAttributes = function ($form, attributes) {
- $.each(attributes, function (i, attribute) {
- var $input = findInput($form, attribute);
- if (attribute.validateOnChange) {
- $input.on('change.yiiActiveForm',function () {
- validateAttribute($form, attribute, false);
- });
- }
- if (attribute.validateOnBlur) {
- $input.on('blur.yiiActiveForm', function () {
- if (attribute.status == 0 || attribute.status == 1) {
- validateAttribute($form, attribute, !attribute.status);
- }
- });
- }
- if (attribute.validateOnType) {
- $input.on('keyup.yiiActiveForm', function () {
- if (attribute.value !== getValue($form, attribute)) {
- validateAttribute($form, attribute, false);
- }
- });
- }
- });
- };
-
var watchAttribute = function ($form, attribute) {
var $input = findInput($form, attribute);
if (attribute.validateOnChange) {
@@ -356,14 +425,7 @@
$form.find(this.container).addClass(data.settings.validatingCssClass);
}
});
- validate($form, function (messages) {
- var hasError = false;
- $.each(data.attributes, function () {
- if (this.status === 2 || this.status === 3) {
- hasError = updateInput($form, this, messages) || hasError;
- }
- });
- });
+ methods.validate.call($form);
}, data.settings.validationDelay);
};
@@ -379,88 +441,52 @@
};
return array;
};
-
+
/**
- * Performs validation.
- * @param $form jQuery the jquery representation of the form
- * @param successCallback function the function to be invoked if the validation completes
- * @param errorCallback function the function to be invoked if the ajax validation request fails
+ * Updates the error messages and the input containers for all applicable attributes
+ * @param $form the form jQuery object
+ * @param messages array the validation error messages
*/
- var validate = function ($form, successCallback, errorCallback) {
- var data = $form.data('yiiActiveForm'),
- needAjaxValidation = false,
- messages = {},
- deferreds = deferredArray();
+ var updateInputs = function ($form, messages) {
+ var data = $form.data('yiiActiveForm');
- $.each(data.attributes, function () {
- if (data.submitting || this.status === 2 || this.status === 3) {
- var msg = [];
- messages[this.id] = msg;
- if (!data.settings.beforeValidate || data.settings.beforeValidate($form, this, msg)) {
- if (this.validate) {
- this.validate(this, getValue($form, this), msg, deferreds);
- }
- if (this.enableAjaxValidation) {
- needAjaxValidation = true;
- }
+ if (data.submitting) {
+ var errorInputs = [];
+ $.each(data.attributes, function () {
+ if (updateInput($form, this, messages)) {
+ errorInputs.push(this.input);
}
- }
- });
+ });
- $.when.apply(this, deferreds).always(function() {
- //Remove empty message arrays
- for (var i in messages) {
- if (0 === messages[i].length) {
- delete messages[i];
+ $form.trigger(events.afterValidate, [messages]);
+
+ updateSummary($form, messages);
+
+ if (errorInputs.length) {
+ var top = $form.find(errorInputs.join(',')).first().offset().top;
+ var wtop = $(window).scrollTop();
+ if (top < wtop || top > wtop + $(window).height) {
+ $(window).scrollTop(top);
}
- }
- if (needAjaxValidation && (!data.submitting || $.isEmptyObject(messages))) {
- // Perform ajax validation when at least one input needs it.
- // If the validation is triggered by form submission, ajax validation
- // should be done only when all inputs pass client validation
- var $button = data.submitObject,
- extData = '&' + data.settings.ajaxParam + '=' + $form.prop('id');
- if ($button && $button.length && $button.prop('name')) {
- extData += '&' + $button.prop('name') + '=' + $button.prop('value');
- }
- $.ajax({
- url: data.settings.validationUrl,
- type: $form.prop('method'),
- data: $form.serialize() + extData,
- dataType: data.settings.ajaxDataType,
- complete: function (jqXHR, textStatus) {
- if (data.settings.ajaxComplete) {
- data.settings.ajaxComplete($form, jqXHR, textStatus);
- }
- },
- beforeSend: function (jqXHR, textStatus) {
- if (data.settings.ajaxBeforeSend) {
- data.settings.ajaxBeforeSend($form, jqXHR, textStatus);
- }
- },
- success: function (msgs) {
- if (msgs !== null && typeof msgs === 'object') {
- $.each(data.attributes, function () {
- if (!this.enableAjaxValidation) {
- delete msgs[this.id];
- }
- });
- successCallback($.extend({}, messages, msgs));
- } else {
- successCallback(messages);
- }
- },
- error: errorCallback
- });
- } else if (data.submitting) {
- // delay callback so that the form can be submitted without problem
- setTimeout(function () {
- successCallback(messages);
- }, 200);
+ data.submitting = false;
} else {
- successCallback(messages);
+ data.validated = true;
+ var $button = data.submitObject || $form.find(':submit:first');
+ // TODO: if the submission is caused by "change" event, it will not work
+ if ($button.length) {
+ $button.click();
+ } else {
+ // no submit button in the form
+ $form.submit();
+ }
}
- });
+ } else {
+ $.each(data.attributes, function () {
+ if (this.status === 2 || this.status === 3) {
+ updateInput($form, this, messages);
+ }
+ });
+ }
};
/**
@@ -475,12 +501,14 @@
$input = findInput($form, attribute),
hasError = false;
- if (data.settings.afterValidate) {
- data.settings.afterValidate($form, attribute, messages);
+ if (!$.isArray(messages[attribute.id])) {
+ messages[attribute.id] = [];
}
+ $form.trigger(events.afterValidate, [messages[attribute.id], attribute]);
+
attribute.status = 1;
if ($input.length) {
- hasError = messages && $.isArray(messages[attribute.id]) && messages[attribute.id].length;
+ hasError = messages[attribute.id].length > 0;
var $container = $form.find(attribute.container);
var $error = $container.find(attribute.error);
if (hasError) {
diff --git a/framework/widgets/ActiveForm.php b/framework/widgets/ActiveForm.php
index e9526655be..a4cbc3561c 100644
--- a/framework/widgets/ActiveForm.php
+++ b/framework/widgets/ActiveForm.php
@@ -15,7 +15,6 @@ use yii\helpers\ArrayHelper;
use yii\helpers\Url;
use yii\helpers\Html;
use yii\helpers\Json;
-use yii\web\JsExpression;
/**
* ActiveForm is a widget that builds an interactive HTML form for one or multiple data models.
@@ -145,79 +144,6 @@ class ActiveForm extends Widget
* @var string the type of data that you're expecting back from the server.
*/
public $ajaxDataType = 'json';
- /**
- * @var string|JsExpression a JS callback that will be called when the form is being submitted.
- * The signature of the callback should be:
- *
- * ~~~
- * function ($form) {
- * ...return false to cancel submission...
- * }
- * ~~~
- */
- public $beforeSubmit;
- /**
- * @var string|JsExpression a JS callback that is called before validating an attribute.
- * The signature of the callback should be:
- *
- * ~~~
- * function ($form, attribute, messages) {
- * ...return false to cancel the validation...
- * }
- * ~~~
- */
- public $beforeValidate;
- /**
- * @var string|JsExpression a JS callback that is called before any validation has run (Only called when the form is submitted).
- * The signature of the callback should be:
- *
- * ~~~
- * function ($form, data) {
- * ...return false to cancel the validation...
- * }
- * ~~~
- */
- public $beforeValidateAll;
- /**
- * @var string|JsExpression a JS callback that is called after validating an attribute.
- * The signature of the callback should be:
- *
- * ~~~
- * function ($form, attribute, messages) {
- * }
- * ~~~
- */
- public $afterValidate;
- /**
- * @var string|JsExpression a JS callback that is called after all validation has run (Only called when the form is submitted).
- * The signature of the callback should be:
- *
- * ~~~
- * function ($form, data, messages) {
- * }
- * ~~~
- */
- public $afterValidateAll;
- /**
- * @var string|JsExpression a JS pre-request callback function on AJAX-based validation.
- * The signature of the callback should be:
- *
- * ~~~
- * function ($form, jqXHR, textStatus) {
- * }
- * ~~~
- */
- public $ajaxBeforeSend;
- /**
- * @var string|JsExpression a JS callback to be called when the request finishes on AJAX-based validation.
- * The signature of the callback should be:
- *
- * ~~~
- * function ($form, jqXHR, textStatus) {
- * }
- * ~~~
- */
- public $ajaxComplete;
/**
* @var array the client validation options for individual attributes. Each element of the array
* represents the validation options for a particular attribute.
@@ -282,11 +208,6 @@ class ActiveForm extends Widget
if ($this->validationUrl !== null) {
$options['validationUrl'] = Url::to($this->validationUrl);
}
- foreach (['beforeSubmit', 'beforeValidate', 'beforeValidateAll', 'afterValidate', 'afterValidateAll', 'ajaxBeforeSend', 'ajaxComplete'] as $name) {
- if (($value = $this->$name) !== null) {
- $options[$name] = $value instanceof JsExpression ? $value : new JsExpression($value);
- }
- }
// only get the options that are different from the default ones (set in yii.activeForm.js)
return array_diff_assoc($options, [
diff --git a/tests/unit/framework/helpers/HtmlTest.php b/tests/unit/framework/helpers/HtmlTest.php
index aa0c37f17c..0614053f05 100644
--- a/tests/unit/framework/helpers/HtmlTest.php
+++ b/tests/unit/framework/helpers/HtmlTest.php
@@ -140,7 +140,7 @@ class HtmlTest extends TestCase
public function testButton()
{
- $this->assertEquals('', Html::button());
+ $this->assertEquals('', Html::button());
$this->assertEquals('', Html::button('content<>', ['name' => 'test', 'value' => 'value']));
$this->assertEquals('', Html::button('content<>', ['type' => 'submit', 'name' => 'test', 'value' => 'value', 'class' => "t"]));
}