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"])); }