Merge "Added release notes for 'ContentHandler::runLegacyHooks' removal"
[mediawiki.git] / resources / src / mediawiki.widgets / mw.widgets.CategorySelector.js
blob422c048e76e0b2f66e32531e27dbe62a6c7620e9
1 /*!
2  * MediaWiki Widgets - CategorySelector 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 ( $, mw ) {
8         var CSP,
9                 NS_CATEGORY = mw.config.get( 'wgNamespaceIds' ).category;
11         /**
12          * Category selector widget. Displays an OO.ui.CapsuleMultiselectWidget
13          * and autocompletes with available categories.
14          *
15          *     mw.loader.using( 'mediawiki.widgets.CategorySelector', function () {
16          *       var selector = new mw.widgets.CategorySelector( {
17          *         searchTypes: [
18          *           mw.widgets.CategorySelector.SearchType.OpenSearch,
19          *           mw.widgets.CategorySelector.SearchType.InternalSearch
20          *         ]
21          *       } );
22          *
23          *       $( 'body' ).append( selector.$element );
24          *
25          *       selector.setSearchTypes( [ mw.widgets.CategorySelector.SearchType.SubCategories ] );
26          *     } );
27          *
28          * @class mw.widgets.CategorySelector
29          * @uses mw.Api
30          * @extends OO.ui.CapsuleMultiselectWidget
31          * @mixins OO.ui.mixin.PendingElement
32          *
33          * @constructor
34          * @param {Object} [config] Configuration options
35          * @cfg {mw.Api} [api] Instance of mw.Api (or subclass thereof) to use for queries
36          * @cfg {number} [limit=10] Maximum number of results to load
37          * @cfg {mw.widgets.CategorySelector.SearchType[]} [searchTypes=[mw.widgets.CategorySelector.SearchType.OpenSearch]]
38          *   Default search API to use when searching.
39          */
40         function CategorySelector( config ) {
41                 // Config initialization
42                 config = $.extend( {
43                         limit: 10,
44                         searchTypes: [ CategorySelector.SearchType.OpenSearch ]
45                 }, config );
46                 this.limit = config.limit;
47                 this.searchTypes = config.searchTypes;
48                 this.validateSearchTypes();
50                 // Parent constructor
51                 mw.widgets.CategorySelector.parent.call( this, $.extend( true, {}, config, {
52                         menu: {
53                                 filterFromInput: false
54                         },
55                         placeholder: mw.msg( 'mw-widgets-categoryselector-add-category-placeholder' ),
56                         // This allows the user to both select non-existent categories, and prevents the selector from
57                         // being wiped from #onMenuItemsChange when we change the available options in the dropdown
58                         allowArbitrary: true
59                 } ) );
61                 // Mixin constructors
62                 OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$handle } ) );
64                 // Event handler to call the autocomplete methods
65                 this.$input.on( 'change input cut paste', OO.ui.debounce( this.updateMenuItems.bind( this ), 100 ) );
67                 // Initialize
68                 this.api = config.api || new mw.Api();
69                 this.searchCache = {};
70         }
72         /* Setup */
74         OO.inheritClass( CategorySelector, OO.ui.CapsuleMultiselectWidget );
75         OO.mixinClass( CategorySelector, OO.ui.mixin.PendingElement );
76         CSP = CategorySelector.prototype;
78         /* Methods */
80         /**
81          * Gets new items based on the input by calling
82          * {@link #getNewMenuItems getNewItems} and updates the menu
83          * after removing duplicates based on the data value.
84          *
85          * @private
86          * @method
87          */
88         CSP.updateMenuItems = function () {
89                 this.getMenu().clearItems();
90                 this.getNewMenuItems( this.$input.val() ).then( function ( items ) {
91                         var existingItems, filteredItems,
92                                 menu = this.getMenu();
94                         // Never show the menu if the input lost focus in the meantime
95                         if ( !this.$input.is( ':focus' ) ) {
96                                 return;
97                         }
99                         // Array of strings of the data of OO.ui.MenuOptionsWidgets
100                         existingItems = menu.getItems().map( function ( item ) {
101                                 return item.data;
102                         } );
104                         // Remove if items' data already exists
105                         filteredItems = items.filter( function ( item ) {
106                                 return existingItems.indexOf( item ) === -1;
107                         } );
109                         // Map to an array of OO.ui.MenuOptionWidgets
110                         filteredItems = filteredItems.map( function ( item ) {
111                                 return new OO.ui.MenuOptionWidget( {
112                                         data: item,
113                                         label: item
114                                 } );
115                         } );
117                         menu.addItems( filteredItems ).toggle( true );
118                 }.bind( this ) );
119         };
121         /**
122          * @inheritdoc
123          */
124         CSP.clearInput = function () {
125                 CategorySelector.parent.prototype.clearInput.call( this );
126                 // Abort all pending requests, we won't need their results
127                 this.api.abort();
128         };
130         /**
131          * Searches for categories based on the input.
132          *
133          * @private
134          * @method
135          * @param {string} input The input used to prefix search categories
136          * @return {jQuery.Promise} Resolves with an array of categories
137          */
138         CSP.getNewMenuItems = function ( input ) {
139                 var i,
140                         promises = [],
141                         deferred = $.Deferred();
143                 if ( $.trim( input ) === '' ) {
144                         deferred.resolve( [] );
145                         return deferred.promise();
146                 }
148                 // Abort all pending requests, we won't need their results
149                 this.api.abort();
150                 for ( i = 0; i < this.searchTypes.length; i++ ) {
151                         promises.push( this.searchCategories( input, this.searchTypes[ i ] ) );
152                 }
154                 this.pushPending();
156                 $.when.apply( $, promises ).done( function () {
157                         var categoryNames,
158                                 allData = [],
159                                 dataSets = Array.prototype.slice.apply( arguments );
161                         // Collect values from all results
162                         allData = allData.concat.apply( allData, dataSets );
164                         categoryNames = allData
165                                 // Remove duplicates
166                                 .filter( function ( value, index, self ) {
167                                         return self.indexOf( value ) === index;
168                                 } )
169                                 // Get Title objects
170                                 .map( function ( name ) {
171                                         return mw.Title.newFromText( name );
172                                 } )
173                                 // Keep only titles from 'Category' namespace
174                                 .filter( function ( title ) {
175                                         return title && title.getNamespaceId() === NS_CATEGORY;
176                                 } )
177                                 // Convert back to strings, strip 'Category:' prefix
178                                 .map( function ( title ) {
179                                         return title.getMainText();
180                                 } );
182                         deferred.resolve( categoryNames );
184                 } ).always( this.popPending.bind( this ) );
186                 return deferred.promise();
187         };
189         /**
190          * @inheritdoc
191          */
192         CSP.createItemWidget = function ( data ) {
193                 var title = mw.Title.makeTitle( NS_CATEGORY, data );
194                 if ( !title ) {
195                         return null;
196                 }
197                 return new mw.widgets.CategoryCapsuleItemWidget( {
198                         apiUrl: this.api.apiUrl || undefined,
199                         title: title
200                 } );
201         };
203         /**
204          * @inheritdoc
205          */
206         CSP.getItemFromData = function ( data ) {
207                 // This is a bit of a hack... We have to canonicalize the data in the same way that
208                 // #createItemWidget and CategoryCapsuleItemWidget will do, otherwise we won't find duplicates.
209                 var title = mw.Title.makeTitle( NS_CATEGORY, data );
210                 if ( !title ) {
211                         return null;
212                 }
213                 return OO.ui.mixin.GroupElement.prototype.getItemFromData.call( this, title.getMainText() );
214         };
216         /**
217          * Validates the values in `this.searchType`.
218          *
219          * @private
220          * @return {boolean}
221          */
222         CSP.validateSearchTypes = function () {
223                 var validSearchTypes = false,
224                         searchTypeEnumCount = Object.keys( CategorySelector.SearchType ).length;
226                 // Check if all values are in the SearchType enum
227                 validSearchTypes = this.searchTypes.every( function ( searchType ) {
228                         return searchType > -1 && searchType < searchTypeEnumCount;
229                 } );
231                 if ( validSearchTypes === false ) {
232                         throw new Error( 'Unknown searchType in searchTypes' );
233                 }
235                 // If the searchTypes has CategorySelector.SearchType.SubCategories
236                 // it can be the only search type.
237                 if ( this.searchTypes.indexOf( CategorySelector.SearchType.SubCategories ) > -1 &&
238                         this.searchTypes.length > 1
239                 ) {
240                         throw new Error( 'Can\'t have additional search types with CategorySelector.SearchType.SubCategories' );
241                 }
243                 // If the searchTypes has CategorySelector.SearchType.ParentCategories
244                 // it can be the only search type.
245                 if ( this.searchTypes.indexOf( CategorySelector.SearchType.ParentCategories ) > -1 &&
246                         this.searchTypes.length > 1
247                 ) {
248                         throw new Error( 'Can\'t have additional search types with CategorySelector.SearchType.ParentCategories' );
249                 }
251                 return true;
252         };
254         /**
255          * Sets and validates the value of `this.searchType`.
256          *
257          * @param {mw.widgets.CategorySelector.SearchType[]} searchTypes
258          */
259         CSP.setSearchTypes = function ( searchTypes ) {
260                 this.searchTypes = searchTypes;
261                 this.validateSearchTypes();
262         };
264         /**
265          * Searches categories based on input and searchType.
266          *
267          * @private
268          * @method
269          * @param {string} input The input used to prefix search categories
270          * @param {mw.widgets.CategorySelector.SearchType} searchType
271          * @return {jQuery.Promise} Resolves with an array of categories
272          */
273         CSP.searchCategories = function ( input, searchType ) {
274                 var deferred = $.Deferred(),
275                         cacheKey = input + searchType.toString();
277                 // Check cache
278                 if ( this.searchCache[ cacheKey ] !== undefined ) {
279                         return this.searchCache[ cacheKey ];
280                 }
282                 switch ( searchType ) {
283                         case CategorySelector.SearchType.OpenSearch:
284                                 this.api.get( {
285                                         formatversion: 2,
286                                         action: 'opensearch',
287                                         namespace: NS_CATEGORY,
288                                         limit: this.limit,
289                                         search: input
290                                 } ).done( function ( res ) {
291                                         var categories = res[ 1 ];
292                                         deferred.resolve( categories );
293                                 } ).fail( deferred.reject.bind( deferred ) );
294                                 break;
296                         case CategorySelector.SearchType.InternalSearch:
297                                 this.api.get( {
298                                         formatversion: 2,
299                                         action: 'query',
300                                         list: 'allpages',
301                                         apnamespace: NS_CATEGORY,
302                                         aplimit: this.limit,
303                                         apfrom: input,
304                                         apprefix: input
305                                 } ).done( function ( res ) {
306                                         var categories = res.query.allpages.map( function ( page ) {
307                                                 return page.title;
308                                         } );
309                                         deferred.resolve( categories );
310                                 } ).fail( deferred.reject.bind( deferred ) );
311                                 break;
313                         case CategorySelector.SearchType.Exists:
314                                 if ( input.indexOf( '|' ) > -1 ) {
315                                         deferred.resolve( [] );
316                                         break;
317                                 }
319                                 this.api.get( {
320                                         formatversion: 2,
321                                         action: 'query',
322                                         prop: 'info',
323                                         titles: 'Category:' + input
324                                 } ).done( function ( res ) {
325                                         var categories = [];
327                                         $.each( res.query.pages, function ( index, page ) {
328                                                 if ( !page.missing ) {
329                                                         categories.push( page.title );
330                                                 }
331                                         } );
333                                         deferred.resolve( categories );
334                                 } ).fail( deferred.reject.bind( deferred ) );
335                                 break;
337                         case CategorySelector.SearchType.SubCategories:
338                                 if ( input.indexOf( '|' ) > -1 ) {
339                                         deferred.resolve( [] );
340                                         break;
341                                 }
343                                 this.api.get( {
344                                         formatversion: 2,
345                                         action: 'query',
346                                         list: 'categorymembers',
347                                         cmtype: 'subcat',
348                                         cmlimit: this.limit,
349                                         cmtitle: 'Category:' + input
350                                 } ).done( function ( res ) {
351                                         var categories = res.query.categorymembers.map( function ( category ) {
352                                                 return category.title;
353                                         } );
354                                         deferred.resolve( categories );
355                                 } ).fail( deferred.reject.bind( deferred ) );
356                                 break;
358                         case CategorySelector.SearchType.ParentCategories:
359                                 if ( input.indexOf( '|' ) > -1 ) {
360                                         deferred.resolve( [] );
361                                         break;
362                                 }
364                                 this.api.get( {
365                                         formatversion: 2,
366                                         action: 'query',
367                                         prop: 'categories',
368                                         cllimit: this.limit,
369                                         titles: 'Category:' + input
370                                 } ).done( function ( res ) {
371                                         var categories = [];
373                                         $.each( res.query.pages, function ( index, page ) {
374                                                 if ( !page.missing && $.isArray( page.categories ) ) {
375                                                         categories.push.apply( categories, page.categories.map( function ( category ) {
376                                                                 return category.title;
377                                                         } ) );
378                                                 }
379                                         } );
381                                         deferred.resolve( categories );
382                                 } ).fail( deferred.reject.bind( deferred ) );
383                                 break;
385                         default:
386                                 throw new Error( 'Unknown searchType' );
387                 }
389                 // Cache the result
390                 this.searchCache[ cacheKey ] = deferred.promise();
392                 return deferred.promise();
393         };
395         /**
396          * @enum mw.widgets.CategorySelector.SearchType
397          * Types of search available.
398          */
399         CategorySelector.SearchType = {
400                 /** Search using action=opensearch */
401                 OpenSearch: 0,
403                 /** Search using action=query */
404                 InternalSearch: 1,
406                 /** Search for existing categories with the exact title */
407                 Exists: 2,
409                 /** Search only subcategories  */
410                 SubCategories: 3,
412                 /** Search only parent categories */
413                 ParentCategories: 4
414         };
416         mw.widgets.CategorySelector = CategorySelector;
417 }( jQuery, mediaWiki ) );