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