Merge "Provide command to adjust phpunit.xml for code coverage"
[lhc/web/wiklou.git] / resources / src / mediawiki.htmlform.checker.js
1 ( function () {
2
3 // FIXME: mw.htmlform.Element also sets this to empty object
4 mw.htmlform = {};
5
6 function debounce( delay, callback ) {
7 var timeout;
8 return function () {
9 clearTimeout( timeout );
10 timeout = setTimeout( Function.prototype.apply.bind( callback, this, arguments ), delay );
11 };
12 }
13
14 /**
15 * @class mw.htmlform.Checker
16 */
17
18 /**
19 * A helper class to add validation to non-OOUI HtmlForm fields.
20 *
21 * @constructor
22 * @param {jQuery} $element Form field generated by HTMLForm
23 * @param {Function} validator Validation callback
24 * @param {string} validator.value Value of the form field to be validated
25 * @param {jQuery.Promise} validator.return The promise should be resolved
26 * with an object with two properties: Boolean 'valid' to indicate success
27 * or failure of validation, and an array 'messages' to be passed to
28 * setErrors() on failure.
29 */
30 mw.htmlform.Checker = function ( $element, validator ) {
31 this.validator = validator;
32 this.$element = $element;
33
34 this.$errorBox = $element.next( '.error' );
35 if ( !this.$errorBox.length ) {
36 this.$errorBox = $( '<span>' );
37 this.$errorBox.hide();
38 $element.after( this.$errorBox );
39 }
40
41 this.currentValue = this.$element.val();
42 };
43
44 /**
45 * Attach validation events to the form element
46 *
47 * @param {jQuery} [$extraElements] Additional elements to listen for change
48 * events on.
49 * @return {mw.htmlform.Checker}
50 * @chainable
51 */
52 mw.htmlform.Checker.prototype.attach = function ( $extraElements ) {
53 var $e,
54 // We need to hook to all of these events to be sure we are
55 // notified of all changes to the value of an <input type=text>
56 // field.
57 events = 'keyup keydown change mouseup cut paste focus blur';
58
59 $e = this.$element;
60 if ( $extraElements ) {
61 $e = $e.add( $extraElements );
62 }
63 $e.on( events, debounce( 1000, this.validate.bind( this ) ) );
64
65 return this;
66 };
67
68 /**
69 * Validate the form element
70 * @return {jQuery.Promise}
71 */
72 mw.htmlform.Checker.prototype.validate = function () {
73 var currentRequestInternal,
74 that = this,
75 value = this.$element.val();
76
77 // Abort any pending requests.
78 if ( this.currentRequest && this.currentRequest.abort ) {
79 this.currentRequest.abort();
80 }
81
82 if ( value === '' ) {
83 this.currentValue = value;
84 this.setErrors( true, [] );
85 return;
86 }
87
88 this.currentRequest = currentRequestInternal = this.validator( value )
89 .done( function ( info ) {
90 var forceReplacement = value !== that.currentValue;
91
92 // Another request was fired in the meantime, the result we got here is no longer current.
93 // This shouldn't happen as we abort pending requests, but you never know.
94 if ( that.currentRequest !== currentRequestInternal ) {
95 return;
96 }
97 // If we're here, then the current request has finished, avoid calling .abort() needlessly.
98 that.currentRequest = undefined;
99
100 that.currentValue = value;
101
102 that.setErrors( info.valid, info.messages, forceReplacement );
103 } ).fail( function () {
104 that.currentValue = null;
105 that.setErrors( true, [] );
106 } );
107
108 return currentRequestInternal;
109 };
110
111 /**
112 * Display errors associated with the form element
113 * @param {boolean} valid Whether the input is still valid regardless of the messages
114 * @param {Array} errors Error messages. Each error message will be appended to a
115 * `<span>` or `<li>`, as with jQuery.append().
116 * @param {boolean} [forceReplacement] Set true to force a visual replacement even
117 * if the errors are the same. Ignored if errors are empty.
118 * @return {mw.htmlform.Checker}
119 * @chainable
120 */
121 mw.htmlform.Checker.prototype.setErrors = function ( valid, errors, forceReplacement ) {
122 var $oldErrorBox, tagName, showFunc, text, replace,
123 $errorBox = this.$errorBox;
124
125 if ( errors.length === 0 ) {
126 // FIXME: Use CSS transition
127 // eslint-disable-next-line no-jquery/no-slide
128 $errorBox.slideUp( function () {
129 $errorBox
130 .removeAttr( 'class' )
131 .empty();
132 } );
133 } else {
134 // Match behavior of HTMLFormField::formatErrors(), <span> or <ul>
135 // depending on the count.
136 tagName = errors.length === 1 ? 'span' : 'ul';
137
138 // We have to animate the replacement if we're changing the tag. We
139 // also want to if told to by the caller (i.e. to make it visually
140 // obvious that the changed field value gives the same error) or if
141 // the error text changes (because it makes more sense than
142 // changing the text with no animation).
143 replace = (
144 forceReplacement || $errorBox.length > 1 ||
145 $errorBox[ 0 ].tagName.toLowerCase() !== tagName
146 );
147 if ( !replace ) {
148 text = $( '<' + tagName + '>' )
149 .append( errors.map( function ( e ) {
150 return errors.length === 1 ? e : $( '<li>' ).append( e );
151 } ) );
152 if ( text.text() !== $errorBox.text() ) {
153 replace = true;
154 }
155 }
156
157 $oldErrorBox = $errorBox;
158 if ( replace ) {
159 this.$errorBox = $errorBox = $( '<' + tagName + '>' );
160 $errorBox.hide();
161 $oldErrorBox.after( this.$errorBox );
162 }
163
164 showFunc = function () {
165 if ( $oldErrorBox !== $errorBox ) {
166 $oldErrorBox
167 .removeAttr( 'class' )
168 .detach();
169 }
170 // FIXME: Use CSS transition
171 // eslint-disable-next-line no-jquery/no-slide
172 $errorBox
173 .attr( 'class', valid ? 'warning' : 'error' )
174 .empty()
175 .append( errors.map( function ( e ) {
176 return errors.length === 1 ? e : $( '<li>' ).append( e );
177 } ) )
178 .slideDown();
179 };
180 if (
181 $oldErrorBox !== $errorBox &&
182 // eslint-disable-next-line no-jquery/no-class-state
183 ( $oldErrorBox.hasClass( 'error' ) || $oldErrorBox.hasClass( 'warning' ) )
184 ) {
185 // eslint-disable-next-line no-jquery/no-slide
186 $oldErrorBox.slideUp( showFunc );
187 } else {
188 showFunc();
189 }
190 }
191
192 return this;
193 };
194
195 }() );