Merge "Use tab instead of spaces in ParsoidVirtualRESTService"
[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
10 /**
11 * Category selector widget. Displays an OO.ui.CapsuleMultiSelectWidget
12 * and autocompletes with available categories.
13 *
14 * var selector = new mw.widgets.CategorySelector( {
15 * searchTypes: [
16 * mw.widgets.CategorySelector.SearchType.OpenSearch,
17 * mw.widgets.CategorySelector.SearchType.InternalSearch
18 * ]
19 * } );
20 *
21 * $( '#content' ).append( selector.$element );
22 *
23 * selector.setSearchType( [ mw.widgets.CategorySelector.SearchType.SubCategories ] );
24 *
25 *
26 * @class mw.widgets.CategorySelector
27 * @uses mw.Api
28 * @extends OO.ui.CapsuleMultiSelectWidget
29 *
30 * @constructor
31 * @param {Object} [config] Configuration options
32 * @cfg {number} [limit=10] Maximum number of results to load
33 * @cfg {mw.widgets.CategorySelector.SearchType[]} [searchTypes=[mw.widgets.CategorySelector.SearchType.OpenSearch]]
34 * Default search API to use when searching.
35 */
36 function CategorySelector( config ) {
37 // Config initialization
38 config = $.extend( {
39 limit: 10,
40 searchTypes: [ CategorySelector.SearchType.OpenSearch ]
41 }, config );
42 this.limit = config.limit;
43 this.searchTypes = config.searchTypes;
44 this.validateSearchTypes();
45
46 // Parent constructor
47 mw.widgets.CategorySelector.parent.call( this, $.extend( true, {}, config, {
48 menu: {
49 filterFromInput: false
50 },
51 // This allows the user to both select non-existent categories, and prevents the selector from
52 // being wiped from #onMenuItemsChange when we change the available options in the dropdown
53 allowArbitrary: true
54 } ) );
55
56 // Event handler to call the autocomplete methods
57 this.$input.on( 'change input cut paste', OO.ui.debounce( this.updateMenuItems.bind( this ), 100 ) );
58
59 // Initialize
60 this.catNsId = mw.config.get( 'wgNamespaceIds' ).category;
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, this.catNsId ).getMainText();
141 } );
142
143 deferred.resolve( categoryNames );
144
145 } );
146
147 return deferred.promise();
148 };
149
150 /**
151 * Validates the values in `this.searchType`.
152 *
153 * @private
154 * @return {boolean}
155 */
156 CSP.validateSearchTypes = function () {
157 var validSearchTypes = false,
158 searchTypeEnumCount = Object.keys( CategorySelector.SearchType ).length;
159
160 // Check if all values are in the SearchType enum
161 validSearchTypes = this.searchTypes.every( function ( searchType ) {
162 return searchType > -1 && searchType < searchTypeEnumCount;
163 } );
164
165 if ( validSearchTypes === false ) {
166 throw new Error( 'Unknown searchType in searchTypes' );
167 }
168
169 // If the searchTypes has CategorySelector.SearchType.SubCategories
170 // it can be the only search type.
171 if ( this.searchTypes.indexOf( CategorySelector.SearchType.SubCategories ) > -1 &&
172 this.searchTypes.length > 1
173 ) {
174 throw new Error( 'Can\'t have additional search types with CategorySelector.SearchType.SubCategories' );
175 }
176
177 // If the searchTypes has CategorySelector.SearchType.ParentCategories
178 // it can be the only search type.
179 if ( this.searchTypes.indexOf( CategorySelector.SearchType.ParentCategories ) > -1 &&
180 this.searchTypes.length > 1
181 ) {
182 throw new Error( 'Can\'t have additional search types with CategorySelector.SearchType.ParentCategories' );
183 }
184
185 return true;
186 };
187
188 /**
189 * Sets and validates the value of `this.searchType`.
190 *
191 * @param {mw.widgets.CategorySelector.SearchType[]} searchTypes
192 */
193 CSP.setSearchTypes = function ( searchTypes ) {
194 this.searchTypes = searchTypes;
195 this.validateSearchTypes();
196 };
197
198 /**
199 * Searches categories based on input and searchType.
200 *
201 * @private
202 * @method
203 * @param {string} input The input used to prefix search categories
204 * @param {mw.widgets.CategorySelector.SearchType} searchType
205 * @return {jQuery.Promise} Resolves with an array of categories
206 */
207 CSP.searchCategories = function ( input, searchType ) {
208 var deferred = new $.Deferred();
209
210 switch ( searchType ) {
211 case CategorySelector.SearchType.OpenSearch:
212 this.api.get( {
213 action: 'opensearch',
214 namespace: this.catNsId,
215 limit: this.limit,
216 search: input
217 } ).done( function ( res ) {
218 var categories = res[ 1 ];
219 deferred.resolve( categories );
220 } );
221 break;
222
223 case CategorySelector.SearchType.InternalSearch:
224 this.api.get( {
225 action: 'query',
226 list: 'allpages',
227 apnamespace: this.catNsId,
228 aplimit: this.limit,
229 apfrom: input,
230 apprefix: input
231 } ).done( function ( res ) {
232 var categories = res.query.allpages.map( function ( page ) {
233 return page.title;
234 } );
235 deferred.resolve( categories );
236 } );
237 break;
238
239 case CategorySelector.SearchType.Exists:
240 if ( input.indexOf( '|' ) > -1 ) {
241 deferred.resolve( [] );
242 break;
243 }
244
245 this.api.get( {
246 action: 'query',
247 prop: 'info',
248 titles: 'Category:' + input
249 } ).done( function ( res ) {
250 var page,
251 categories = [];
252
253 for ( page in res.query.pages ) {
254 if ( parseInt( page, 10 ) > -1 ) {
255 categories.push( res.query.pages[ page ].title );
256 }
257 }
258
259 deferred.resolve( categories );
260 } );
261 break;
262
263 case CategorySelector.SearchType.SubCategories:
264 if ( input.indexOf( '|' ) > -1 ) {
265 deferred.resolve( [] );
266 break;
267 }
268
269 this.api.get( {
270 action: 'query',
271 list: 'categorymembers',
272 cmtype: 'subcat',
273 cmlimit: this.limit,
274 cmtitle: 'Category:' + input
275 } ).done( function ( res ) {
276 var categories = res.query.categorymembers.map( function ( category ) {
277 return category.title;
278 } );
279 deferred.resolve( categories );
280 } );
281 break;
282
283 case CategorySelector.SearchType.ParentCategories:
284 if ( input.indexOf( '|' ) > -1 ) {
285 deferred.resolve( [] );
286 break;
287 }
288
289 this.api.get( {
290 action: 'query',
291 prop: 'categories',
292 cllimit: this.limit,
293 titles: 'Category:' + input
294 } ).done( function ( res ) {
295 var page,
296 categories = [];
297
298 for ( page in res.query.pages ) {
299 if ( parseInt( page, 10 ) > -1 ) {
300 if ( $.isArray( res.query.pages[ page ].categories ) ) {
301 categories.push.apply( categories, res.query.pages[ page ].categories.map( function ( category ) {
302 return category.title;
303 } ) );
304 }
305 }
306 }
307
308 deferred.resolve( categories );
309 } );
310 break;
311
312 default:
313 throw new Error( 'Unknown searchType' );
314 }
315
316 return deferred.promise();
317 };
318
319 /**
320 * @enum mw.widgets.CategorySelector.SearchType
321 * Types of search available.
322 */
323 CategorySelector.SearchType = {
324 /** Search using action=opensearch */
325 OpenSearch: 0,
326
327 /** Search using action=query */
328 InternalSearch: 1,
329
330 /** Search for existing categories with the exact title */
331 Exists: 2,
332
333 /** Search only subcategories */
334 SubCategories: 3,
335
336 /** Search only parent categories */
337 ParentCategories: 4
338 };
339
340 mw.widgets.CategorySelector = CategorySelector;
341 }( jQuery, mediaWiki ) );