Localisation updates from https://translatewiki.net.
[mediawiki.git] / resources / src / mediawiki.widgets / mw.widgets.CategoryMultiselectWidget.js
blob1e5687e8d10ba8cf64045f71d9a9291414bddb1d
1 /*!
2  * MediaWiki Widgets - CategoryMultiselectWidget class.
3  *
4  * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
5  * @license The MIT License (MIT); see LICENSE.txt
6  */
7 ( function () {
8         const hasOwn = Object.prototype.hasOwnProperty,
9                 NS_CATEGORY = mw.config.get( 'wgNamespaceIds' ).category;
11         /**
12          * @classdesc Displays an {@link OO.ui.MenuTagMultiselectWidget}
13          * and autocompletes with available categories.
14          *
15          * @example
16          * mw.loader.using( 'mediawiki.widgets.CategoryMultiselectWidget', function () {
17          *   let selector = new mw.widgets.CategoryMultiselectWidget( {
18          *     searchTypes: [
19          *       mw.widgets.CategoryMultiselectWidget.SearchType.OpenSearch,
20          *       mw.widgets.CategoryMultiselectWidget.SearchType.InternalSearch
21          *     ]
22          *   } );
23          *
24          *   $( document.body ).append( selector.$element );
25          *
26          *   selector.setSearchTypes( [ mw.widgets.CategoryMultiselectWidget.SearchType.SubCategories ] );
27          * } );
28          *
29          * @class mw.widgets.CategoryMultiselectWidget
30          * @uses mw.Api
31          * @extends OO.ui.MenuTagMultiselectWidget
32          * @mixes OO.ui.mixin.PendingElement
33          *
34          * @constructor
35          * @description Create an instance of `mw.widgets.CategoryMultiselectWidget`.
36          * @param {Object} [config] Configuration options
37          * @param {mw.Api} [config.api] Instance of mw.Api (or subclass thereof) to use for queries
38          * @param {number} [config.limit=10] Maximum number of results to load
39          * @param {mw.widgets.CategoryMultiselectWidget.SearchType[]} [config.searchTypes=[mw.widgets.CategoryMultiselectWidget.SearchType.OpenSearch]]
40          *   Default search API to use when searching.
41          */
42         mw.widgets.CategoryMultiselectWidget = function MWCategoryMultiselectWidget( config ) {
43                 // Config initialization
44                 config = Object.assign( {
45                         limit: 10,
46                         searchTypes: [ mw.widgets.CategoryMultiselectWidget.SearchType.OpenSearch ],
47                         placeholder: mw.msg( 'mw-widgets-categoryselector-add-category-placeholder' )
48                 }, config );
49                 this.limit = config.limit;
50                 this.searchTypes = config.searchTypes;
51                 this.validateSearchTypes();
53                 // Parent constructor
54                 mw.widgets.CategoryMultiselectWidget.super.call( this, $.extend( true, {}, config, {
55                         menu: {
56                                 filterFromInput: false
57                         },
58                         // This allows the user to both select non-existent categories, and prevents the selector from
59                         // being wiped from #onMenuItemsChange when we change the available options in the dropdown
60                         allowArbitrary: true
61                 } ) );
63                 // Mixin constructors
64                 OO.ui.mixin.PendingElement.call( this, Object.assign( {}, config, { $pending: this.$handle } ) );
66                 // Event handler to call the autocomplete methods
67                 this.input.$input.on( 'change input cut paste', OO.ui.debounce( this.updateMenuItems.bind( this ), 100 ) );
69                 // Initialize
70                 this.api = config.api || new mw.Api();
71                 this.searchCache = {};
72         };
74         /* Setup */
76         OO.inheritClass( mw.widgets.CategoryMultiselectWidget, OO.ui.MenuTagMultiselectWidget );
77         OO.mixinClass( mw.widgets.CategoryMultiselectWidget, OO.ui.mixin.PendingElement );
79         /* Methods */
81         /**
82          * Gets new items based on the input by calling
83          * {@link #getNewMenuItems getNewItems} and updates the menu
84          * after removing duplicates based on the data value.
85          *
86          * @private
87          * @method
88          */
89         mw.widgets.CategoryMultiselectWidget.prototype.updateMenuItems = function () {
90                 this.getMenu().clearItems();
91                 this.getNewMenuItems( this.input.$input.val() ).then( ( items ) => {
92                         const menu = this.getMenu();
94                         // Never show the menu if the input lost focus in the meantime
95                         if ( !this.input.$input.is( ':focus' ) ) {
96                                 return;
97                         }
99                         // Array of strings of the data of OO.ui.MenuOptionsWidgets
100                         const existingItems = menu.getItems().map( ( item ) => item.data );
102                         // Remove if items' data already exists
103                         let filteredItems = items.filter( ( item ) => existingItems.indexOf( item ) === -1 );
105                         // Map to an array of OO.ui.MenuOptionWidgets
106                         filteredItems = filteredItems.map( ( item ) => new OO.ui.MenuOptionWidget( {
107                                 data: item,
108                                 label: item
109                         } ) );
111                         menu.addItems( filteredItems ).toggle( true );
112                 } );
113         };
115         /**
116          * @inheritdoc
117          */
118         mw.widgets.CategoryMultiselectWidget.prototype.clearInput = function () {
119                 mw.widgets.CategoryMultiselectWidget.super.prototype.clearInput.call( this );
120                 // Abort all pending requests, we won't need their results
121                 this.api.abort();
122         };
124         /**
125          * Searches for categories based on the input.
126          *
127          * @private
128          * @method
129          * @param {string} input The input used to prefix search categories
130          * @return {jQuery.Promise} Resolves with an array of categories
131          */
132         mw.widgets.CategoryMultiselectWidget.prototype.getNewMenuItems = function ( input ) {
133                 const deferred = $.Deferred();
135                 if ( input.trim() === '' ) {
136                         deferred.resolve( [] );
137                         return deferred.promise();
138                 }
140                 // Abort all pending requests, we won't need their results
141                 this.api.abort();
142                 const promises = [];
143                 for ( let i = 0; i < this.searchTypes.length; i++ ) {
144                         promises.push( this.searchCategories( input, this.searchTypes[ i ] ) );
145                 }
147                 this.pushPending();
149                 $.when( ...promises ).done( ( ...dataSets ) => {
150                         // Flatten array
151                         const allData = [].concat( ...dataSets );
153                         const categoryNames = allData
154                                 // Remove duplicates
155                                 .filter( ( value, index, arr ) => arr.indexOf( value ) === index )
156                                 // Get Title objects
157                                 .map( ( name ) => mw.Title.newFromText( name ) )
158                                 // Keep only titles from 'Category' namespace
159                                 .filter( ( title ) => title && title.getNamespaceId() === NS_CATEGORY )
160                                 // Convert back to strings, strip 'Category:' prefix
161                                 .map( ( title ) => title.getMainText() );
163                         deferred.resolve( categoryNames );
165                 } ).always( this.popPending.bind( this ) );
167                 return deferred.promise();
168         };
170         /**
171          * @inheritdoc
172          */
173         mw.widgets.CategoryMultiselectWidget.prototype.isAllowedData = function ( data ) {
174                 const title = mw.Title.makeTitle( NS_CATEGORY, data );
175                 if ( !title ) {
176                         return false;
177                 }
178                 return mw.widgets.CategoryMultiselectWidget.super.prototype.isAllowedData.call( this, data );
179         };
181         /**
182          * @inheritdoc
183          */
184         mw.widgets.CategoryMultiselectWidget.prototype.createTagItemWidget = function ( data ) {
185                 const title = mw.Title.makeTitle( NS_CATEGORY, data );
187                 return new mw.widgets.CategoryTagItemWidget( {
188                         apiUrl: this.api.apiUrl || undefined,
189                         title: title
190                 } );
191         };
193         /**
194          * @inheritdoc
195          */
196         mw.widgets.CategoryMultiselectWidget.prototype.findItemFromData = function ( data ) {
197                 // This is a bit of a hack... We have to canonicalize the data in the same way that
198                 // #createItemWidget and CategoryTagItemWidget will do, otherwise we won't find duplicates.
199                 const title = mw.Title.makeTitle( NS_CATEGORY, data );
200                 if ( !title ) {
201                         return null;
202                 }
203                 return OO.ui.mixin.GroupElement.prototype.findItemFromData.call( this, title.getMainText() );
204         };
206         /**
207          * Validates the values in `this.searchType`.
208          *
209          * @private
210          * @return {boolean}
211          */
212         mw.widgets.CategoryMultiselectWidget.prototype.validateSearchTypes = function () {
213                 let validSearchTypes = false;
215                 const searchTypeEnumCount = Object.keys( mw.widgets.CategoryMultiselectWidget.SearchType ).length;
217                 // Check if all values are in the SearchType enum
218                 validSearchTypes = this.searchTypes.every( ( searchType ) => searchType > -1 && searchType < searchTypeEnumCount );
220                 if ( validSearchTypes === false ) {
221                         throw new Error( 'Unknown searchType in searchTypes' );
222                 }
224                 // If the searchTypes has mw.widgets.CategoryMultiselectWidget.SearchType.SubCategories
225                 // it can be the only search type.
226                 if ( this.searchTypes.indexOf( mw.widgets.CategoryMultiselectWidget.SearchType.SubCategories ) > -1 &&
227                         this.searchTypes.length > 1
228                 ) {
229                         throw new Error( 'Can\'t have additional search types with mw.widgets.CategoryMultiselectWidget.SearchType.SubCategories' );
230                 }
232                 // If the searchTypes has mw.widgets.CategoryMultiselectWidget.SearchType.ParentCategories
233                 // it can be the only search type.
234                 if ( this.searchTypes.indexOf( mw.widgets.CategoryMultiselectWidget.SearchType.ParentCategories ) > -1 &&
235                         this.searchTypes.length > 1
236                 ) {
237                         throw new Error( 'Can\'t have additional search types with mw.widgets.CategoryMultiselectWidget.SearchType.ParentCategories' );
238                 }
240                 return true;
241         };
243         /**
244          * Sets and validates the value of `this.searchType`.
245          *
246          * @param {mw.widgets.CategoryMultiselectWidget.SearchType[]} searchTypes
247          */
248         mw.widgets.CategoryMultiselectWidget.prototype.setSearchTypes = function ( searchTypes ) {
249                 this.searchTypes = searchTypes;
250                 this.validateSearchTypes();
251         };
253         /**
254          * Searches categories based on input and searchType.
255          *
256          * @private
257          * @method
258          * @param {string} input The input used to prefix search categories
259          * @param {mw.widgets.CategoryMultiselectWidget.SearchType} searchType
260          * @return {jQuery.Promise} Resolves with an array of categories
261          */
262         mw.widgets.CategoryMultiselectWidget.prototype.searchCategories = function ( input, searchType ) {
263                 const deferred = $.Deferred(),
264                         cacheKey = input + searchType.toString();
266                 // Check cache
267                 if ( hasOwn.call( this.searchCache, cacheKey ) ) {
268                         return this.searchCache[ cacheKey ];
269                 }
271                 switch ( searchType ) {
272                         case mw.widgets.CategoryMultiselectWidget.SearchType.OpenSearch:
273                                 this.api.get( {
274                                         formatversion: 2,
275                                         action: 'opensearch',
276                                         namespace: NS_CATEGORY,
277                                         limit: this.limit,
278                                         search: input
279                                 } ).done( ( res ) => {
280                                         const categories = res[ 1 ];
281                                         deferred.resolve( categories );
282                                 } ).fail( deferred.reject.bind( deferred ) );
283                                 break;
285                         case mw.widgets.CategoryMultiselectWidget.SearchType.InternalSearch:
286                                 this.api.get( {
287                                         formatversion: 2,
288                                         action: 'query',
289                                         list: 'allpages',
290                                         apnamespace: NS_CATEGORY,
291                                         aplimit: this.limit,
292                                         apfrom: input,
293                                         apprefix: input
294                                 } ).done( ( res ) => {
295                                         const categories = res.query.allpages.map( ( page ) => page.title );
296                                         deferred.resolve( categories );
297                                 } ).fail( deferred.reject.bind( deferred ) );
298                                 break;
300                         case mw.widgets.CategoryMultiselectWidget.SearchType.Exists:
301                                 if ( input.indexOf( '|' ) > -1 ) {
302                                         deferred.resolve( [] );
303                                         break;
304                                 }
306                                 this.api.get( {
307                                         formatversion: 2,
308                                         action: 'query',
309                                         prop: 'info',
310                                         titles: 'Category:' + input
311                                 } ).done( ( res ) => {
312                                         const categories = [];
314                                         res.query.pages.forEach( ( page ) => {
315                                                 if ( !page.missing ) {
316                                                         categories.push( page.title );
317                                                 }
318                                         } );
320                                         deferred.resolve( categories );
321                                 } ).fail( deferred.reject.bind( deferred ) );
322                                 break;
324                         case mw.widgets.CategoryMultiselectWidget.SearchType.SubCategories:
325                                 if ( input.indexOf( '|' ) > -1 ) {
326                                         deferred.resolve( [] );
327                                         break;
328                                 }
330                                 this.api.get( {
331                                         formatversion: 2,
332                                         action: 'query',
333                                         list: 'categorymembers',
334                                         cmtype: 'subcat',
335                                         cmlimit: this.limit,
336                                         cmtitle: 'Category:' + input
337                                 } ).done( ( res ) => {
338                                         const categories = res.query.categorymembers.map( ( category ) => category.title );
339                                         deferred.resolve( categories );
340                                 } ).fail( deferred.reject.bind( deferred ) );
341                                 break;
343                         case mw.widgets.CategoryMultiselectWidget.SearchType.ParentCategories:
344                                 if ( input.indexOf( '|' ) > -1 ) {
345                                         deferred.resolve( [] );
346                                         break;
347                                 }
349                                 this.api.get( {
350                                         formatversion: 2,
351                                         action: 'query',
352                                         prop: 'categories',
353                                         cllimit: this.limit,
354                                         titles: 'Category:' + input
355                                 } ).done( ( res ) => {
356                                         const categories = [];
358                                         res.query.pages.forEach( ( page ) => {
359                                                 if ( !page.missing && Array.isArray( page.categories ) ) {
360                                                         categories.push( ...page.categories.map( ( category ) => category.title ) );
361                                                 }
362                                         } );
364                                         deferred.resolve( categories );
365                                 } ).fail( deferred.reject.bind( deferred ) );
366                                 break;
368                         default:
369                                 throw new Error( 'Unknown searchType' );
370                 }
372                 // Cache the result
373                 this.searchCache[ cacheKey ] = deferred.promise();
375                 return deferred.promise();
376         };
378         /**
379          * @enum mw.widgets.CategoryMultiselectWidget.SearchType
380          * Types of search available.
381          */
382         mw.widgets.CategoryMultiselectWidget.SearchType = {
383                 /** Search using action=opensearch */
384                 OpenSearch: 0,
386                 /** Search using action=query */
387                 InternalSearch: 1,
389                 /** Search for existing categories with the exact title */
390                 Exists: 2,
392                 /** Search only subcategories */
393                 SubCategories: 3,
395                 /** Search only parent categories */
396                 ParentCategories: 4
397         };
398 }() );