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 259 260 261
                data = $form.data('yiiActiveForm'),
                needAjaxValidation = false,
                messages = {},
                deferreds = deferredArray();

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

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

            // 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
301
                    }
302
                }
Qiang Xue committed
303
                if (needAjaxValidation) {
304 305 306 307
                    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
308
                    }
309 310 311 312 313 314 315 316 317 318 319 320 321 322
                    $.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
323
                                    if (!this.enableAjaxValidation || this.cancelled) {
324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342
                                        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);
Qiang Xue committed
343 344
                }
            });
345 346 347 348 349 350 351
        },

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

            if (data.validated) {
352 353 354 355 356
                if (!data.submitting) {
                    // form is being submitted. Do nothing to avoid duplicated form submission
                    return false;
                }
                data.submitting = false;
Qiang Xue committed
357
                var event = $.Event(events.beforeSubmit);
358
                $form.trigger(event);
Qiang Xue committed
359
                if (event.result === false) {
360 361 362 363 364 365 366 367 368 369 370 371
                    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
372
        },
Qiang Xue committed
373

Qiang Xue committed
374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396
        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('');
                });
                $form.find(data.settings.summary).hide().find('ul').html('');
            }, 1);
        }
    };
Qiang Xue committed
397

398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414
    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
415
                    validateAttribute($form, attribute, false, attribute.validationDelay);
416 417 418 419 420 421 422 423 424
                }
            });
        }
    };

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

425
    var validateAttribute = function ($form, attribute, forceValidate, validationDelay) {
Qiang Xue committed
426
        var data = $form.data('yiiActiveForm');
Qiang Xue committed
427

Qiang Xue committed
428 429 430 431 432 433 434 435 436 437 438 439
        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
440

Qiang Xue committed
441 442 443 444 445 446 447 448 449 450 451 452 453
        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);
                }
            });
454
            methods.validate.call($form);
455
        }, validationDelay ? validationDelay : 200);
Qiang Xue committed
456
    };
Alex-Code committed
457 458 459 460 461 462 463 464 465
    
    /**
     * 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
466
            this.push(new $.Deferred(callback));
Alex-Code committed
467 468 469
        };
        return array;
    };
470

Qiang Xue committed
471
    /**
472 473 474
     * 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
Qiang Xue committed
475
     */
476 477
    var updateInputs = function ($form, messages) {
        var data = $form.data('yiiActiveForm');
Qiang Xue committed
478

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

487 488 489 490 491 492 493 494 495
            $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);
Alex-Code committed
496
                }
497
                data.submitting = false;
Alex-Code committed
498
            } else {
499 500 501 502 503 504 505 506 507
                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
508
            }
509 510
        } else {
            $.each(data.attributes, function () {
Qiang Xue committed
511
                if (!this.cancelled && (this.status === 2 || this.status === 3)) {
512 513 514 515
                    updateInput($form, this, messages);
                }
            });
        }
Qiang Xue committed
516
    };
Qiang Xue committed
517

Qiang Xue committed
518 519 520 521 522 523 524 525 526 527 528
    /**
     * 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
529

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

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

Qiang Xue committed
558 559 560 561 562 563 564 565
    /**
     * 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),
566
            $ul = $summary.find('ul').empty();
Qiang Xue committed
567

Qiang Xue committed
568 569
        if ($summary.length && messages) {
            $.each(data.attributes, function () {
570
                if ($.isArray(messages[this.id]) && messages[this.id].length) {
571 572 573 574 575 576 577
                    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
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 605 606
                }
            });
            $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
607

608
})(window.jQuery);