Merge "Remove not used private member variable mParserWarnings from OutputPage"
[mediawiki.git] / resources / src / mediawiki.widgets / mw.widgets.CategorySelector.js
blob510068a231146cc4eb7c9f20aa82d400feccc0d0
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          *     var selector = new mw.widgets.CategorySelector( {
16          *       searchTypes: [
17          *         mw.widgets.CategorySelector.SearchType.OpenSearch,
18          *         mw.widgets.CategorySelector.SearchType.InternalSearch
19          *       ]
20          *     } );
21          *
22          *     $( '#content' ).append( selector.$element );
23          *
24          *     selector.setSearchType( [ mw.widgets.CategorySelector.SearchType.SubCategories ] );
25          *
26          * @class mw.widgets.CategorySelector
27          * @uses mw.Api
28          * @extends OO.ui.CapsuleMultiSelectWidget
29          * @mixins OO.ui.mixin.PendingElement
30          *
31          * @constructor
32          * @param {Object} [config] Configuration options
33          * @cfg {mw.Api} [api] Instance of mw.Api (or subclass thereof) to use for queries
34          * @cfg {number} [limit=10] Maximum number of results to load
35          * @cfg {mw.widgets.CategorySelector.SearchType[]} [searchTypes=[mw.widgets.CategorySelector.SearchType.OpenSearch]]
36          *   Default search API to use when searching.
37          */
38         function CategorySelector( config ) {
39                 // Config initialization
40                 config = $.extend( {
41                         limit: 10,
42                         searchTypes: [ CategorySelector.SearchType.OpenSearch ]
43                 }, config );
44                 this.limit = config.limit;
45                 this.searchTypes = config.searchTypes;
46                 this.validateSearchTypes();
48                 // Parent constructor
49                 mw.widgets.CategorySelector.parent.call( this, $.extend( true, {}, config, {
50                         menu: {
51                                 filterFromInput: false
52                         },
53                         // This allows the user to both select non-existent categories, and prevents the selector from
54                         // being wiped from #onMenuItemsChange when we change the available options in the dropdown
55                         allowArbitrary: true
56                 } ) );
58                 // Mixin constructors
59                 OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$handle } ) );
61                 // Event handler to call the autocomplete methods
62                 this.$input.on( 'change input cut paste', OO.ui.debounce( this.updateMenuItems.bind( this ), 100 ) );
64                 // Initialize
65                 this.api = config.api || new mw.Api();
66         }
68         /* Setup */
70         OO.inheritClass( CategorySelector, OO.ui.CapsuleMultiSelectWidget );
71         OO.mixinClass( CategorySelector, OO.ui.mixin.PendingElement );
72         CSP = CategorySelector.prototype;
74         /* Methods */
76         /**
77          * Gets new items based on the input by calling
78          * {@link #getNewMenuItems getNewItems} and updates the menu
79          * after removing duplicates based on the data value.
80          *
81          * @private
82          * @method
83          */
84         CSP.updateMenuItems = function () {
85                 this.getMenu().clearItems();
86                 this.getNewMenuItems( this.$input.val() ).then( function ( items ) {
87                         var existingItems, filteredItems,
88                                 menu = this.getMenu();
90                         // Never show the menu if the input lost focus in the meantime
91                         if ( !this.$input.is( ':focus' ) ) {
92                                 return;
93                         }
95                         // Array of strings of the data of OO.ui.MenuOptionsWidgets
96                         existingItems = menu.getItems().map( function ( item ) {
97                                 return item.data;
98                         } );
100                         // Remove if items' data already exists
101                         filteredItems = items.filter( function ( item ) {
102                                 return existingItems.indexOf( item ) === -1;
103                         } );
105                         // Map to an array of OO.ui.MenuOptionWidgets
106                         filteredItems = filteredItems.map( function ( item ) {
107                                 return new OO.ui.MenuOptionWidget( {
108                                         data: item,
109                                         label: item
110                                 } );
111                         } );
113                         menu.addItems( filteredItems ).toggle( true );
114                 }.bind( this ) );
115         };
117         /**
118          * @inheritdoc
119          */
120         CSP.clearInput = function () {
121                 CategorySelector.parent.prototype.clearInput.call( this );
122                 // Abort all pending requests, we won't need their results
123                 this.api.abort();
124         };
126         /**
127          * Searches for categories based on the input.
128          *
129          * @private
130          * @method
131          * @param {string} input The input used to prefix search categories
132          * @return {jQuery.Promise} Resolves with an array of categories
133          */
134         CSP.getNewMenuItems = function ( input ) {
135                 var i,
136                         promises = [],
137                         deferred = $.Deferred();
139                 if ( $.trim( input ) === '' ) {
140                         deferred.resolve( [] );
141                         return deferred.promise();
142                 }
144                 // Abort all pending requests, we won't need their results
145                 this.api.abort();
146                 for ( i = 0; i < this.searchTypes.length; i++ ) {
147                         promises.push( this.searchCategories( input, this.searchTypes[ i ] ) );
148                 }
150                 this.pushPending();
152                 $.when.apply( $, promises ).done( function () {
153                         var categories, categoryNames,
154                                 allData = [],
155                                 dataSets = Array.prototype.slice.apply( arguments );
157                         // Collect values from all results
158                         allData = allData.concat.apply( allData, dataSets );
160                         // Remove duplicates
161                         categories = allData.filter( function ( value, index, self ) {
162                                 return self.indexOf( value ) === index;
163                         } );
165                         // Get titles
166                         categoryNames = categories.map( function ( name ) {
167                                 return mw.Title.newFromText( name, NS_CATEGORY ).getMainText();
168                         } );
170                         deferred.resolve( categoryNames );
172                 } ).always( this.popPending.bind( this ) );
174                 return deferred.promise();
175         };
177         /**
178          * @inheritdoc
179          */
180         CSP.createItemWidget = function ( data ) {
181                 return new mw.widgets.CategoryCapsuleItemWidget( {
182                         apiUrl: this.api.apiUrl || undefined,
183                         title: mw.Title.newFromText( data, NS_CATEGORY )
184                 } );
185         };
187         /**
188          * @inheritdoc
189          */
190         CSP.getItemFromData = function ( data ) {
191                 // This is a bit of a hack... We have to canonicalize the data in the same way that
192                 // #createItemWidget and CategoryCapsuleItemWidget will do, otherwise we won't find duplicates.
193                 data = mw.Title.newFromText( data, NS_CATEGORY ).getMainText();
194                 return OO.ui.mixin.GroupElement.prototype.getItemFromData.call( this, data );
195         };
197         /**
198          * Validates the values in `this.searchType`.
199          *
200          * @private
201          * @return {boolean}
202          */
203         CSP.validateSearchTypes = function () {
204                 var validSearchTypes = false,
205                         searchTypeEnumCount = Object.keys( CategorySelector.SearchType ).length;
207                 // Check if all values are in the SearchType enum
208                 validSearchTypes = this.searchTypes.every( function ( searchType ) {
209                         return searchType > -1 && searchType < searchTypeEnumCount;
210                 } );
212                 if ( validSearchTypes === false ) {
213                         throw new Error( 'Unknown searchType in searchTypes' );
214                 }
216                 // If the searchTypes has CategorySelector.SearchType.SubCategories
217                 // it can be the only search type.
218                 if ( this.searchTypes.indexOf( CategorySelector.SearchType.SubCategories ) > -1 &&
219                         this.searchTypes.length > 1
220                 ) {
221                         throw new Error( 'Can\'t have additional search types with CategorySelector.SearchType.SubCategories' );
222                 }
224                 // If the searchTypes has CategorySelector.SearchType.ParentCategories
225                 // it can be the only search type.
226                 if ( this.searchTypes.indexOf( CategorySelector.SearchType.ParentCategories ) > -1 &&
227                         this.searchTypes.length > 1
228                 ) {
229                         throw new Error( 'Can\'t have additional search types with CategorySelector.SearchType.ParentCategories' );
230                 }
232                 return true;
233         };
235         /**
236          * Sets and validates the value of `this.searchType`.
237          *
238          * @param {mw.widgets.CategorySelector.SearchType[]} searchTypes
239          */
240         CSP.setSearchTypes = function ( searchTypes ) {
241                 this.searchTypes = searchTypes;
242                 this.validateSearchTypes();
243         };
245         /**
246          * Searches categories based on input and searchType.
247          *
248          * @private
249          * @method
250          * @param {string} input The input used to prefix search categories
251          * @param {mw.widgets.CategorySelector.SearchType} searchType
252          * @return {jQuery.Promise} Resolves with an array of categories
253          */
254         CSP.searchCategories = function ( input, searchType ) {
255                 var deferred = $.Deferred();
257                 switch ( searchType ) {
258                         case CategorySelector.SearchType.OpenSearch:
259                                 this.api.get( {
260                                         action: 'opensearch',
261                                         namespace: NS_CATEGORY,
262                                         limit: this.limit,
263                                         search: input
264                                 } ).done( function ( res ) {
265                                         var categories = res[ 1 ];
266                                         deferred.resolve( categories );
267                                 } ).fail( deferred.reject.bind( deferred ) );
268                                 break;
270                         case CategorySelector.SearchType.InternalSearch:
271                                 this.api.get( {
272                                         action: 'query',
273                                         list: 'allpages',
274                                         apnamespace: NS_CATEGORY,
275                                         aplimit: this.limit,
276                                         apfrom: input,
277                                         apprefix: input
278                                 } ).done( function ( res ) {
279                                         var categories = res.query.allpages.map( function ( page ) {
280                                                 return page.title;
281                                         } );
282                                         deferred.resolve( categories );
283                                 } ).fail( deferred.reject.bind( deferred ) );
284                                 break;
286                         case CategorySelector.SearchType.Exists:
287                                 if ( input.indexOf( '|' ) > -1 ) {
288                                         deferred.resolve( [] );
289                                         break;
290                                 }
292                                 this.api.get( {
293                                         action: 'query',
294                                         prop: 'info',
295                                         titles: 'Category:' + input
296                                 } ).done( function ( res ) {
297                                         var page,
298                                                 categories = [];
300                                         for ( page in res.query.pages ) {
301                                                 if ( parseInt( page, 10 ) > -1 ) {
302                                                         categories.push( res.query.pages[ page ].title );
303                                                 }
304                                         }
306                                         deferred.resolve( categories );
307                                 } ).fail( deferred.reject.bind( deferred ) );
308                                 break;
310                         case CategorySelector.SearchType.SubCategories:
311                                 if ( input.indexOf( '|' ) > -1 ) {
312                                         deferred.resolve( [] );
313                                         break;
314                                 }
316                                 this.api.get( {
317                                         action: 'query',
318                                         list: 'categorymembers',
319                                         cmtype: 'subcat',
320                                         cmlimit: this.limit,
321                                         cmtitle: 'Category:' + input
322                                 } ).done( function ( res ) {
323                                         var categories = res.query.categorymembers.map( function ( category ) {
324                                                 return category.title;
325                                         } );
326                                         deferred.resolve( categories );
327                                 } ).fail( deferred.reject.bind( deferred ) );
328                                 break;
330                         case CategorySelector.SearchType.ParentCategories:
331                                 if ( input.indexOf( '|' ) > -1 ) {
332                                         deferred.resolve( [] );
333                                         break;
334                                 }
336                                 this.api.get( {
337                                         action: 'query',
338                                         prop: 'categories',
339                                         cllimit: this.limit,
340                                         titles: 'Category:' + input
341                                 } ).done( function ( res )  {
342                                         var page,
343                                                 categories = [];
345                                         for ( page in res.query.pages ) {
346                                                 if ( parseInt( page, 10 ) > -1 ) {
347                                                         if ( $.isArray( res.query.pages[ page ].categories ) ) {
348                                                                 categories.push.apply( categories, res.query.pages[ page ].categories.map( function ( category ) {
349                                                                         return category.title;
350                                                                 } ) );
351                                                         }
352                                                 }
353                                         }
355                                         deferred.resolve( categories );
356                                 } ).fail( deferred.reject.bind( deferred ) );
357                                 break;
359                         default:
360                                 throw new Error( 'Unknown searchType' );
361                 }
363                 return deferred.promise();
364         };
366         /**
367          * @enum mw.widgets.CategorySelector.SearchType
368          * Types of search available.
369          */
370         CategorySelector.SearchType = {
371                 /** Search using action=opensearch */
372                 OpenSearch: 0,
374                 /** Search using action=query */
375                 InternalSearch: 1,
377                 /** Search for existing categories with the exact title */
378                 Exists: 2,
380                 /** Search only subcategories  */
381                 SubCategories: 3,
383                 /** Search only parent categories */
384                 ParentCategories: 4
385         };
387         mw.widgets.CategorySelector = CategorySelector;
388 }( jQuery, mediaWiki ) );