Merge "Make lines short to pass phpcs in WikiReferenceTest.php"
[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 *
27 * @class mw.widgets.CategorySelector
28 * @uses mw.Api
29 * @extends OO.ui.CapsuleMultiSelectWidget
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 // Event handler to call the autocomplete methods
58 this.$input.on( 'change input cut paste', OO.ui.debounce( this.updateMenuItems.bind( this ), 100 ) );
59
60 // Initialize
61 this.api = new mw.Api();
62
63 }
64
65 /* Setup */
66
67 OO.inheritClass( CategorySelector, OO.ui.CapsuleMultiSelectWidget );
68 CSP = CategorySelector.prototype;
69
70 /* Methods */
71
72 /**
73 * Gets new items based on the input by calling
74 * {@link #getNewMenuItems getNewItems} and updates the menu
75 * after removing duplicates based on the data value.
76 *
77 * @private
78 * @method
79 */
80 CSP.updateMenuItems = function () {
81 this.getMenu().clearItems();
82 this.getNewMenuItems( this.$input.val() ).then( function ( items ) {
83 var existingItems, filteredItems,
84 menu = this.getMenu();
85
86 // Array of strings of the data of OO.ui.MenuOptionsWidgets
87 existingItems = menu.getItems().map( function ( item ) {
88 return item.data;
89 } );
90
91 // Remove if items' data already exists
92 filteredItems = items.filter( function ( item ) {
93 return existingItems.indexOf( item ) === -1;
94 } );
95
96 // Map to an array of OO.ui.MenuOptionWidgets
97 filteredItems = filteredItems.map( function ( item ) {
98 return new OO.ui.MenuOptionWidget( {
99 data: item,
100 label: item
101 } );
102 } );
103
104 menu.addItems( filteredItems ).toggle( true );
105 }.bind( this ) );
106 };
107
108 /**
109 * Searches for categories based on the input.
110 *
111 * @private
112 * @method
113 * @param {string} input The input used to prefix search categories
114 * @return {jQuery.Promise} Resolves with an array of categories
115 */
116 CSP.getNewMenuItems = function ( input ) {
117 var i,
118 promises = [],
119 deferred = new $.Deferred();
120
121 for ( i = 0; i < this.searchTypes.length; i++ ) {
122 promises.push( this.searchCategories( input, this.searchTypes[ i ] ) );
123 }
124
125 $.when.apply( $, promises ).done( function () {
126 var categories, categoryNames,
127 allData = [],
128 dataSets = Array.prototype.slice.apply( arguments );
129
130 // Collect values from all results
131 allData = allData.concat.apply( allData, dataSets );
132
133 // Remove duplicates
134 categories = allData.filter( function ( value, index, self ) {
135 return self.indexOf( value ) === index;
136 } );
137
138 // Get titles
139 categoryNames = categories.map( function ( name ) {
140 return mw.Title.newFromText( name, NS_CATEGORY ).getMainText();
141 } );
142
143 deferred.resolve( categoryNames );
144
145 } );
146
147 return deferred.promise();
148 };
149
150 /**
151 * @inheritdoc
152 */
153 CSP.createItemWidget = function ( data ) {
154 return new mw.widgets.CategoryCapsuleItemWidget( {
155 title: mw.Title.newFromText( data, NS_CATEGORY )
156 } );
157 };
158
159 /**
160 * Validates the values in `this.searchType`.
161 *
162 * @private
163 * @return {boolean}
164 */
165 CSP.validateSearchTypes = function () {
166 var validSearchTypes = false,
167 searchTypeEnumCount = Object.keys( CategorySelector.SearchType ).length;
168
169 // Check if all values are in the SearchType enum
170 validSearchTypes = this.searchTypes.every( function ( searchType ) {
171 return searchType > -1 && searchType < searchTypeEnumCount;
172 } );
173
174 if ( validSearchTypes === false ) {
175 throw new Error( 'Unknown searchType in searchTypes' );
176 }
177
178 // If the searchTypes has CategorySelector.SearchType.SubCategories
179 // it can be the only search type.
180 if ( this.searchTypes.indexOf( CategorySelector.SearchType.SubCategories ) > -1 &&
181 this.searchTypes.length > 1
182 ) {
183 throw new Error( 'Can\'t have additional search types with CategorySelector.SearchType.SubCategories' );
184 }
185
186 // If the searchTypes has CategorySelector.SearchType.ParentCategories
187 // it can be the only search type.
188 if ( this.searchTypes.indexOf( CategorySelector.SearchType.ParentCategories ) > -1 &&
189 this.searchTypes.length > 1
190 ) {
191 throw new Error( 'Can\'t have additional search types with CategorySelector.SearchType.ParentCategories' );
192 }
193
194 return true;
195 };
196
197 /**
198 * Sets and validates the value of `this.searchType`.
199 *
200 * @param {mw.widgets.CategorySelector.SearchType[]} searchTypes
201 */
202 CSP.setSearchTypes = function ( searchTypes ) {
203 this.searchTypes = searchTypes;
204 this.validateSearchTypes();
205 };
206
207 /**
208 * Searches categories based on input and searchType.
209 *
210 * @private
211 * @method
212 * @param {string} input The input used to prefix search categories
213 * @param {mw.widgets.CategorySelector.SearchType} searchType
214 * @return {jQuery.Promise} Resolves with an array of categories
215 */
216 CSP.searchCategories = function ( input, searchType ) {
217 var deferred = new $.Deferred();
218
219 switch ( searchType ) {
220 case CategorySelector.SearchType.OpenSearch:
221 this.api.get( {
222 action: 'opensearch',
223 namespace: NS_CATEGORY,
224 limit: this.limit,
225 search: input
226 } ).done( function ( res ) {
227 var categories = res[ 1 ];
228 deferred.resolve( categories );
229 } );
230 break;
231
232 case CategorySelector.SearchType.InternalSearch:
233 this.api.get( {
234 action: 'query',
235 list: 'allpages',
236 apnamespace: NS_CATEGORY,
237 aplimit: this.limit,
238 apfrom: input,
239 apprefix: input
240 } ).done( function ( res ) {
241 var categories = res.query.allpages.map( function ( page ) {
242 return page.title;
243 } );
244 deferred.resolve( categories );
245 } );
246 break;
247
248 case CategorySelector.SearchType.Exists:
249 if ( input.indexOf( '|' ) > -1 ) {
250 deferred.resolve( [] );
251 break;
252 }
253
254 this.api.get( {
255 action: 'query',
256 prop: 'info',
257 titles: 'Category:' + input
258 } ).done( function ( res ) {
259 var page,
260 categories = [];
261
262 for ( page in res.query.pages ) {
263 if ( parseInt( page, 10 ) > -1 ) {
264 categories.push( res.query.pages[ page ].title );
265 }
266 }
267
268 deferred.resolve( categories );
269 } );
270 break;
271
272 case CategorySelector.SearchType.SubCategories:
273 if ( input.indexOf( '|' ) > -1 ) {
274 deferred.resolve( [] );
275 break;
276 }
277
278 this.api.get( {
279 action: 'query',
280 list: 'categorymembers',
281 cmtype: 'subcat',
282 cmlimit: this.limit,
283 cmtitle: 'Category:' + input
284 } ).done( function ( res ) {
285 var categories = res.query.categorymembers.map( function ( category ) {
286 return category.title;
287 } );
288 deferred.resolve( categories );
289 } );
290 break;
291
292 case CategorySelector.SearchType.ParentCategories:
293 if ( input.indexOf( '|' ) > -1 ) {
294 deferred.resolve( [] );
295 break;
296 }
297
298 this.api.get( {
299 action: 'query',
300 prop: 'categories',
301 cllimit: this.limit,
302 titles: 'Category:' + input
303 } ).done( function ( res ) {
304 var page,
305 categories = [];
306
307 for ( page in res.query.pages ) {
308 if ( parseInt( page, 10 ) > -1 ) {
309 if ( $.isArray( res.query.pages[ page ].categories ) ) {
310 categories.push.apply( categories, res.query.pages[ page ].categories.map( function ( category ) {
311 return category.title;
312 } ) );
313 }
314 }
315 }
316
317 deferred.resolve( categories );
318 } );
319 break;
320
321 default:
322 throw new Error( 'Unknown searchType' );
323 }
324
325 return deferred.promise();
326 };
327
328 /**
329 * @enum mw.widgets.CategorySelector.SearchType
330 * Types of search available.
331 */
332 CategorySelector.SearchType = {
333 /** Search using action=opensearch */
334 OpenSearch: 0,
335
336 /** Search using action=query */
337 InternalSearch: 1,
338
339 /** Search for existing categories with the exact title */
340 Exists: 2,
341
342 /** Search only subcategories */
343 SubCategories: 3,
344
345 /** Search only parent categories */
346 ParentCategories: 4
347 };
348
349 mw.widgets.CategorySelector = CategorySelector;
350 }( jQuery, mediaWiki ) );