Merge "populateContentModel: Invalidate content model and revision cache"
[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 * 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();
49
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 } ) );
60
61 // Mixin constructors
62 OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$handle } ) );
63
64 // Event handler to call the autocomplete methods
65 this.$input.on( 'change input cut paste', OO.ui.debounce( this.updateMenuItems.bind( this ), 100 ) );
66
67 // Initialize
68 this.api = config.api || new mw.Api();
69 this.searchCache = {};
70 }
71
72 /* Setup */
73
74 OO.inheritClass( CategorySelector, OO.ui.CapsuleMultiselectWidget );
75 OO.mixinClass( CategorySelector, OO.ui.mixin.PendingElement );
76 CSP = CategorySelector.prototype;
77
78 /* Methods */
79
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();
93
94 // Never show the menu if the input lost focus in the meantime
95 if ( !this.$input.is( ':focus' ) ) {
96 return;
97 }
98
99 // Array of strings of the data of OO.ui.MenuOptionsWidgets
100 existingItems = menu.getItems().map( function ( item ) {
101 return item.data;
102 } );
103
104 // Remove if items' data already exists
105 filteredItems = items.filter( function ( item ) {
106 return existingItems.indexOf( item ) === -1;
107 } );
108
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 } );
116
117 menu.addItems( filteredItems ).toggle( true );
118 }.bind( this ) );
119 };
120
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 };
129
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();
142
143 if ( $.trim( input ) === '' ) {
144 deferred.resolve( [] );
145 return deferred.promise();
146 }
147
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 }
153
154 this.pushPending();
155
156 $.when.apply( $, promises ).done( function () {
157 var categoryNames,
158 allData = [],
159 dataSets = Array.prototype.slice.apply( arguments );
160
161 // Collect values from all results
162 allData = allData.concat.apply( allData, dataSets );
163
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 } );
181
182 deferred.resolve( categoryNames );
183
184 } ).always( this.popPending.bind( this ) );
185
186 return deferred.promise();
187 };
188
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 };
202
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 };
215
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;
225
226 // Check if all values are in the SearchType enum
227 validSearchTypes = this.searchTypes.every( function ( searchType ) {
228 return searchType > -1 && searchType < searchTypeEnumCount;
229 } );
230
231 if ( validSearchTypes === false ) {
232 throw new Error( 'Unknown searchType in searchTypes' );
233 }
234
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 }
242
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 }
250
251 return true;
252 };
253
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 };
263
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();
276
277 // Check cache
278 if ( this.searchCache[ cacheKey ] !== undefined ) {
279 return this.searchCache[ cacheKey ];
280 }
281
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;
295
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;
312
313 case CategorySelector.SearchType.Exists:
314 if ( input.indexOf( '|' ) > -1 ) {
315 deferred.resolve( [] );
316 break;
317 }
318
319 this.api.get( {
320 formatversion: 2,
321 action: 'query',
322 prop: 'info',
323 titles: 'Category:' + input
324 } ).done( function ( res ) {
325 var categories = [];
326
327 $.each( res.query.pages, function ( index, page ) {
328 if ( !page.missing ) {
329 categories.push( page.title );
330 }
331 } );
332
333 deferred.resolve( categories );
334 } ).fail( deferred.reject.bind( deferred ) );
335 break;
336
337 case CategorySelector.SearchType.SubCategories:
338 if ( input.indexOf( '|' ) > -1 ) {
339 deferred.resolve( [] );
340 break;
341 }
342
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;
357
358 case CategorySelector.SearchType.ParentCategories:
359 if ( input.indexOf( '|' ) > -1 ) {
360 deferred.resolve( [] );
361 break;
362 }
363
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 = [];
372
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 } );
380
381 deferred.resolve( categories );
382 } ).fail( deferred.reject.bind( deferred ) );
383 break;
384
385 default:
386 throw new Error( 'Unknown searchType' );
387 }
388
389 // Cache the result
390 this.searchCache[ cacheKey ] = deferred.promise();
391
392 return deferred.promise();
393 };
394
395 /**
396 * @enum mw.widgets.CategorySelector.SearchType
397 * Types of search available.
398 */
399 CategorySelector.SearchType = {
400 /** Search using action=opensearch */
401 OpenSearch: 0,
402
403 /** Search using action=query */
404 InternalSearch: 1,
405
406 /** Search for existing categories with the exact title */
407 Exists: 2,
408
409 /** Search only subcategories */
410 SubCategories: 3,
411
412 /** Search only parent categories */
413 ParentCategories: 4
414 };
415
416 mw.widgets.CategorySelector = CategorySelector;
417 }( jQuery, mediaWiki ) );