TitlesMultiselectWidget: Add a widget that allows selection of multiple titles
authorMoriel Schottlender <moriel@gmail.com>
Thu, 30 Aug 2018 00:33:32 +0000 (17:33 -0700)
committerJforrester <jforrester@wikimedia.org>
Wed, 24 Oct 2018 00:46:48 +0000 (00:46 +0000)
Add the widget in both PHP and JS for OOUI, and into HTMLForm
definitions.

In JS, the widget uses the engine from mw.widgets.TitleWidget
with the async support from OO.ui.mixin.RequestManager.

The PHP version provides a textarea, like UsersMultiselectWidget.php
which is then infused if JS is available.

Also, add highlightSearchQuery option for TitleWidget to allow for
not highlighting the partial search query the user typed in, if the
UI requires it. This option (highlighting partial result) is already
optional in the TitleOptionWidget, so this config exposes that
optionality in the TitleWidget widget for its menu children.

Notes:

HTMLTitlesMultiselectField is a duplication of HTMLUsersMultiselectField
except for:
- The configuration variable changed to 'titles' (from 'users')
- OOUI modules were adjusted for the TitlesMultiselectWidget
- The PHP version instantiates a MediaWiki\Widget\TitlesMultiselectWidget

TitlesMultiselectWidget is a duplication of UsersMultiselectWidget
except for:
- $usersArray was renamed to $titlesArray
- getJavascriptClassName returns the correct js class for
  mw.widgets.TitlesMultiselectWidget for infusion.

Bug: T197109
Depends-On: I675316dddf272fd0d6172ecad3882160752bf780
Change-Id: Ie96947a35f70b76731e16ae5b85de815dfa4a8ce

autoload.php
includes/htmlform/HTMLForm.php
includes/htmlform/fields/HTMLTitlesMultiselectField.php [new file with mode: 0644]
includes/widget/TitlesMultiselectWidget.php [new file with mode: 0644]
languages/i18n/en.json
languages/i18n/qqq.json
resources/Resources.php
resources/src/mediawiki.widgets/mw.widgets.TitleWidget.js
resources/src/mediawiki.widgets/mw.widgets.TitlesMultiselectWidget.js [new file with mode: 0644]

index 3e6b4a2..94e6fa1 100644 (file)
@@ -613,6 +613,7 @@ $wgAutoloadLocalClasses = [
        'HTMLTextField' => __DIR__ . '/includes/htmlform/fields/HTMLTextField.php',
        'HTMLTextFieldWithButton' => __DIR__ . '/includes/htmlform/fields/HTMLTextFieldWithButton.php',
        'HTMLTitleTextField' => __DIR__ . '/includes/htmlform/fields/HTMLTitleTextField.php',
+       'HTMLTitlesMultiselectField' => __DIR__ . '/includes/htmlform/fields/HTMLTitlesMultiselectField.php',
        'HTMLUserTextField' => __DIR__ . '/includes/htmlform/fields/HTMLUserTextField.php',
        'HTMLUsersMultiselectField' => __DIR__ . '/includes/htmlform/fields/HTMLUsersMultiselectField.php',
        'HTTPFileStreamer' => __DIR__ . '/includes/libs/filebackend/HTTPFileStreamer.php',
@@ -937,6 +938,7 @@ $wgAutoloadLocalClasses = [
        'MediaWiki\\Widget\\SelectWithInputWidget' => __DIR__ . '/includes/widget/SelectWithInputWidget.php',
        'MediaWiki\\Widget\\SizeFilterWidget' => __DIR__ . '/includes/widget/SizeFilterWidget.php',
        'MediaWiki\\Widget\\TitleInputWidget' => __DIR__ . '/includes/widget/TitleInputWidget.php',
+       'MediaWiki\\Widget\\TitlesMultiselectWidget' => __DIR__ . '/includes/widget/TitlesMultiselectWidget.php',
        'MediaWiki\\Widget\\UserInputWidget' => __DIR__ . '/includes/widget/UserInputWidget.php',
        'MediaWiki\\Widget\\UsersMultiselectWidget' => __DIR__ . '/includes/widget/UsersMultiselectWidget.php',
        'MemcLockManager' => __DIR__ . '/includes/libs/lockmanager/MemcLockManager.php',
index 52a18eb..d3b5db7 100644 (file)
@@ -175,6 +175,7 @@ class HTMLForm extends ContextSource {
                'title' => HTMLTitleTextField::class,
                'user' => HTMLUserTextField::class,
                'usersmultiselect' => HTMLUsersMultiselectField::class,
+               'titlesmultiselect' => HTMLTitlesMultiselectField::class,
        ];
 
        public $mFieldData;
diff --git a/includes/htmlform/fields/HTMLTitlesMultiselectField.php b/includes/htmlform/fields/HTMLTitlesMultiselectField.php
new file mode 100644 (file)
index 0000000..c93c940
--- /dev/null
@@ -0,0 +1,120 @@
+<?php
+
+use MediaWiki\Widget\TitlesMultiselectWidget;
+
+/**
+ * Implements a tag multiselect input field for titles.
+ *
+ * Besides the parameters recognized by HTMLTitleTextField, additional recognized
+ * parameters are:
+ *  default - (optional) Array of usernames to use as preset data
+ *  placeholder - (optional) Custom placeholder message for input
+ *
+ * The result is the array of titles
+ *
+ * This widget is a duplication of HTMLUsersMultiselectField, except for:
+ * - The configuration variable changed to 'titles' (from 'users')
+ * - OOUI modules were adjusted for the TitlesMultiselectWidget
+ * - The PHP version instantiates a MediaWiki\Widget\TitlesMultiselectWidget
+ *
+ * @note This widget is not likely to remain functional in non-OOUI forms.
+ */
+class HTMLTitlesMultiselectField extends HTMLTitleTextField {
+       public function __construct( $params ) {
+               $params += [
+                       // This overrides the default from HTMLTitleTextField
+                       'required' => false,
+               ];
+
+               parent::__construct( $params );
+       }
+
+       public function loadDataFromRequest( $request ) {
+               $value = $request->getText( $this->mName, $this->getDefault() );
+
+               $titlesArray = explode( "\n", $value );
+               // Remove empty lines
+               $titlesArray = array_values( array_filter( $titlesArray, function ( $title ) {
+                       return trim( $title ) !== '';
+               } ) );
+               // This function is expected to return a string
+               return implode( "\n", $titlesArray );
+       }
+
+       public function validate( $value, $alldata ) {
+               if ( !$this->mParams['exists'] ) {
+                       return true;
+               }
+
+               if ( is_null( $value ) ) {
+                       return false;
+               }
+
+               // $value is a string, because HTMLForm fields store their values as strings
+               $titlesArray = explode( "\n", $value );
+
+               if ( isset( $this->mParams['max'] ) ) {
+                       if ( count( $titlesArray ) > $this->mParams['max'] ) {
+                               return $this->msg( 'htmlform-int-toohigh', $this->mParams['max'] );
+                       }
+               }
+
+               foreach ( $titlesArray as $title ) {
+                       $result = parent::validate( $title, $alldata );
+                       if ( $result !== true ) {
+                               return $result;
+                       }
+               }
+
+               return true;
+       }
+
+       public function getInputHTML( $value ) {
+               $this->mParent->getOutput()->enableOOUI();
+               return $this->getInputOOUI( $value );
+       }
+
+       public function getInputOOUI( $value ) {
+               $params = [
+                       'id' => $this->mID,
+                       'name' => $this->mName,
+                       'dir' => $this->mDir,
+               ];
+
+               if ( isset( $this->mParams['disabled'] ) ) {
+                       $params['disabled'] = $this->mParams['disabled'];
+               }
+
+               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-titlesmultiselect-placeholder' )->plain();
+               }
+
+               if ( !is_null( $value ) ) {
+                       // $value is a string, but the widget expects an array
+                       $params['default'] = $value === '' ? [] : explode( "\n", $value );
+               }
+
+               // Make the field auto-infusable when it's used inside a legacy HTMLForm rather than OOUIHTMLForm
+               $params['infusable'] = true;
+               $params['classes'] = [ 'mw-htmlform-field-autoinfuse' ];
+               $widget = new TitlesMultiselectWidget( $params );
+               $widget->setAttributes( [ 'data-mw-modules' => implode( ',', $this->getOOUIModules() ) ] );
+
+               return $widget;
+       }
+
+       protected function shouldInfuseOOUI() {
+               return true;
+       }
+
+       protected function getOOUIModules() {
+               return [ 'mediawiki.widgets.TitlesMultiselectWidget' ];
+       }
+
+}
diff --git a/includes/widget/TitlesMultiselectWidget.php b/includes/widget/TitlesMultiselectWidget.php
new file mode 100644 (file)
index 0000000..95304b0
--- /dev/null
@@ -0,0 +1,67 @@
+<?php
+
+namespace MediaWiki\Widget;
+
+use OOUI\MultilineTextInputWidget;
+
+/**
+ * Widget to select multiple titles.
+ *
+ * @copyright 2017 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license MIT
+ */
+class TitlesMultiselectWidget extends \OOUI\Widget {
+
+       protected $titlesArray = [];
+       protected $inputName = null;
+       protected $inputPlaceholder = null;
+
+       /**
+        * @param array $config Configuration options
+        *   - array $config['titles'] Array of titles to use as preset data
+        *   - array $config['placeholder'] Placeholder message for input
+        *   - array $config['name'] Name attribute (used in forms)
+        */
+       public function __construct( array $config = [] ) {
+               parent::__construct( $config );
+
+               // Properties
+               if ( isset( $config['default'] ) ) {
+                       $this->titlesArray = $config['default'];
+               }
+               if ( isset( $config['name'] ) ) {
+                       $this->inputName = $config['name'];
+               }
+               if ( isset( $config['placeholder'] ) ) {
+                       $this->inputPlaceholder = $config['placeholder'];
+               }
+
+               $textarea = new MultilineTextInputWidget( [
+                       'name' => $this->inputName,
+                       'value' => implode( "\n", $this->titlesArray ),
+                       'rows' => 10,
+               ] );
+               $this->appendContent( $textarea );
+               $this->addClasses( [ 'mw-widgets-titlesMultiselectWidget' ] );
+       }
+
+       protected function getJavaScriptClassName() {
+               return 'mw.widgets.TitlesMultiselectWidget';
+       }
+
+       public function getConfig( &$config ) {
+               if ( $this->titlesArray !== null ) {
+                       $config['selected'] = $this->titlesArray;
+               }
+               if ( $this->inputName !== null ) {
+                       $config['name'] = $this->inputName;
+               }
+               if ( $this->inputPlaceholder !== null ) {
+                       $config['placeholder'] = $this->inputPlaceholder;
+               }
+
+               $config['$overlay'] = true;
+               return parent::getConfig( $config );
+       }
+
+}
index 5283a5b..0580b0c 100644 (file)
        "mw-widgets-titleinput-description-redirect": "redirect to $1",
        "mw-widgets-categoryselector-add-category-placeholder": "Add a category...",
        "mw-widgets-usersmultiselect-placeholder": "Add more...",
+       "mw-widgets-titlesmultiselect-placeholder": "Add more...",
        "date-range-from": "From date:",
        "date-range-to": "To date:",
        "sessionmanager-tie": "Cannot combine multiple request authentication types: $1.",
index a17cfca..8dae3da 100644 (file)
        "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",
+       "mw-widgets-titlesmultiselect-placeholder": "Placeholder displayed in the input field, where new titles are entered",
        "date-range-from": "Label for an input field that specifies the start date of a date range filter.",
        "date-range-to": "Label for an input field that specifies the end date of a date range filter.",
        "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}}.",
index 9603830..49a3a84 100644 (file)
@@ -2706,6 +2706,18 @@ return [
                ],
                'targets' => [ 'desktop', 'mobile' ],
        ],
+       'mediawiki.widgets.TitlesMultiselectWidget' => [
+               'scripts' => [
+                       'resources/src/mediawiki.widgets/mw.widgets.TitlesMultiselectWidget.js',
+               ],
+               'dependencies' => [
+                       'mediawiki.api',
+                       'oojs-ui-widgets',
+                       // FIXME: Needs TitleInputWidget only
+                       'mediawiki.widgets',
+               ],
+               'targets' => [ 'desktop', 'mobile' ],
+       ],
        'mediawiki.widgets.SearchInputWidget' => [
                'scripts' => [
                        'resources/src/mediawiki.widgets/mw.widgets.SearchInputWidget.js',
index c256f1f..cb1281d 100644 (file)
@@ -28,6 +28,7 @@
         * @cfg {boolean} [excludeCurrentPage] Exclude the current page from suggestions
         * @cfg {boolean} [validateTitle=true] Whether the input must be a valid title
         * @cfg {boolean} [required=false] Whether the input must not be empty
+        * @cfg {boolean} [highlightSearchQuery=true] Highlight the partial query the user used for this title
         * @cfg {Object} [cache] Result cache which implements a 'set' method, taking keyed values as an argument
         * @cfg {mw.Api} [api] API object to use, creates a default mw.Api instance if not specified
         */
@@ -51,6 +52,7 @@
                this.addQueryInput = config.addQueryInput !== false;
                this.excludeCurrentPage = !!config.excludeCurrentPage;
                this.validateTitle = config.validateTitle !== undefined ? config.validateTitle : true;
+               this.highlightSearchQuery = config.highlightSearchQuery === undefined ? true : !!config.highlightSearchQuery;
                this.cache = config.cache;
                this.api = config.api || new mw.Api();
                // Supports: IE10, FF28, Chrome23
                        missing: data.missing,
                        redirect: data.redirect,
                        disambiguation: data.disambiguation,
-                       query: this.getQueryValue(),
+                       query: this.highlightSearchQuery ? this.getQueryValue() : null,
                        compare: this.compare
                };
        };
diff --git a/resources/src/mediawiki.widgets/mw.widgets.TitlesMultiselectWidget.js b/resources/src/mediawiki.widgets/mw.widgets.TitlesMultiselectWidget.js
new file mode 100644 (file)
index 0000000..71ba33f
--- /dev/null
@@ -0,0 +1,145 @@
+/*!
+ * MediaWiki Widgets - TitlesMultiselectWidget class.
+ *
+ * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+( function () {
+
+       /**
+        * Creates an mw.widgets.TitlesMultiselectWidget object
+        *
+        * @class
+        * @extends OO.ui.MenuTagMultiselectWidget
+        * @mixins OO.ui.mixin.RequestManager
+        * @mixins OO.ui.mixin.PendingElement
+        * @mixins mw.widgets.TitleWidget
+        *
+        * @constructor
+        * @param {Object} [config] Configuration options
+        */
+       mw.widgets.TitlesMultiselectWidget = function MwWidgetsTitlesMultiselectWidget( config ) {
+               config = $.extend( true, {
+                       // Shouldn't this be handled by MenuTagMultiselectWidget?
+                       options: config.selected ? config.selected.map( function ( title ) {
+                               return {
+                                       data: title,
+                                       label: title
+                               };
+                       } ) : []
+               }, config );
+
+               // Parent constructor
+               mw.widgets.TitlesMultiselectWidget.parent.call( this, $.extend( true,
+                       {
+                               clearInputOnChoose: true,
+                               inputPosition: 'inline',
+                               allowEditTags: false
+                       },
+                       config
+               ) );
+
+               // Mixin constructors
+               mw.widgets.TitleWidget.call( this, $.extend( true, {
+                       addQueryInput: true,
+                       highlightSearchQuery: false
+               }, config ) );
+               OO.ui.mixin.RequestManager.call( this, config );
+               OO.ui.mixin.PendingElement.call( this, $.extend( true, {}, config, {
+                       $pending: this.$handle
+               } ) );
+
+               // Validate from mw.widgets.TitleWidget
+               this.input.setValidation( this.isQueryValid.bind( this ) );
+
+               if ( this.maxLength !== undefined ) {
+                       // maxLength is defined through TitleWidget parent
+                       this.input.$input.attr( 'maxlength', this.maxLength );
+               }
+
+               // Initialization
+               this.$element
+                       .addClass( 'mw-widgets-titlesMultiselectWidget' );
+
+               this.menu.$element
+                       // For consistency, use the same classes as TitleWidget
+                       // expects for menu results
+                       .addClass( 'mw-widget-titleWidget-menu' )
+                       .toggleClass( 'mw-widget-titleWidget-menu-withImages', this.showImages )
+                       .toggleClass( 'mw-widget-titleWidget-menu-withDescriptions', this.showDescriptions );
+
+               if ( 'name' in config ) {
+                       // Use this instead of <input type="hidden">, because hidden inputs do not have separate
+                       // 'value' and 'defaultValue' properties. The script on Special:Preferences
+                       // (mw.special.preferences.confirmClose) checks this property to see if a field was changed.
+                       this.hiddenInput = $( '<textarea>' )
+                               .addClass( 'oo-ui-element-hidden' )
+                               .attr( 'name', config.name )
+                               .appendTo( this.$element );
+                       // Update with preset values
+                       // Set the default value (it might be different from just being empty)
+                       this.hiddenInput.prop( 'defaultValue', this.getItems().map( function ( item ) {
+                               return item.getData();
+                       } ).join( '\n' ) );
+                       this.on( 'change', function ( items ) {
+                               this.hiddenInput.val( items.map( function ( item ) {
+                                       return item.getData();
+                               } ).join( '\n' ) );
+                               // Trigger a 'change' event as if a user edited the text
+                               // (it is not triggered when changing the value from JS code).
+                               this.hiddenInput.trigger( 'change' );
+                       }.bind( this ) );
+               }
+
+       };
+
+       /* Setup */
+
+       OO.inheritClass( mw.widgets.TitlesMultiselectWidget, OO.ui.MenuTagMultiselectWidget );
+       OO.mixinClass( mw.widgets.TitlesMultiselectWidget, OO.ui.mixin.RequestManager );
+       OO.mixinClass( mw.widgets.TitlesMultiselectWidget, OO.ui.mixin.PendingElement );
+       OO.mixinClass( mw.widgets.TitlesMultiselectWidget, mw.widgets.TitleWidget );
+
+       /* Methods */
+
+       mw.widgets.TitlesMultiselectWidget.prototype.getQueryValue = function () {
+               return this.input.getValue();
+       };
+
+       /**
+        * @inheritdoc OO.ui.MenuTagMultiselectWidget
+        */
+       mw.widgets.TitlesMultiselectWidget.prototype.onInputChange = function () {
+               var widget = this;
+
+               this.getRequestData()
+                       .then( function ( data ) {
+                               // Reset
+                               widget.menu.clearItems();
+                               widget.menu.addItems( widget.getOptionsFromData( data ) );
+                       } );
+
+               mw.widgets.TitlesMultiselectWidget.parent.prototype.onInputChange.call( this );
+       };
+
+       /**
+        * @inheritdoc OO.ui.mixin.RequestManager
+        */
+       mw.widgets.TitlesMultiselectWidget.prototype.getRequestQuery = function () {
+               return this.getQueryValue();
+       };
+
+       /**
+        * @inheritdoc OO.ui.mixin.RequestManager
+        */
+       mw.widgets.TitlesMultiselectWidget.prototype.getRequest = function () {
+               return this.getSuggestionsPromise();
+       };
+
+       /**
+        * @inheritdoc OO.ui.mixin.RequestManager
+        */
+       mw.widgets.TitlesMultiselectWidget.prototype.getRequestCacheDataFromResponse = function ( response ) {
+               return response.query || {};
+       };
+}() );