yii.activeForm.js 24.8 KB
Newer Older
Qiang Xue committed
1 2 3 4 5 6 7 8 9 10 11 12 13
/**
 * Yii form widget.
 *
 * This is the JavaScript widget used by the yii\widgets\ActiveForm widget.
 *
 * @link http://www.yiiframework.com/
 * @copyright Copyright (c) 2008 Yii Software LLC
 * @license http://www.yiiframework.com/license/
 * @author Qiang Xue <qiang.xue@gmail.com>
 * @since 2.0
 */
(function ($) {

Qiang Xue committed
14 15 16 17 18 19 20 21 22 23
    $.fn.yiiActiveForm = function (method) {
        if (methods[method]) {
            return methods[method].apply(this, Array.prototype.slice.call(arguments, 1));
        } else if (typeof method === 'object' || !method) {
            return methods.init.apply(this, arguments);
        } else {
            $.error('Method ' + method + ' does not exist on jQuery.yiiActiveForm');
            return false;
        }
    };
Qiang Xue committed
24

25 26
    var events = {
        /**
Qiang Xue committed
27
         * beforeValidate event is triggered before validating the whole form.
28
         * The signature of the event handler should be:
Qiang Xue committed
29
         *     function (event, messages, deferreds)
30
         * where
Qiang Xue committed
31
         *  - event: an Event object.
Qiang Xue committed
32 33
         *  - messages: an associative array with keys being attribute IDs and values being error message arrays
         *    for the corresponding attributes.
34
         *  - deferreds: an array of Deferred objects. You can use deferreds.add(callback) to add a new deferred validation.
Qiang Xue committed
35
         *
Qiang Xue committed
36 37
         * If the handler returns a boolean false, it will stop further form validation after this event. And as
         * a result, afterValidate event will not be triggered.
38 39 40
         */
        beforeValidate: 'beforeValidate',
        /**
Qiang Xue committed
41
         * afterValidate event is triggered after validating the whole form.
42
         * The signature of the event handler should be:
Qiang Xue committed
43
         *     function (event, messages)
44 45
         * where
         *  - event: an Event object.
Qiang Xue committed
46 47
         *  - messages: an associative array with keys being attribute IDs and values being error message arrays
         *    for the corresponding attributes.
48 49 50
         */
        afterValidate: 'afterValidate',
        /**
Qiang Xue committed
51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75
         * beforeValidateAttribute event is triggered before validating an attribute.
         * The signature of the event handler should be:
         *     function (event, attribute, messages, deferreds)
         * where
         *  - event: an Event object.
         *  - attribute: the attribute to be validated. Please refer to attributeDefaults for the structure of this parameter.
         *  - messages: an array to which you can add validation error messages for the specified attribute.
         *  - deferreds: an array of Deferred objects. You can use deferreds.add(callback) to add a new deferred validation.
         *
         * If the handler returns a boolean false, it will stop further validation of the specified attribute.
         * And as a result, afterValidateAttribute event will not be triggered.
         */
        beforeValidateAttribute: 'beforeValidateAttribute',
        /**
         * afterValidateAttribute event is triggered after validating the whole form and each attribute.
         * The signature of the event handler should be:
         *     function (event, attribute, messages)
         * where
         *  - event: an Event object.
         *  - attribute: the attribute being validated. Please refer to attributeDefaults for the structure of this parameter.
         *  - messages: an array to which you can add additional validation error messages for the specified attribute.
         */
        afterValidateAttribute: 'afterValidateAttribute',
        /**
         * beforeSubmit event is triggered before submitting the form after all validations have passed.
76 77 78
         * The signature of the event handler should be:
         *     function (event)
         * where event is an Event object.
Qiang Xue committed
79
         *
Qiang Xue committed
80
         * If the handler returns a boolean false, it will stop form submission.
81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104
         */
        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'
    };

105
    // NOTE: If you change any of these defaults, make sure you update yii\widgets\ActiveForm::getClientOptions() as well
Qiang Xue committed
106
    var defaults = {
107 108
        // whether to encode the error summary
        encodeErrorSummary: true,
Qiang Xue committed
109
        // the jQuery selector for the error summary
110
        errorSummary: '.error-summary',
Qiang Xue committed
111 112 113
        // whether to perform validation before submitting the form.
        validateOnSubmit: true,
        // the container CSS class representing the corresponding attribute has validation error
114
        errorCssClass: 'has-error',
Qiang Xue committed
115
        // the container CSS class representing the corresponding attribute passes validation
116
        successCssClass: 'has-success',
Qiang Xue committed
117 118
        // the container CSS class representing the corresponding attribute is being validated
        validatingCssClass: 'validating',
119 120 121 122
        // the GET parameter name indicating an AJAX-based validation
        ajaxParam: 'ajax',
        // the type of data that you're expecting back from the server
        ajaxDataType: 'json',
Qiang Xue committed
123
        // the URL for performing AJAX-based validation. If not set, it will use the the form's action
124
        validationUrl: undefined
Qiang Xue committed
125
    };
Qiang Xue committed
126

127
    // NOTE: If you change any of these defaults, make sure you update yii\widgets\ActiveField::getClientOptions() as well
Qiang Xue committed
128
    var attributeDefaults = {
129 130
        // a unique ID identifying an attribute (e.g. "loginform-username") in a form
        id: undefined,
Qiang Xue committed
131 132 133 134
        // attribute name or expression (e.g. "[0]content" for tabular input)
        name: undefined,
        // the jQuery selector of the container of the input field
        container: undefined,
135
        // the jQuery selector of the input field under the context of the container
Qiang Xue committed
136
        input: undefined,
137 138
        // the jQuery selector of the error tag under the context of the container
        error: '.help-block',
139 140
        // whether to encode the error
        encodeError: true,
Qiang Xue committed
141
        // whether to perform validation when a change is detected on the input
142
        validateOnChange: true,
143
        // whether to perform validation when the input loses focus
144
        validateOnBlur: true,
Qiang Xue committed
145 146 147
        // whether to perform validation when the user is typing.
        validateOnType: false,
        // number of milliseconds that the validation should be delayed when a user is typing in the input field.
148
        validationDelay: 500,
Qiang Xue committed
149 150 151 152 153 154
        // whether to enable AJAX-based validation.
        enableAjaxValidation: false,
        // function (attribute, value, messages), the client-side validation function.
        validate: undefined,
        // status of the input field, 0: empty, not entered before, 1: validated, 2: pending validation, 3: validating
        status: 0,
Qiang Xue committed
155 156
        // whether the validation is cancelled by beforeValidateAttribute event handler
        cancelled: false,
Qiang Xue committed
157 158 159
        // the value of the input
        value: undefined
    };
Qiang Xue committed
160

Qiang Xue committed
161 162 163 164 165 166 167
    var methods = {
        init: function (attributes, options) {
            return this.each(function () {
                var $form = $(this);
                if ($form.data('yiiActiveForm')) {
                    return;
                }
Qiang Xue committed
168

Qiang Xue committed
169 170 171 172
                var settings = $.extend({}, defaults, options || {});
                if (settings.validationUrl === undefined) {
                    settings.validationUrl = $form.prop('action');
                }
173

Qiang Xue committed
174 175
                $.each(attributes, function (i) {
                    attributes[i] = $.extend({value: getValue($form, this)}, attributeDefaults, this);
176
                    watchAttribute($form, attributes[i]);
Qiang Xue committed
177
                });
178

Qiang Xue committed
179 180 181 182 183 184
                $form.data('yiiActiveForm', {
                    settings: settings,
                    attributes: attributes,
                    submitting: false,
                    validated: false
                });
Qiang Xue committed
185

Qiang Xue committed
186 187 188 189 190
                /**
                 * Clean up error status when the form is reset.
                 * Note that $form.on('reset', ...) does work because the "reset" event does not bubble on IE.
                 */
                $form.bind('reset.yiiActiveForm', methods.resetForm);
Qiang Xue committed
191

Qiang Xue committed
192 193 194 195
                if (settings.validateOnSubmit) {
                    $form.on('mouseup.yiiActiveForm keyup.yiiActiveForm', ':submit', function () {
                        $form.data('yiiActiveForm').submitObject = $(this);
                    });
196
                    $form.on('submit.yiiActiveForm', methods.submitForm);
Qiang Xue committed
197 198 199
                }
            });
        },
Qiang Xue committed
200

201 202 203 204 205 206 207 208 209 210 211 212 213 214
        // add a new attribute to the form dynamically.
        // please refer to attributeDefaults for the structure of attribute
        add: function (attribute) {
            var $form = $(this);
            attribute = $.extend({value: getValue($form, attribute)}, attributeDefaults, attribute);
            $form.data('yiiActiveForm').attributes.push(attribute);
            watchAttribute($form, attribute);
        },

        // remove the attribute with the specified ID from the form
        remove: function (id) {
            var $form = $(this),
                attributes = $form.data('yiiActiveForm').attributes,
                index = -1,
215
                attribute = undefined;
216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231
            $.each(attributes, function (i) {
                if (attributes[i]['id'] == id) {
                    index = i;
                    attribute = attributes[i];
                    return false;
                }
            });
            if (index >= 0) {
                attributes.splice(index, 1);
                unwatchAttribute($form, attribute);
            }
            return attribute;
        },

        // find an attribute config based on the specified attribute ID
        find: function (id) {
232 233
            var attributes = $(this).data('yiiActiveForm').attributes,
                result = undefined;
234 235 236 237 238 239 240 241 242
            $.each(attributes, function (i) {
                if (attributes[i]['id'] == id) {
                    result = attributes[i];
                    return false;
                }
            });
            return result;
        },

Qiang Xue committed
243 244
        destroy: function () {
            return this.each(function () {
Qiang Xue committed
245
                $(this).unbind('.yiiActiveForm');
Qiang Xue committed
246 247 248
                $(this).removeData('yiiActiveForm');
            });
        },
Qiang Xue committed
249

Qiang Xue committed
250 251 252
        data: function () {
            return this.data('yiiActiveForm');
        },
Qiang Xue committed
253

254
        validate: function () {
Qiang Xue committed
255
            var $form = $(this),
256 257 258
                data = $form.data('yiiActiveForm'),
                needAjaxValidation = false,
                messages = {},
259 260
                deferreds = deferredArray(),
                submitting = data.submitting;
261

262
            if (submitting) {
Qiang Xue committed
263
                var event = $.Event(events.beforeValidate);
264
                $form.trigger(event, [messages, deferreds]);
Qiang Xue committed
265
                if (event.result === false) {
266 267
                    data.submitting = false;
                    return;
Qiang Xue committed
268 269
                }
            }
Qiang Xue committed
270

271 272
            // client-side validation
            $.each(data.attributes, function () {
Qiang Xue committed
273
                this.cancelled = false;
274 275 276 277 278 279 280
                // 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;
                    }
Qiang Xue committed
281 282
                    var event = $.Event(events.beforeValidateAttribute);
                    $form.trigger(event, [this, msg, deferreds]);
Qiang Xue committed
283
                    if (event.result !== false) {
284 285 286 287 288 289
                        if (this.validate) {
                            this.validate(this, getValue($form, this), msg, deferreds);
                        }
                        if (this.enableAjaxValidation) {
                            needAjaxValidation = true;
                        }
Qiang Xue committed
290 291
                    } else {
                        this.cancelled = true;
Qiang Xue committed
292
                    }
293
                }
294 295 296 297 298 299 300 301
            });

            // 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];
Qiang Xue committed
302
                    }
303
                }
Qiang Xue committed
304
                if (needAjaxValidation) {
305 306 307 308
                    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');
Qiang Xue committed
309
                    }
310 311 312 313 314 315 316 317 318 319 320 321 322 323
                    $.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 () {
Qiang Xue committed
324
                                    if (!this.enableAjaxValidation || this.cancelled) {
325 326 327
                                        delete msgs[this.id];
                                    }
                                });
328
                                updateInputs($form, $.extend(messages, msgs), submitting);
329
                            } else {
330
                                updateInputs($form, messages, submitting);
331 332 333 334 335 336 337 338 339
                            }
                        },
                        error: function () {
                            data.submitting = false;
                        }
                    });
                } else if (data.submitting) {
                    // delay callback so that the form can be submitted without problem
                    setTimeout(function () {
340
                        updateInputs($form, messages, submitting);
341 342
                    }, 200);
                } else {
343
                    updateInputs($form, messages, submitting);
Qiang Xue committed
344 345
                }
            });
346 347 348 349 350 351 352
        },

        submitForm: function () {
            var $form = $(this),
                data = $form.data('yiiActiveForm');

            if (data.validated) {
353
                data.submitting = false;
Qiang Xue committed
354
                var event = $.Event(events.beforeSubmit);
355
                $form.trigger(event);
Qiang Xue committed
356
                if (event.result === false) {
357 358 359 360 361 362 363 364 365 366 367 368
                    data.validated = 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;
            }
Qiang Xue committed
369
        },
Qiang Xue committed
370

Qiang Xue committed
371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389
        resetForm: function () {
            var $form = $(this);
            var data = $form.data('yiiActiveForm');
            // Because we bind directly to a form reset event instead of a reset button (that may not exist),
            // when this function is executed form input values have not been reset yet.
            // Therefore we do the actual reset work through setTimeout.
            setTimeout(function () {
                $.each(data.attributes, function () {
                    // Without setTimeout() we would get the input values that are not reset yet.
                    this.value = getValue($form, this);
                    this.status = 0;
                    var $container = $form.find(this.container);
                    $container.removeClass(
                        data.settings.validatingCssClass + ' ' +
                            data.settings.errorCssClass + ' ' +
                            data.settings.successCssClass
                    );
                    $container.find(this.error).html('');
                });
390
                $form.find(data.settings.errorSummary).hide().find('ul').html('');
Qiang Xue committed
391 392 393
            }, 1);
        }
    };
Qiang Xue committed
394

395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411
    var watchAttribute = function ($form, 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)) {
Qiang Xue committed
412
                    validateAttribute($form, attribute, false, attribute.validationDelay);
413 414 415 416 417 418 419 420 421
                }
            });
        }
    };

    var unwatchAttribute = function ($form, attribute) {
        findInput($form, attribute).off('.yiiActiveForm');
    };

422
    var validateAttribute = function ($form, attribute, forceValidate, validationDelay) {
Qiang Xue committed
423
        var data = $form.data('yiiActiveForm');
Qiang Xue committed
424

Qiang Xue committed
425 426 427 428 429 430 431 432 433 434 435 436
        if (forceValidate) {
            attribute.status = 2;
        }
        $.each(data.attributes, function () {
            if (this.value !== getValue($form, this)) {
                this.status = 2;
                forceValidate = true;
            }
        });
        if (!forceValidate) {
            return;
        }
Qiang Xue committed
437

Qiang Xue committed
438 439 440 441 442 443 444 445 446 447 448 449 450
        if (data.settings.timer !== undefined) {
            clearTimeout(data.settings.timer);
        }
        data.settings.timer = setTimeout(function () {
            if (data.submitting || $form.is(':hidden')) {
                return;
            }
            $.each(data.attributes, function () {
                if (this.status === 2) {
                    this.status = 3;
                    $form.find(this.container).addClass(data.settings.validatingCssClass);
                }
            });
451
            methods.validate.call($form);
452
        }, validationDelay ? validationDelay : 200);
Qiang Xue committed
453
    };
Alex-Code committed
454 455 456 457 458 459 460 461 462
    
    /**
     * Returns an array prototype with a shortcut method for adding a new deferred.
     * The context of the callback will be the deferred object so it can be resolved like ```this.resolve()```
     * @returns Array
     */
    var deferredArray = function () {
        var array = [];
        array.add = function(callback) {
Alex-Code committed
463
            this.push(new $.Deferred(callback));
Alex-Code committed
464 465 466
        };
        return array;
    };
467

Qiang Xue committed
468
    /**
469 470 471
     * 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
472
     * @param submitting whether this method is called after validation triggered by form submission
Qiang Xue committed
473
     */
474
    var updateInputs = function ($form, messages, submitting) {
475
        var data = $form.data('yiiActiveForm');
Qiang Xue committed
476

477
        if (submitting) {
478 479
            var errorInputs = [];
            $.each(data.attributes, function () {
Qiang Xue committed
480
                if (!this.cancelled && updateInput($form, this, messages)) {
481
                    errorInputs.push(this.input);
Qiang Xue committed
482
                }
483
            });
Qiang Xue committed
484

485 486 487 488 489
            $form.trigger(events.afterValidate, [messages]);

            updateSummary($form, messages);

            if (errorInputs.length) {
490
                var top = $form.find(errorInputs.join(',')).first().closest(':visible').offset().top;
491 492 493
                var wtop = $(window).scrollTop();
                if (top < wtop || top > wtop + $(window).height) {
                    $(window).scrollTop(top);
Alex-Code committed
494
                }
495
                data.submitting = false;
Alex-Code committed
496
            } else {
497 498 499 500 501 502 503 504 505
                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();
                }
Alex-Code committed
506
            }
507 508
        } else {
            $.each(data.attributes, function () {
Qiang Xue committed
509
                if (!this.cancelled && (this.status === 2 || this.status === 3)) {
510 511 512 513
                    updateInput($form, this, messages);
                }
            });
        }
Qiang Xue committed
514
    };
Qiang Xue committed
515

Qiang Xue committed
516 517 518 519 520 521 522 523 524 525 526
    /**
     * Updates the error message and the input container for a particular attribute.
     * @param $form the form jQuery object
     * @param attribute object the configuration for a particular attribute.
     * @param messages array the validation error messages
     * @return boolean whether there is a validation error for the specified attribute
     */
    var updateInput = function ($form, attribute, messages) {
        var data = $form.data('yiiActiveForm'),
            $input = findInput($form, attribute),
            hasError = false;
Qiang Xue committed
527

528 529
        if (!$.isArray(messages[attribute.id])) {
            messages[attribute.id] = [];
Qiang Xue committed
530
        }
Qiang Xue committed
531
        $form.trigger(events.afterValidateAttribute, [attribute, messages[attribute.id]]);
532

Qiang Xue committed
533 534
        attribute.status = 1;
        if ($input.length) {
535
            hasError = messages[attribute.id].length > 0;
Qiang Xue committed
536 537 538
            var $container = $form.find(attribute.container);
            var $error = $container.find(attribute.error);
            if (hasError) {
539 540 541 542 543
                if (attribute.encodeError) {
                    $error.text(messages[attribute.id][0]);
                } else {
                    $error.html(messages[attribute.id][0]);
                }
Qiang Xue committed
544 545 546
                $container.removeClass(data.settings.validatingCssClass + ' ' + data.settings.successCssClass)
                    .addClass(data.settings.errorCssClass);
            } else {
547
                $error.empty();
Qiang Xue committed
548 549 550 551 552 553 554
                $container.removeClass(data.settings.validatingCssClass + ' ' + data.settings.errorCssClass + ' ')
                    .addClass(data.settings.successCssClass);
            }
            attribute.value = getValue($form, attribute);
        }
        return hasError;
    };
Qiang Xue committed
555

Qiang Xue committed
556 557 558 559 560 561 562 563
    /**
     * Updates the error summary.
     * @param $form the form jQuery object
     * @param messages array the validation error messages
     */
    var updateSummary = function ($form, messages) {
        var data = $form.data('yiiActiveForm'),
            $summary = $form.find(data.settings.errorSummary),
564
            $ul = $summary.find('ul').empty();
Qiang Xue committed
565

Qiang Xue committed
566 567
        if ($summary.length && messages) {
            $.each(data.attributes, function () {
568
                if ($.isArray(messages[this.id]) && messages[this.id].length) {
569 570 571 572 573 574 575
                    var error = $('<li/>');
                    if (data.settings.encodeErrorSummary) {
                        error.text(messages[this.id][0]);
                    } else {
                        error.html(messages[this.id][0]);
                    }
                    $ul.append(error);
Qiang Xue committed
576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604
                }
            });
            $summary.toggle($ul.find('li').length > 0);
        }
    };

    var getValue = function ($form, attribute) {
        var $input = findInput($form, attribute);
        var type = $input.prop('type');
        if (type === 'checkbox' || type === 'radio') {
            var $realInput = $input.filter(':checked');
            if (!$realInput.length) {
                $realInput = $form.find('input[type=hidden][name="' + $input.prop('name') + '"]');
            }
            return $realInput.val();
        } else {
            return $input.val();
        }
    };

    var findInput = function ($form, attribute) {
        var $input = $form.find(attribute.input);
        if ($input.length && $input[0].tagName.toLowerCase() === 'div') {
            // checkbox list or radio list
            return $input.find('input');
        } else {
            return $input;
        }
    };
Qiang Xue committed
605

606
})(window.jQuery);