From c70f464c078fb04c19a2a0fc3cc9ded118b6b732 Mon Sep 17 00:00:00 2001 From: Phantom42 Date: Sun, 8 Jan 2017 04:37:29 +0200 Subject: [PATCH] UsersMultiselect widget and form field. New widget and html form field, which allows selecting multiple users using convenient single-line input (CapsuleMultiselectWidget) Bug: T131492 Change-Id: I7b6ffe7fb47e0a7083e2a956156ab0f142444398 --- autoload.php | 2 + includes/htmlform/HTMLForm.php | 1 + .../fields/HTMLUsersMultiselectField.php | 86 +++++++++ includes/widget/UsersMultiselectWidget.php | 68 ++++++++ languages/i18n/en.json | 1 + languages/i18n/qqq.json | 1 + resources/Resources.php | 9 + .../mw.widgets.UsersMultiselectWidget.js | 163 ++++++++++++++++++ 8 files changed, 331 insertions(+) create mode 100644 includes/htmlform/fields/HTMLUsersMultiselectField.php create mode 100644 includes/widget/UsersMultiselectWidget.php create mode 100644 resources/src/mediawiki.widgets/mw.widgets.UsersMultiselectWidget.js diff --git a/autoload.php b/autoload.php index c8033cf951..66326545a0 100644 --- a/autoload.php +++ b/autoload.php @@ -568,6 +568,7 @@ $wgAutoloadLocalClasses = [ 'HTMLTextFieldWithButton' => __DIR__ . '/includes/htmlform/fields/HTMLTextFieldWithButton.php', 'HTMLTitleTextField' => __DIR__ . '/includes/htmlform/fields/HTMLTitleTextField.php', 'HTMLUserTextField' => __DIR__ . '/includes/htmlform/fields/HTMLUserTextField.php', + 'HTMLUsersMultiselectField' => __DIR__ . '/includes/htmlform/fields/HTMLUsersMultiselectField.php', 'HTTPFileStreamer' => __DIR__ . '/includes/libs/filebackend/HTTPFileStreamer.php', 'HWLDFWordAccumulator' => __DIR__ . '/includes/diff/DairikiDiff.php', 'HashBagOStuff' => __DIR__ . '/includes/libs/objectcache/HashBagOStuff.php', @@ -937,6 +938,7 @@ $wgAutoloadLocalClasses = [ 'MediaWiki\\Widget\\Search\\SimpleSearchResultWidget' => __DIR__ . '/includes/widget/search/SimpleSearchResultWidget.php', 'MediaWiki\\Widget\\TitleInputWidget' => __DIR__ . '/includes/widget/TitleInputWidget.php', 'MediaWiki\\Widget\\UserInputWidget' => __DIR__ . '/includes/widget/UserInputWidget.php', + 'MediaWiki\\Widget\\UsersMultiselectWidget' => __DIR__ . '/includes/widget/UsersMultiselectWidget.php', 'MemCachedClientforWiki' => __DIR__ . '/includes/compat/MemcachedClientCompat.php', 'MemcLockManager' => __DIR__ . '/includes/libs/lockmanager/MemcLockManager.php', 'MemcachedBagOStuff' => __DIR__ . '/includes/libs/objectcache/MemcachedBagOStuff.php', diff --git a/includes/htmlform/HTMLForm.php b/includes/htmlform/HTMLForm.php index 5c5a9a7eee..ad8c6b4878 100644 --- a/includes/htmlform/HTMLForm.php +++ b/includes/htmlform/HTMLForm.php @@ -165,6 +165,7 @@ class HTMLForm extends ContextSource { 'url' => 'HTMLTextField', 'title' => 'HTMLTitleTextField', 'user' => 'HTMLUserTextField', + 'usersmultiselect' => 'HTMLUsersMultiselectField', ]; public $mFieldData; diff --git a/includes/htmlform/fields/HTMLUsersMultiselectField.php b/includes/htmlform/fields/HTMLUsersMultiselectField.php new file mode 100644 index 0000000000..8c1241d015 --- /dev/null +++ b/includes/htmlform/fields/HTMLUsersMultiselectField.php @@ -0,0 +1,86 @@ +getCheck( $this->mName ) ) { + return $this->getDefault(); + } + + $usersArray = explode( "\n", $request->getText( $this->mName ) ); + // Remove empty lines + $usersArray = array_values( array_filter( $usersArray, function( $username ) { + return trim( $username ) !== ''; + } ) ); + return $usersArray; + } + + public function validate( $value, $alldata ) { + if ( !$this->mParams['exists'] ) { + return true; + } + + if ( is_null( $value ) ) { + return false; + } + + foreach ( $value as $username ) { + $result = parent::validate( $username, $alldata ); + if ( $result !== true ) { + return $result; + } + } + + return true; + } + + public function getInputHTML( $values ) { + $this->mParent->getOutput()->enableOOUI(); + return $this->getInputOOUI( $values ); + } + + public function getInputOOUI( $values ) { + $params = [ 'name' => $this->mName ]; + + if ( isset( $this->mParams['default'] ) ) { + $params['default'] = $this->mParams['default']; + } + + if ( isset( $this->mParams['placeholder'] ) ) { + $params['placeholder'] = $this->mParams['placeholder']; + } else { + $params['placeholder'] = $this->msg( 'mw-widgets-usersmultiselect-placeholder' ) + ->inContentLanguage() + ->plain(); + } + + if ( !is_null( $values ) ) { + $params['default'] = $values; + } + + return new UsersMultiselectWidget( $params ); + } + + protected function shouldInfuseOOUI() { + return true; + } + + protected function getOOUIModules() { + return [ 'mediawiki.widgets.UsersMultiselectWidget' ]; + } + +} diff --git a/includes/widget/UsersMultiselectWidget.php b/includes/widget/UsersMultiselectWidget.php new file mode 100644 index 0000000000..d24ab7bf66 --- /dev/null +++ b/includes/widget/UsersMultiselectWidget.php @@ -0,0 +1,68 @@ +usersArray = $config['default']; + } + if ( isset( $config['name'] ) ) { + $this->inputName = $config['name']; + } + if ( isset( $config['placeholder'] ) ) { + $this->inputPlaceholder = $config['placeholder']; + } + + $textarea = new TextInputWidget( [ + 'name' => $this->inputName, + 'multiline' => true, + 'value' => implode( "\n", $this->usersArray ), + 'rows' => 25, + ] ); + $this->prependContent( $textarea ); + } + + protected function getJavaScriptClassName() { + return 'mw.widgets.UsersMultiselectWidget'; + } + + public function getConfig( &$config ) { + if ( $this->usersArray !== null ) { + $config['data'] = $this->usersArray; + } + if ( $this->inputName !== null ) { + $config['name'] = $this->inputName; + } + if ( $this->inputPlaceholder !== null ) { + $config['placeholder'] = $this->inputPlaceholder; + } + + return parent::getConfig( $config ); + } + +} diff --git a/languages/i18n/en.json b/languages/i18n/en.json index a621f1c5b2..73e2286738 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -4142,6 +4142,7 @@ "mw-widgets-titleinput-description-new-page": "page does not exist yet", "mw-widgets-titleinput-description-redirect": "redirect to $1", "mw-widgets-categoryselector-add-category-placeholder": "Add a category...", + "mw-widgets-usersmultiselect-placeholder": "Add more...", "sessionmanager-tie": "Cannot combine multiple request authentication types: $1.", "sessionprovider-generic": "$1 sessions", "sessionprovider-mediawiki-session-cookiesessionprovider": "cookie-based sessions", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index 4df97b0f2f..543696842e 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -4326,6 +4326,7 @@ "mw-widgets-titleinput-description-new-page": "Description label for a new page in the title input widget.", "mw-widgets-titleinput-description-redirect": "Description label for a redirect in the title input widget.", "mw-widgets-categoryselector-add-category-placeholder": "Placeholder displayed in the category selector widget after the capsules of already added categories.", + "mw-widgets-usersmultiselect-placeholder": "Placeholder displayed in the input field, where new usernames are entered", "sessionmanager-tie": "Used as an error message when multiple session sources are tied in priority.\n\nParameters:\n* $1 - List of dession type descriptions, from messages like {{msg-mw|sessionprovider-mediawiki-session-cookiesessionprovider}}.", "sessionprovider-generic": "Used to create a generic session type description when one isn't provided via the proper message. Should be phrased to make sense when added to a message such as {{msg-mw|cannotloginnow-text}}.\n\nParameters:\n* $1 - PHP classname.", "sessionprovider-mediawiki-session-cookiesessionprovider": "Description of the sessions provided by the CookieSessionProvider class, which use HTTP cookies. Should be phrased to make sense when added to a message such as {{msg-mw|cannotloginnow-text}}.", diff --git a/resources/Resources.php b/resources/Resources.php index 1c16cc3b19..458985f41c 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -2378,6 +2378,15 @@ return [ ], 'targets' => [ 'desktop', 'mobile' ], ], + 'mediawiki.widgets.UsersMultiselectWidget' => [ + 'scripts' => [ + 'resources/src/mediawiki.widgets/mw.widgets.UsersMultiselectWidget.js', + ], + 'dependencies' => [ + 'oojs-ui-widgets', + ], + 'targets' => [ 'desktop', 'mobile' ], + ], 'mediawiki.widgets.SearchInputWidget' => [ 'scripts' => [ 'resources/src/mediawiki.widgets/mw.widgets.SearchInputWidget.js', diff --git a/resources/src/mediawiki.widgets/mw.widgets.UsersMultiselectWidget.js b/resources/src/mediawiki.widgets/mw.widgets.UsersMultiselectWidget.js new file mode 100644 index 0000000000..70d7cb50f2 --- /dev/null +++ b/resources/src/mediawiki.widgets/mw.widgets.UsersMultiselectWidget.js @@ -0,0 +1,163 @@ +/*! + * MediaWiki Widgets - UsersMultiselectWidget class. + * + * @copyright 2017 MediaWiki Widgets Team and others; see AUTHORS.txt + * @license The MIT License (MIT); see LICENSE.txt + */ +( function ( $, mw ) { + + /** + * UsersMultiselectWidget can be used to input list of users in a single + * line. + * + * If used inside HTML form the results will be sent as the list of + * newline-separated usernames. + * + * @class + * @extends OO.ui.CapsuleMultiselectWidget + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {mw.Api} [api] Instance of mw.Api (or subclass thereof) to use for queries + * @cfg {number} [limit=10] Number of results to show in autocomplete menu + * @cfg {string} [name] Name of input to submit results (when used in HTML forms) + */ + mw.widgets.UsersMultiselectWidget = function MwWidgetsUsersMultiselectWidget( config ) { + // Config initialization + config = $.extend( { + limit: 10 + }, config, { + // Because of using autocomplete (constantly changing menu), we need to + // allow adding usernames, which do not present in the menu. + allowArbitrary: true + } ); + + // Parent constructor + mw.widgets.UsersMultiselectWidget.parent.call( this, $.extend( {}, config, {} ) ); + + // Mixin constructors + OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$handle } ) ); + + // Properties + this.limit = config.limit; + + if ( 'name' in config ) { + // If used inside HTML form, then create hidden input, which will store + // the results. + this.hiddenInput = $( '' ) + .attr( 'type', 'hidden' ) + .attr( 'name', config.name ) + .appendTo( this.$element ); + + // Update with preset values + this.updateHiddenInput(); + } + + this.menu = this.getMenu(); + + // Events + // Update contents of autocomplete menu as user types letters + this.$input.on( { + keyup: this.updateMenuItems.bind( this ) + } ); + // When option is selected from autocomplete menu, update the menu + this.menu.connect( this, { + select: 'updateMenuItems' + } ); + // When list of selected usernames changes, update hidden input + this.connect( this, { + change: 'updateHiddenInput' + } ); + + // API init + this.api = config.api || new mw.Api(); + }; + + /* Setup */ + + OO.inheritClass( mw.widgets.UsersMultiselectWidget, OO.ui.CapsuleMultiselectWidget ); + OO.mixinClass( mw.widgets.UsersMultiselectWidget, OO.ui.mixin.PendingElement ); + + /* Methods */ + + /** + * Get currently selected usernames + * + * @return {Array} usernames + */ + mw.widgets.UsersMultiselectWidget.prototype.getSelectedUsernames = function() { + return this.getItemsData(); + }; + + /** + * Update autocomplete menu with items + * + * @private + */ + mw.widgets.UsersMultiselectWidget.prototype.updateMenuItems = function() { + var inputValue = this.$input.val(); + + if ( inputValue === this.inputValue ) { + // Do not restart api query if nothing has changed in the input + return; + } else { + this.inputValue = inputValue; + } + + this.api.abort(); // Abort all unfinished api requests + + if ( inputValue.length > 0 ) { + this.pushPending(); + + this.api.get( { + action: 'query', + list: 'allusers', + // Prefix of list=allusers is case sensitive. Normalise first + // character to uppercase so that "fo" may yield "Foo". + auprefix: inputValue[ 0 ].toUpperCase() + inputValue.slice( 1 ), + aulimit: this.limit + } ).done( function( response ) { + var suggestions = response.query.allusers, + selected = this.getSelectedUsernames(); + + // Remove usernames, which are already selected from suggestions + suggestions = suggestions.map( function ( user ) { + if ( selected.indexOf( user.name ) === -1 ) { + return new OO.ui.MenuOptionWidget( { + data: user.name, + label: user.name + } ); + } + } ).filter( function( item ) { + return item !== undefined; + } ); + + // Remove all items from menu add fill it with new + this.menu.clearItems(); + + // Additional check to prevent bug of autoinserting first suggestion + // while removing user from the list + if ( inputValue.length > 1 || suggestions.length > 1 ) { + this.menu.addItems( suggestions ); + } + + this.popPending(); + }.bind( this ) ).fail( this.popPending.bind( this ) ); + } else { + this.menu.clearItems(); + } + }; + + /** + * If used inside HTML form, then update hiddenInput with list o + * newline-separated usernames. + * + * @private + */ + mw.widgets.UsersMultiselectWidget.prototype.updateHiddenInput = function() { + if ( 'hiddenInput' in this ) { + this.hiddenInput.val( this.getSelectedUsernames().join( '\n' ) ); + } + }; + +}( jQuery, mediaWiki ) ); -- 2.20.1