jquery.accessKeyLabel: make modifier info public
[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 {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();
47
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 } ) );
57
58 // Mixin constructors
59 OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$handle } ) );
60
61 // Event handler to call the autocomplete methods
62 this.$input.on( 'change input cut paste', OO.ui.debounce( this.updateMenuItems.bind( this ), 100 ) );
63
64 // Initialize
65 this.api = config.api || new mw.Api();
66 }
67
68 /* Setup */
69
70 OO.inheritClass( CategorySelector, OO.ui.CapsuleMultiSelectWidget );
71 OO.mixinClass( CategorySelector, OO.ui.mixin.PendingElement );
72 CSP = CategorySelector.prototype;
73
74 /* Methods */
75
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();
89
90 // Never show the menu if the input lost focus in the meantime
91 if ( !this.$input.is( ':focus' ) ) {
92 return;
93 }
94
95 // Array of strings of the data of OO.ui.MenuOptionsWidgets
96 existingItems = menu.getItems().map( function ( item ) {
97 return item.data;
98 } );
99
100 // Remove if items' data already exists
101 filteredItems = items.filter( function ( item ) {
102 return existingItems.indexOf( item ) === -1;
103 } );
104
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 } );
112
113 menu.addItems( filteredItems ).toggle( true );
114 }.bind( this ) );
115 };
116
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 };
125
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 = new $.Deferred();
138
139 if ( $.trim( input ) === '' ) {
140 deferred.resolve( [] );
141 return deferred.promise();
142 }
143
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 }
149
150 this.pushPending();
151
152 $.when.apply( $, promises ).done( function () {
153 var categories, categoryNames,
154 allData = [],
155 dataSets = Array.prototype.slice.apply( arguments );
156
157 // Collect values from all results
158 allData = allData.concat.apply( allData, dataSets );
159
160 // Remove duplicates
161 categories = allData.filter( function ( value, index, self ) {
162 return self.indexOf( value ) === index;
163 } );
164
165 // Get titles
166 categoryNames = categories.map( function ( name ) {
167 return mw.Title.newFromText( name, NS_CATEGORY ).getMainText();
168 } );
169
170 deferred.resolve( categoryNames );
171
172 } ).always( this.popPending.bind( this ) );
173
174 return deferred.promise();
175 };
176
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 };
186
187 /**
188 * Validates the values in `this.searchType`.
189 *
190 * @private
191 * @return {boolean}
192 */
193 CSP.validateSearchTypes = function () {
194 var validSearchTypes = false,
195 searchTypeEnumCount = Object.keys( CategorySelector.SearchType ).length;
196
197 // Check if all values are in the SearchType enum
198 validSearchTypes = this.searchTypes.every( function ( searchType ) {
199 return searchType > -1 && searchType < searchTypeEnumCount;
200 } );
201
202 if ( validSearchTypes === false ) {
203 throw new Error( 'Unknown searchType in searchTypes' );
204 }
205
206 // If the searchTypes has CategorySelector.SearchType.SubCategories
207 // it can be the only search type.
208 if ( this.searchTypes.indexOf( CategorySelector.SearchType.SubCategories ) > -1 &&
209 this.searchTypes.length > 1
210 ) {
211 throw new Error( 'Can\'t have additional search types with CategorySelector.SearchType.SubCategories' );
212 }
213
214 // If the searchTypes has CategorySelector.SearchType.ParentCategories
215 // it can be the only search type.
216 if ( this.searchTypes.indexOf( CategorySelector.SearchType.ParentCategories ) > -1 &&
217 this.searchTypes.length > 1
218 ) {
219 throw new Error( 'Can\'t have additional search types with CategorySelector.SearchType.ParentCategories' );
220 }
221
222 return true;
223 };
224
225 /**
226 * Sets and validates the value of `this.searchType`.
227 *
228 * @param {mw.widgets.CategorySelector.SearchType[]} searchTypes
229 */
230 CSP.setSearchTypes = function ( searchTypes ) {
231 this.searchTypes = searchTypes;
232 this.validateSearchTypes();
233 };
234
235 /**
236 * Searches categories based on input and searchType.
237 *
238 * @private
239 * @method
240 * @param {string} input The input used to prefix search categories
241 * @param {mw.widgets.CategorySelector.SearchType} searchType
242 * @return {jQuery.Promise} Resolves with an array of categories
243 */
244 CSP.searchCategories = function ( input, searchType ) {
245 var deferred = new $.Deferred();
246
247 switch ( searchType ) {
248 case CategorySelector.SearchType.OpenSearch:
249 this.api.get( {
250 action: 'opensearch',
251 namespace: NS_CATEGORY,
252 limit: this.limit,
253 search: input
254 } ).done( function ( res ) {
255 var categories = res[ 1 ];
256 deferred.resolve( categories );
257 } ).fail( deferred.reject.bind( deferred ) );
258 break;
259
260 case CategorySelector.SearchType.InternalSearch:
261 this.api.get( {
262 action: 'query',
263 list: 'allpages',
264 apnamespace: NS_CATEGORY,
265 aplimit: this.limit,
266 apfrom: input,
267 apprefix: input
268 } ).done( function ( res ) {
269 var categories = res.query.allpages.map( function ( page ) {
270 return page.title;
271 } );
272 deferred.resolve( categories );
273 } ).fail( deferred.reject.bind( deferred ) );
274 break;
275
276 case CategorySelector.SearchType.Exists:
277 if ( input.indexOf( '|' ) > -1 ) {
278 deferred.resolve( [] );
279 break;
280 }
281
282 this.api.get( {
283 action: 'query',
284 prop: 'info',
285 titles: 'Category:' + input
286 } ).done( function ( res ) {
287 var page,
288 categories = [];
289
290 for ( page in res.query.pages ) {
291 if ( parseInt( page, 10 ) > -1 ) {
292 categories.push( res.query.pages[ page ].title );
293 }
294 }
295
296 deferred.resolve( categories );
297 } ).fail( deferred.reject.bind( deferred ) );
298 break;
299
300 case CategorySelector.SearchType.SubCategories:
301 if ( input.indexOf( '|' ) > -1 ) {
302 deferred.resolve( [] );
303 break;
304 }
305
306 this.api.get( {
307 action: 'query',
308 list: 'categorymembers',
309 cmtype: 'subcat',
310 cmlimit: this.limit,
311 cmtitle: 'Category:' + input
312 } ).done( function ( res ) {
313 var categories = res.query.categorymembers.map( function ( category ) {
314 return category.title;
315 } );
316 deferred.resolve( categories );
317 } ).fail( deferred.reject.bind( deferred ) );
318 break;
319
320 case CategorySelector.SearchType.ParentCategories:
321 if ( input.indexOf( '|' ) > -1 ) {
322 deferred.resolve( [] );
323 break;
324 }
325
326 this.api.get( {
327 action: 'query',
328 prop: 'categories',
329 cllimit: this.limit,
330 titles: 'Category:' + input
331 } ).done( function ( res ) {
332 var page,
333 categories = [];
334
335 for ( page in res.query.pages ) {
336 if ( parseInt( page, 10 ) > -1 ) {
337 if ( $.isArray( res.query.pages[ page ].categories ) ) {
338 categories.push.apply( categories, res.query.pages[ page ].categories.map( function ( category ) {
339 return category.title;
340 } ) );
341 }
342 }
343 }
344
345 deferred.resolve( categories );
346 } ).fail( deferred.reject.bind( deferred ) );
347 break;
348
349 default:
350 throw new Error( 'Unknown searchType' );
351 }
352
353 return deferred.promise();
354 };
355
356 /**
357 * @enum mw.widgets.CategorySelector.SearchType
358 * Types of search available.
359 */
360 CategorySelector.SearchType = {
361 /** Search using action=opensearch */
362 OpenSearch: 0,
363
364 /** Search using action=query */
365 InternalSearch: 1,
366
367 /** Search for existing categories with the exact title */
368 Exists: 2,
369
370 /** Search only subcategories */
371 SubCategories: 3,
372
373 /** Search only parent categories */
374 ParentCategories: 4
375 };
376
377 mw.widgets.CategorySelector = CategorySelector;
378 }( jQuery, mediaWiki ) );