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