CategorySelector: Add different ways of searching
authorPrateek Saxena <prtksxna@gmail.com>
Fri, 11 Sep 2015 09:22:22 +0000 (14:52 +0530)
committerPrateek Saxena <prtksxna@gmail.com>
Wed, 23 Sep 2015 11:41:59 +0000 (17:11 +0530)
We'll need this for HotCat. We'll be able to set multiple ways of
searching, build an array of promises and collate the results once all
of them have resolved.

Also adds methods to set searchTypes and validates the list.

searchCategories() is too long right now, but can be easily broken down
into multiple routines, not sure if that is helpful though.

This also allows the user to choose categories that don't exist yet.

Bug: T113462
Change-Id: I5a350a2d91b0afb0567f4290654770c74607ce63

resources/src/mediawiki.widgets/mw.widgets.CategorySelector.js

index 8d6452c..995561e 100644 (file)
@@ -5,34 +5,67 @@
  * @license The MIT License (MIT); see LICENSE.txt
  */
 ( function ( $, mw ) {
+       var CSP;
 
        /**
         * Category selector widget. Displays an OO.ui.CapsuleMultiSelectWidget
         * and autocompletes with available categories.
         *
-        * @class
+        *     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
         *
         * @constructor
         * @param {Object} [config] Configuration options
         * @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.
         */
-       mw.widgets.CategorySelector = function ( config ) {
+       function CategorySelector( config ) {
                // Config initialization
-               config = $.extend( { limit: 10 }, config );
+               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, config );
+               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
+               } ) );
 
                // Event handler to call the autocomplete methods
                this.$input.on( 'change input cut paste', OO.ui.debounce( this.updateMenuItems.bind( this ), 100 ) );
-       };
+
+               // Initialize
+               this.catNsId = mw.config.get( 'wgNamespaceIds' ).category;
+               this.api = new mw.Api();
+
+       }
 
        /* Setup */
 
-       OO.inheritClass( mw.widgets.CategorySelector, OO.ui.CapsuleMultiSelectWidget );
+       OO.inheritClass( CategorySelector, OO.ui.CapsuleMultiSelectWidget );
+       CSP = CategorySelector.prototype;
 
        /* Methods */
 
@@ -44,7 +77,8 @@
         * @private
         * @method
         */
-       mw.widgets.CategorySelector.prototype.updateMenuItems = function () {
+       CSP.updateMenuItems = function () {
+               this.getMenu().clearItems();
                this.getNewMenuItems( this.$input.val() ).then( function ( items ) {
                        var existingItems, filteredItems,
                                menu = this.getMenu();
                                } );
                        } );
 
-                       menu.addItems( filteredItems ).updateItemVisibility();
+                       menu.addItems( filteredItems ).toggle( true );
                }.bind( this ) );
        };
 
         * @param {string} input The input used to prefix search categories
         * @return {jQuery.Promise} Resolves with an array of categories
         */
-       mw.widgets.CategorySelector.prototype.getNewMenuItems = function ( input ) {
-               var deferred = new $.Deferred(),
-                       catNsId = mw.config.get( 'wgNamespaceIds' ).category,
-                       api = new mw.Api();
-
-               api.get( {
-                       action: 'opensearch',
-                       namespace: catNsId,
-                       limit: this.limit,
-                       search: input
-               } ).done( function ( res ) {
-                       var categoryNames = res[ 1 ].map( function ( name ) {
-                               return mw.Title.newFromText( name, catNsId ).getMainText();
+       CSP.getNewMenuItems = function ( input ) {
+               var i,
+                       promises = [],
+                       deferred = new $.Deferred();
+
+               for ( i = 0; i < this.searchTypes.length; i++ ) {
+                       promises.push( this.searchCategories( input, this.searchTypes[ i ] ) );
+               }
+
+               $.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, this.catNsId ).getMainText();
                        } );
 
                        deferred.resolve( categoryNames );
+
                } );
 
                return deferred.promise();
        };
+
+       /**
+        * 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: this.catNsId,
+                                       limit: this.limit,
+                                       search: input
+                               } ).done( function ( res ) {
+                                       var categories = res[ 1 ];
+                                       deferred.resolve( categories );
+                               } );
+                               break;
+
+                       case CategorySelector.SearchType.InternalSearch:
+                               this.api.get( {
+                                       action: 'query',
+                                       list: 'allpages',
+                                       apnamespace: this.catNsId,
+                                       aplimit: this.limit,
+                                       apfrom: input,
+                                       apprefix: input
+                               } ).done( function ( res ) {
+                                       var categories = res.query.allpages.map( function ( page ) {
+                                               return page.title;
+                                       } );
+                                       deferred.resolve( categories );
+                               } );
+                               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 );
+                               } );
+                               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 );
+                               } );
+                               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 );
+                               } );
+                               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 ) );