2 * MediaWiki Widgets - CategorySelector class.
4 * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
5 * @license The MIT License (MIT); see LICENSE.txt
9 NS_CATEGORY = mw.config.get( 'wgNamespaceIds' ).category;
12 * Category selector widget. Displays an OO.ui.CapsuleMultiselectWidget
13 * and autocompletes with available categories.
15 * mw.loader.using( 'mediawiki.widgets.CategorySelector', function () {
16 * var selector = new mw.widgets.CategorySelector( {
18 * mw.widgets.CategorySelector.SearchType.OpenSearch,
19 * mw.widgets.CategorySelector.SearchType.InternalSearch
23 * $( 'body' ).append( selector.$element );
25 * selector.setSearchTypes( [ mw.widgets.CategorySelector.SearchType.SubCategories ] );
28 * @class mw.widgets.CategorySelector
30 * @extends OO.ui.CapsuleMultiselectWidget
31 * @mixins OO.ui.mixin.PendingElement
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.
40 function CategorySelector( config ) {
41 // Config initialization
44 searchTypes: [ CategorySelector.SearchType.OpenSearch ]
46 this.limit = config.limit;
47 this.searchTypes = config.searchTypes;
48 this.validateSearchTypes();
51 mw.widgets.CategorySelector.parent.call( this, $.extend( true, {}, config, {
53 filterFromInput: false
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
62 OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$handle } ) );
64 // Event handler to call the autocomplete methods
65 this.$input.on( 'change input cut paste', OO.ui.debounce( this.updateMenuItems.bind( this ), 100 ) );
68 this.api = config.api || new mw.Api();
69 this.searchCache = {};
74 OO.inheritClass( CategorySelector, OO.ui.CapsuleMultiselectWidget );
75 OO.mixinClass( CategorySelector, OO.ui.mixin.PendingElement );
76 CSP = CategorySelector.prototype;
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.
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();
94 // Never show the menu if the input lost focus in the meantime
95 if ( !this.$input.is( ':focus' ) ) {
99 // Array of strings of the data of OO.ui.MenuOptionsWidgets
100 existingItems = menu.getItems().map( function ( item ) {
104 // Remove if items' data already exists
105 filteredItems = items.filter( function ( item ) {
106 return existingItems.indexOf( item ) === -1;
109 // Map to an array of OO.ui.MenuOptionWidgets
110 filteredItems = filteredItems.map( function ( item ) {
111 return new OO.ui.MenuOptionWidget( {
117 menu.addItems( filteredItems ).toggle( true );
124 CSP.clearInput = function () {
125 CategorySelector.parent.prototype.clearInput.call( this );
126 // Abort all pending requests, we won't need their results
131 * Searches for categories based on the input.
135 * @param {string} input The input used to prefix search categories
136 * @return {jQuery.Promise} Resolves with an array of categories
138 CSP.getNewMenuItems = function ( input ) {
141 deferred = $.Deferred();
143 if ( $.trim( input ) === '' ) {
144 deferred.resolve( [] );
145 return deferred.promise();
148 // Abort all pending requests, we won't need their results
150 for ( i = 0; i < this.searchTypes.length; i++ ) {
151 promises.push( this.searchCategories( input, this.searchTypes[ i ] ) );
156 $.when.apply( $, promises ).done( function () {
159 dataSets = Array.prototype.slice.apply( arguments );
161 // Collect values from all results
162 allData = allData.concat.apply( allData, dataSets );
164 categoryNames = allData
166 .filter( function ( value, index, self ) {
167 return self.indexOf( value ) === index;
170 .map( function ( name ) {
171 return mw.Title.newFromText( name );
173 // Keep only titles from 'Category' namespace
174 .filter( function ( title ) {
175 return title && title.getNamespaceId() === NS_CATEGORY;
177 // Convert back to strings, strip 'Category:' prefix
178 .map( function ( title ) {
179 return title.getMainText();
182 deferred.resolve( categoryNames );
184 } ).always( this.popPending.bind( this ) );
186 return deferred.promise();
192 CSP.createItemWidget = function ( data ) {
193 var title = mw.Title.makeTitle( NS_CATEGORY, data );
197 return new mw.widgets.CategoryCapsuleItemWidget( {
198 apiUrl: this.api.apiUrl || undefined,
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 );
213 return OO.ui.mixin.GroupElement.prototype.getItemFromData.call( this, title.getMainText() );
217 * Validates the values in `this.searchType`.
222 CSP.validateSearchTypes = function () {
223 var validSearchTypes = false,
224 searchTypeEnumCount = Object.keys( CategorySelector.SearchType ).length;
226 // Check if all values are in the SearchType enum
227 validSearchTypes = this.searchTypes.every( function ( searchType ) {
228 return searchType > -1 && searchType < searchTypeEnumCount;
231 if ( validSearchTypes === false ) {
232 throw new Error( 'Unknown searchType in searchTypes' );
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
240 throw new Error( 'Can\'t have additional search types with CategorySelector.SearchType.SubCategories' );
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
248 throw new Error( 'Can\'t have additional search types with CategorySelector.SearchType.ParentCategories' );
255 * Sets and validates the value of `this.searchType`.
257 * @param {mw.widgets.CategorySelector.SearchType[]} searchTypes
259 CSP.setSearchTypes = function ( searchTypes ) {
260 this.searchTypes = searchTypes;
261 this.validateSearchTypes();
265 * Searches categories based on input and searchType.
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
273 CSP.searchCategories = function ( input, searchType ) {
274 var deferred = $.Deferred(),
275 cacheKey = input + searchType.toString();
278 if ( this.searchCache[ cacheKey ] !== undefined ) {
279 return this.searchCache[ cacheKey ];
282 switch ( searchType ) {
283 case CategorySelector.SearchType.OpenSearch:
286 action: 'opensearch',
287 namespace: NS_CATEGORY,
290 } ).done( function ( res ) {
291 var categories = res[ 1 ];
292 deferred.resolve( categories );
293 } ).fail( deferred.reject.bind( deferred ) );
296 case CategorySelector.SearchType.InternalSearch:
301 apnamespace: NS_CATEGORY,
305 } ).done( function ( res ) {
306 var categories = res.query.allpages.map( function ( page ) {
309 deferred.resolve( categories );
310 } ).fail( deferred.reject.bind( deferred ) );
313 case CategorySelector.SearchType.Exists:
314 if ( input.indexOf( '|' ) > -1 ) {
315 deferred.resolve( [] );
323 titles: 'Category:' + input
324 } ).done( function ( res ) {
327 $.each( res.query.pages, function ( index, page ) {
328 if ( !page.missing ) {
329 categories.push( page.title );
333 deferred.resolve( categories );
334 } ).fail( deferred.reject.bind( deferred ) );
337 case CategorySelector.SearchType.SubCategories:
338 if ( input.indexOf( '|' ) > -1 ) {
339 deferred.resolve( [] );
346 list: 'categorymembers',
349 cmtitle: 'Category:' + input
350 } ).done( function ( res ) {
351 var categories = res.query.categorymembers.map( function ( category ) {
352 return category.title;
354 deferred.resolve( categories );
355 } ).fail( deferred.reject.bind( deferred ) );
358 case CategorySelector.SearchType.ParentCategories:
359 if ( input.indexOf( '|' ) > -1 ) {
360 deferred.resolve( [] );
369 titles: 'Category:' + input
370 } ).done( function ( res ) {
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;
381 deferred.resolve( categories );
382 } ).fail( deferred.reject.bind( deferred ) );
386 throw new Error( 'Unknown searchType' );
390 this.searchCache[ cacheKey ] = deferred.promise();
392 return deferred.promise();
396 * @enum mw.widgets.CategorySelector.SearchType
397 * Types of search available.
399 CategorySelector.SearchType = {
400 /** Search using action=opensearch */
403 /** Search using action=query */
406 /** Search for existing categories with the exact title */
409 /** Search only subcategories */
412 /** Search only parent categories */
416 mw.widgets.CategorySelector = CategorySelector;
417 }( jQuery, mediaWiki ) );