diff options
Diffstat (limited to 'resources/src/mediawiki.widgets/mw.widgets.CategorySelector.js')
-rw-r--r-- | resources/src/mediawiki.widgets/mw.widgets.CategorySelector.js | 378 |
1 files changed, 378 insertions, 0 deletions
diff --git a/resources/src/mediawiki.widgets/mw.widgets.CategorySelector.js b/resources/src/mediawiki.widgets/mw.widgets.CategorySelector.js new file mode 100644 index 00000000..59f1d507 --- /dev/null +++ b/resources/src/mediawiki.widgets/mw.widgets.CategorySelector.js @@ -0,0 +1,378 @@ +/*! + * MediaWiki Widgets - CategorySelector class. + * + * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt + * @license The MIT License (MIT); see LICENSE.txt + */ +( function ( $, mw ) { + var CSP, + NS_CATEGORY = mw.config.get( 'wgNamespaceIds' ).category; + + /** + * Category selector widget. Displays an OO.ui.CapsuleMultiSelectWidget + * and autocompletes with available categories. + * + * var selector = new mw.widgets.CategorySelector( { + * searchTypes: [ + * mw.widgets.CategorySelector.SearchType.OpenSearch, + * mw.widgets.CategorySelector.SearchType.InternalSearch + * ] + * } ); + * + * $( '#content' ).append( selector.$element ); + * + * selector.setSearchType( [ mw.widgets.CategorySelector.SearchType.SubCategories ] ); + * + * @class mw.widgets.CategorySelector + * @uses mw.Api + * @extends OO.ui.CapsuleMultiSelectWidget + * @mixins OO.ui.mixin.PendingElement + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {mw.Api} [api] Instance of mw.Api (or subclass thereof) to use for queries + * @cfg {number} [limit=10] Maximum number of results to load + * @cfg {mw.widgets.CategorySelector.SearchType[]} [searchTypes=[mw.widgets.CategorySelector.SearchType.OpenSearch]] + * Default search API to use when searching. + */ + function CategorySelector( config ) { + // Config initialization + config = $.extend( { + limit: 10, + searchTypes: [ CategorySelector.SearchType.OpenSearch ] + }, config ); + this.limit = config.limit; + this.searchTypes = config.searchTypes; + this.validateSearchTypes(); + + // Parent constructor + mw.widgets.CategorySelector.parent.call( this, $.extend( true, {}, config, { + menu: { + filterFromInput: false + }, + // This allows the user to both select non-existent categories, and prevents the selector from + // being wiped from #onMenuItemsChange when we change the available options in the dropdown + allowArbitrary: true + } ) ); + + // Mixin constructors + OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$handle } ) ); + + // Event handler to call the autocomplete methods + this.$input.on( 'change input cut paste', OO.ui.debounce( this.updateMenuItems.bind( this ), 100 ) ); + + // Initialize + this.api = config.api || new mw.Api(); + } + + /* Setup */ + + OO.inheritClass( CategorySelector, OO.ui.CapsuleMultiSelectWidget ); + OO.mixinClass( CategorySelector, OO.ui.mixin.PendingElement ); + CSP = CategorySelector.prototype; + + /* Methods */ + + /** + * Gets new items based on the input by calling + * {@link #getNewMenuItems getNewItems} and updates the menu + * after removing duplicates based on the data value. + * + * @private + * @method + */ + CSP.updateMenuItems = function () { + this.getMenu().clearItems(); + this.getNewMenuItems( this.$input.val() ).then( function ( items ) { + var existingItems, filteredItems, + menu = this.getMenu(); + + // Never show the menu if the input lost focus in the meantime + if ( !this.$input.is( ':focus' ) ) { + return; + } + + // Array of strings of the data of OO.ui.MenuOptionsWidgets + existingItems = menu.getItems().map( function ( item ) { + return item.data; + } ); + + // Remove if items' data already exists + filteredItems = items.filter( function ( item ) { + return existingItems.indexOf( item ) === -1; + } ); + + // Map to an array of OO.ui.MenuOptionWidgets + filteredItems = filteredItems.map( function ( item ) { + return new OO.ui.MenuOptionWidget( { + data: item, + label: item + } ); + } ); + + menu.addItems( filteredItems ).toggle( true ); + }.bind( this ) ); + }; + + /** + * @inheritdoc + */ + CSP.clearInput = function () { + CategorySelector.parent.prototype.clearInput.call( this ); + // Abort all pending requests, we won't need their results + this.api.abort(); + }; + + /** + * Searches for categories based on the input. + * + * @private + * @method + * @param {string} input The input used to prefix search categories + * @return {jQuery.Promise} Resolves with an array of categories + */ + CSP.getNewMenuItems = function ( input ) { + var i, + promises = [], + deferred = new $.Deferred(); + + if ( $.trim( input ) === '' ) { + deferred.resolve( [] ); + return deferred.promise(); + } + + // Abort all pending requests, we won't need their results + this.api.abort(); + for ( i = 0; i < this.searchTypes.length; i++ ) { + promises.push( this.searchCategories( input, this.searchTypes[ i ] ) ); + } + + this.pushPending(); + + $.when.apply( $, promises ).done( function () { + var categories, categoryNames, + allData = [], + dataSets = Array.prototype.slice.apply( arguments ); + + // Collect values from all results + allData = allData.concat.apply( allData, dataSets ); + + // Remove duplicates + categories = allData.filter( function ( value, index, self ) { + return self.indexOf( value ) === index; + } ); + + // Get titles + categoryNames = categories.map( function ( name ) { + return mw.Title.newFromText( name, NS_CATEGORY ).getMainText(); + } ); + + deferred.resolve( categoryNames ); + + } ).always( this.popPending.bind( this ) ); + + return deferred.promise(); + }; + + /** + * @inheritdoc + */ + CSP.createItemWidget = function ( data ) { + return new mw.widgets.CategoryCapsuleItemWidget( { + apiUrl: this.api.apiUrl || undefined, + title: mw.Title.newFromText( data, NS_CATEGORY ) + } ); + }; + + /** + * Validates the values in `this.searchType`. + * + * @private + * @return {boolean} + */ + CSP.validateSearchTypes = function () { + var validSearchTypes = false, + searchTypeEnumCount = Object.keys( CategorySelector.SearchType ).length; + + // Check if all values are in the SearchType enum + validSearchTypes = this.searchTypes.every( function ( searchType ) { + return searchType > -1 && searchType < searchTypeEnumCount; + } ); + + if ( validSearchTypes === false ) { + throw new Error( 'Unknown searchType in searchTypes' ); + } + + // If the searchTypes has CategorySelector.SearchType.SubCategories + // it can be the only search type. + if ( this.searchTypes.indexOf( CategorySelector.SearchType.SubCategories ) > -1 && + this.searchTypes.length > 1 + ) { + throw new Error( 'Can\'t have additional search types with CategorySelector.SearchType.SubCategories' ); + } + + // If the searchTypes has CategorySelector.SearchType.ParentCategories + // it can be the only search type. + if ( this.searchTypes.indexOf( CategorySelector.SearchType.ParentCategories ) > -1 && + this.searchTypes.length > 1 + ) { + throw new Error( 'Can\'t have additional search types with CategorySelector.SearchType.ParentCategories' ); + } + + return true; + }; + + /** + * Sets and validates the value of `this.searchType`. + * + * @param {mw.widgets.CategorySelector.SearchType[]} searchTypes + */ + CSP.setSearchTypes = function ( searchTypes ) { + this.searchTypes = searchTypes; + this.validateSearchTypes(); + }; + + /** + * Searches categories based on input and searchType. + * + * @private + * @method + * @param {string} input The input used to prefix search categories + * @param {mw.widgets.CategorySelector.SearchType} searchType + * @return {jQuery.Promise} Resolves with an array of categories + */ + CSP.searchCategories = function ( input, searchType ) { + var deferred = new $.Deferred(); + + switch ( searchType ) { + case CategorySelector.SearchType.OpenSearch: + this.api.get( { + action: 'opensearch', + namespace: NS_CATEGORY, + limit: this.limit, + search: input + } ).done( function ( res ) { + var categories = res[ 1 ]; + deferred.resolve( categories ); + } ).fail( deferred.reject.bind( deferred ) ); + break; + + case CategorySelector.SearchType.InternalSearch: + this.api.get( { + action: 'query', + list: 'allpages', + apnamespace: NS_CATEGORY, + aplimit: this.limit, + apfrom: input, + apprefix: input + } ).done( function ( res ) { + var categories = res.query.allpages.map( function ( page ) { + return page.title; + } ); + deferred.resolve( categories ); + } ).fail( deferred.reject.bind( deferred ) ); + break; + + case CategorySelector.SearchType.Exists: + if ( input.indexOf( '|' ) > -1 ) { + deferred.resolve( [] ); + break; + } + + this.api.get( { + action: 'query', + prop: 'info', + titles: 'Category:' + input + } ).done( function ( res ) { + var page, + categories = []; + + for ( page in res.query.pages ) { + if ( parseInt( page, 10 ) > -1 ) { + categories.push( res.query.pages[ page ].title ); + } + } + + deferred.resolve( categories ); + } ).fail( deferred.reject.bind( deferred ) ); + break; + + case CategorySelector.SearchType.SubCategories: + if ( input.indexOf( '|' ) > -1 ) { + deferred.resolve( [] ); + break; + } + + this.api.get( { + action: 'query', + list: 'categorymembers', + cmtype: 'subcat', + cmlimit: this.limit, + cmtitle: 'Category:' + input + } ).done( function ( res ) { + var categories = res.query.categorymembers.map( function ( category ) { + return category.title; + } ); + deferred.resolve( categories ); + } ).fail( deferred.reject.bind( deferred ) ); + break; + + case CategorySelector.SearchType.ParentCategories: + if ( input.indexOf( '|' ) > -1 ) { + deferred.resolve( [] ); + break; + } + + this.api.get( { + action: 'query', + prop: 'categories', + cllimit: this.limit, + titles: 'Category:' + input + } ).done( function ( res ) { + var page, + categories = []; + + for ( page in res.query.pages ) { + if ( parseInt( page, 10 ) > -1 ) { + if ( $.isArray( res.query.pages[ page ].categories ) ) { + categories.push.apply( categories, res.query.pages[ page ].categories.map( function ( category ) { + return category.title; + } ) ); + } + } + } + + deferred.resolve( categories ); + } ).fail( deferred.reject.bind( deferred ) ); + break; + + default: + throw new Error( 'Unknown searchType' ); + } + + return deferred.promise(); + }; + + /** + * @enum mw.widgets.CategorySelector.SearchType + * Types of search available. + */ + CategorySelector.SearchType = { + /** Search using action=opensearch */ + OpenSearch: 0, + + /** Search using action=query */ + InternalSearch: 1, + + /** Search for existing categories with the exact title */ + Exists: 2, + + /** Search only subcategories */ + SubCategories: 3, + + /** Search only parent categories */ + ParentCategories: 4 + }; + + mw.widgets.CategorySelector = CategorySelector; +}( jQuery, mediaWiki ) ); |