From: Brad Jorsch Date: Thu, 1 Dec 2016 23:04:21 +0000 (-0500) Subject: Update account creation form validation X-Git-Tag: 1.31.0-rc.0~3777^2 X-Git-Url: https://git.heureux-cyclage.org/?p=lhc%2Fweb%2Fwiklou.git;a=commitdiff_plain;h=4d59edf138f697aa200fabc4dcb2310f1c1af4f4 Update account creation form validation Use the cancreateerror returned from list=users&usprop=cancreate for username validation. Use the new action=validatepassword to validate entered passwords. This also injects the resulting errors in the style of HTMLForm's field validation rather than at the top of the form. Change-Id: Ie8c1270eb605367556fe36b0b2080eb3f957dc54 --- diff --git a/includes/htmlform/HTMLFormField.php b/includes/htmlform/HTMLFormField.php index 3a3146bc22..83a80233f9 100644 --- a/includes/htmlform/HTMLFormField.php +++ b/includes/htmlform/HTMLFormField.php @@ -1145,6 +1145,9 @@ abstract class HTMLFormField { * @since 1.18 */ protected static function formatErrors( $errors ) { + // Note: If you change the logic in this method, change + // htmlform.Checker.js to match. + if ( is_array( $errors ) && count( $errors ) === 1 ) { $errors = array_shift( $errors ); } diff --git a/maintenance/jsduck/categories.json b/maintenance/jsduck/categories.json index 9fe5009acd..2c8c8b0f2f 100644 --- a/maintenance/jsduck/categories.json +++ b/maintenance/jsduck/categories.json @@ -33,7 +33,8 @@ "mw.plugin.*", "mw.cookie", "mw.experiments", - "mw.viewport" + "mw.viewport", + "mw.htmlform.*" ] }, { diff --git a/resources/Resources.php b/resources/Resources.php index 6d08c44e55..814a3af36e 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -1092,6 +1092,12 @@ return [ ], 'targets' => [ 'desktop', 'mobile' ], ], + 'mediawiki.htmlform.checker' => [ + 'scripts' => [ + 'resources/src/mediawiki/htmlform/htmlform.Checker.js', + ], + 'targets' => [ 'desktop', 'mobile' ], + ], 'mediawiki.htmlform.ooui' => [ 'scripts' => [ 'resources/src/mediawiki/htmlform/htmlform.Element.js', @@ -2070,7 +2076,6 @@ return [ 'mediawiki.special.userlogin.signup.js' => [ 'scripts' => 'resources/src/mediawiki.special/mediawiki.special.userlogin.signup.js', 'messages' => [ - 'createacct-error', 'createacct-emailrequired', 'noname', 'userexists', @@ -2079,6 +2084,7 @@ return [ 'mediawiki.api', 'mediawiki.jqueryMsg', 'jquery.throttle-debounce', + 'mediawiki.htmlform.checker', ], ], 'mediawiki.special.unwatchedPages' => [ diff --git a/resources/src/mediawiki.special/mediawiki.special.userlogin.signup.js b/resources/src/mediawiki.special/mediawiki.special.userlogin.signup.js index 24f54d0ada..10e19e722f 100644 --- a/resources/src/mediawiki.special/mediawiki.special.userlogin.signup.js +++ b/resources/src/mediawiki.special/mediawiki.special.userlogin.signup.js @@ -31,29 +31,14 @@ } ); // Check if the username is invalid or already taken - $( function () { - var - // We need to hook to all of these events to be sure we are notified of all changes to the - // value of an field. - events = 'keyup keydown change mouseup cut paste focus blur', - $input = $( '#wpName2' ), - $statusContainer = $( '#mw-createacct-status-area' ), + mw.hook( 'htmlform.enhance' ).add( function ( $root ) { + var $usernameInput = $root.find( '#wpName2' ), + $passwordInput = $root.find( '#wpPassword2' ), + $emailInput = $root.find( '#wpEmail' ), + $realNameInput = $root.find( '#wpRealName' ), api = new mw.Api(), - currentRequest; - - // Hide any present status messages. - function clearStatus() { - $statusContainer.slideUp( function () { - $statusContainer - .removeAttr( 'class' ) - .empty(); - } ); - } + usernameChecker, passwordChecker; - // Returns a promise receiving a { state:, username: } object, where: - // * 'state' is one of 'invalid', 'taken', 'ok' - // * 'username' is the validated username if 'state' is 'ok', null otherwise (if it's not - // possible to register such an account) function checkUsername( username ) { // We could just use .then() if we didn't have to pass on .abort()… var d, apiPromise; @@ -62,20 +47,29 @@ apiPromise = api.get( { action: 'query', list: 'users', - ususers: username // '|' in usernames is handled below + ususers: username, + usprop: 'cancreate', + formatversion: 2, + errorformat: 'html', + errorsuselocal: true, + uselang: mw.config.get( 'wgUserLanguage' ) } ) .done( function ( resp ) { var userinfo = resp.query.users[ 0 ]; - if ( resp.query.users.length !== 1 ) { - // Happens if the user types '|' into the field - d.resolve( { state: 'invalid', username: null } ); - } else if ( userinfo.invalid !== undefined ) { - d.resolve( { state: 'invalid', username: null } ); + if ( resp.query.users.length !== 1 || userinfo.invalid ) { + d.resolve( { valid: false, messages: [ mw.message( 'noname' ).parseDom() ] } ); } else if ( userinfo.userid !== undefined ) { - d.resolve( { state: 'taken', username: null } ); + d.resolve( { valid: false, messages: [ mw.message( 'userexists' ).parseDom() ] } ); + } else if ( !userinfo.cancreate ) { + d.resolve( { + valid: false, + messages: userinfo.cancreateerror ? userinfo.cancreateerror.map( function ( m ) { + return m.html; + } ) : [] + } ); } else { - d.resolve( { state: 'ok', username: username } ); + d.resolve( { valid: true, messages: [] } ); } } ) .fail( d.reject ); @@ -83,58 +77,46 @@ return d.promise( { abort: apiPromise.abort } ); } - function updateUsernameStatus() { - var - username = $.trim( $input.val() ), - currentRequestInternal; - - // Abort any pending requests. - if ( currentRequest ) { - currentRequest.abort(); - } + function checkPassword() { + // We could just use .then() if we didn't have to pass on .abort()… + var apiPromise, + d = $.Deferred(); - if ( username === '' ) { - clearStatus(); - return; + if ( $.trim( $usernameInput.val() ) === '' ) { + d.resolve( { valid: true, messages: [] } ); + return d.promise(); } - currentRequest = currentRequestInternal = checkUsername( username ).done( function ( info ) { - var message; - - // Another request was fired in the meantime, the result we got here is no longer current. - // This shouldn't happen as we abort pending requests, but you never know. - if ( currentRequest !== currentRequestInternal ) { - return; - } - // If we're here, then the current request has finished, avoid calling .abort() needlessly. - currentRequest = undefined; - - if ( info.state === 'ok' ) { - clearStatus(); - } else { - if ( info.state === 'invalid' ) { - message = mw.message( 'noname' ).text(); - } else if ( info.state === 'taken' ) { - message = mw.message( 'userexists' ).text(); - } + apiPromise = api.post( { + action: 'validatepassword', + user: $usernameInput.val(), + password: $passwordInput.val(), + email: $emailInput.val() || '', + realname: $realNameInput.val() || '', + formatversion: 2, + errorformat: 'html', + errorsuselocal: true, + uselang: mw.config.get( 'wgUserLanguage' ) + } ) + .done( function ( resp ) { + var pwinfo = resp.validatepassword || {}; + + d.resolve( { + valid: pwinfo.validity === 'Good', + messages: pwinfo.validitymessages ? pwinfo.validitymessages.map( function ( m ) { + return m.html; + } ) : [] + } ); + } ) + .fail( d.reject ); - $statusContainer - .attr( 'class', 'errorbox' ) - .empty() - .append( - // Ugh… - // TODO Change the HTML structure in includes/templates/Usercreate.php - $( '' ).text( mw.message( 'createacct-error' ).text() ), - $( '
' ), - document.createTextNode( message ) - ) - .slideDown(); - } - } ).fail( function () { - clearStatus(); - } ); + return d.promise( { abort: apiPromise.abort } ); } - $input.on( events, $.debounce( 1000, updateUsernameStatus ) ); + usernameChecker = new mw.htmlform.Checker( $usernameInput, checkUsername ); + usernameChecker.attach(); + + passwordChecker = new mw.htmlform.Checker( $passwordInput, checkPassword ); + passwordChecker.attach( $usernameInput.add( $emailInput ).add( $realNameInput ) ); } ); }( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/htmlform/htmlform.Checker.js b/resources/src/mediawiki/htmlform/htmlform.Checker.js new file mode 100644 index 0000000000..3f53b63600 --- /dev/null +++ b/resources/src/mediawiki/htmlform/htmlform.Checker.js @@ -0,0 +1,180 @@ +( function ( mw, $ ) { + + mw.htmlform = {}; + + /** + * @class mw.htmlform.Checker + */ + + /** + * A helper class to add validation to non-OOUI HtmlForm fields. + * + * @constructor + * @param {jQuery} $element Form field generated by HTMLForm + * @param {Function} validator Validation callback + * @param {string} validator.value Value of the form field to be validated + * @param {jQuery.Promise} validator.return The promise should be resolved + * with an object with two properties: Boolean 'valid' to indicate success + * or failure of validation, and an array 'messages' to be passed to + * setErrors() on failure. + */ + mw.htmlform.Checker = function ( $element, validator ) { + this.validator = validator; + this.$element = $element; + + this.$errorBox = $element.next( '.error' ); + if ( !this.$errorBox.length ) { + this.$errorBox = $( '' ); + this.$errorBox.hide(); + $element.after( this.$errorBox ); + } + + this.currentValue = this.$element.val(); + }; + + /** + * Attach validation events to the form element + * + * @param {jQuery} [$extraElements] Additional elements to listen for change + * events on. + * @return {mw.htmlform.Checker} + * @chainable + */ + mw.htmlform.Checker.prototype.attach = function ( $extraElements ) { + var $e, + // We need to hook to all of these events to be sure we are + // notified of all changes to the value of an + // field. + events = 'keyup keydown change mouseup cut paste focus blur'; + + $e = this.$element; + if ( $extraElements ) { + $e = $e.add( $extraElements ); + } + $e.on( events, $.debounce( 1000, this.validate.bind( this ) ) ); + + return this; + }; + + /** + * Validate the form element + * @return {jQuery.Promise} + */ + mw.htmlform.Checker.prototype.validate = function () { + var currentRequestInternal, + that = this, + value = this.$element.val(); + + // Abort any pending requests. + if ( this.currentRequest && this.currentRequest.abort ) { + this.currentRequest.abort(); + } + + if ( value === '' ) { + this.currentValue = value; + this.setErrors( [] ); + return; + } + + this.currentRequest = currentRequestInternal = this.validator( value ) + .done( function ( info ) { + var forceReplacement = value !== that.currentValue; + + // Another request was fired in the meantime, the result we got here is no longer current. + // This shouldn't happen as we abort pending requests, but you never know. + if ( that.currentRequest !== currentRequestInternal ) { + return; + } + // If we're here, then the current request has finished, avoid calling .abort() needlessly. + that.currentRequest = undefined; + + that.currentValue = value; + + if ( info.valid ) { + that.setErrors( [], forceReplacement ); + } else { + that.setErrors( info.messages, forceReplacement ); + } + } ).fail( function () { + that.currentValue = null; + that.setErrors( [] ); + } ); + + return currentRequestInternal; + }; + + /** + * Display errors associated with the form element + * @param {Array} errors Error messages. Each error message will be appended to a + * `` or `
  • `, as with jQuery.append(). + * @param {boolean} [forceReplacement] Set true to force a visual replacement even + * if the errors are the same. Ignored if errors are empty. + * @return {mw.htmlform.Checker} + * @chainable + */ + mw.htmlform.Checker.prototype.setErrors = function ( errors, forceReplacement ) { + var $oldErrorBox, tagName, showFunc, text, replace, + $errorBox = this.$errorBox; + + if ( errors.length === 0 ) { + $errorBox.slideUp( function () { + $errorBox + .removeAttr( 'class' ) + .empty(); + } ); + } else { + // Match behavior of HTMLFormField::formatErrors(), or
      + // depending on the count. + tagName = errors.length === 1 ? 'span' : 'ul'; + + // We have to animate the replacement if we're changing the tag. We + // also want to if told to by the caller (i.e. to make it visually + // obvious that the changed field value gives the same error) or if + // the error text changes (because it makes more sense than + // changing the text with no animation). + replace = ( + forceReplacement || $errorBox.length > 1 || + $errorBox[ 0 ].tagName.toLowerCase() !== tagName + ); + if ( !replace ) { + text = $( '<' + tagName + '>' ) + .append( errors.map( function ( e ) { + return errors.length === 1 ? e : $( '
    • ' ).append( e ); + } ) ); + if ( text.text() !== $errorBox.text() ) { + replace = true; + } + } + + $oldErrorBox = $errorBox; + if ( replace ) { + this.$errorBox = $errorBox = $( '<' + tagName + '>' ); + $errorBox.hide(); + $oldErrorBox.after( this.$errorBox ); + } + + showFunc = function () { + if ( $oldErrorBox !== $errorBox ) { + $oldErrorBox + .removeAttr( 'class' ) + .detach(); + } + $errorBox + .attr( 'class', 'error' ) + .empty() + .append( errors.map( function ( e ) { + return errors.length === 1 ? e : $( '
    • ' ).append( e ); + } ) ) + .slideDown(); + }; + if ( $oldErrorBox !== $errorBox && $oldErrorBox.hasClass( 'error' ) ) { + $oldErrorBox.slideUp( showFunc ); + } else { + showFunc(); + } + } + + return this; + }; + +}( mediaWiki, jQuery ) );