RCFilters: Unify reading filters by views and adjust unit tests
[lhc/web/wiklou.git] / resources / src / mediawiki.rcfilters / dm / mw.rcfilters.dm.FiltersViewModel.js
index 36fc4a7..527b96d 100644 (file)
                this.defaultParams = {};
                this.defaultFiltersEmpty = null;
                this.highlightEnabled = false;
+               this.invertedNamespaces = false;
+               this.parameterMap = {};
+
+               this.views = {};
+               this.currentView = null;
 
                // Events
                this.aggregate( { update: 'filterItemUpdate' } );
         * Filter list is initialized
         */
 
+       /**
+        * @event update
+        *
+        * Model has been updated
+        */
+
        /**
         * @event itemUpdate
         * @param {mw.rcfilters.dm.FilterItem} item Filter item updated
         * Highlight feature has been toggled enabled or disabled
         */
 
+       /**
+        * @event invertChange
+        * @param {boolean} isInverted Namespace selected is inverted
+        *
+        * Namespace selection is inverted or straight forward
+        */
+
        /* Methods */
 
        /**
                } );
        };
 
+       /**
+        * Get whether the model has any conflict in its items
+        *
+        * @return {boolean} There is a conflict
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.hasConflict = function () {
+               return this.getItems().some( function ( filterItem ) {
+                       return filterItem.isSelected() && filterItem.isConflicted();
+               } );
+       };
+
+       /**
+        * Get the first item with a current conflict
+        *
+        * @return {mw.rcfilters.dm.FilterItem} Conflicted item
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.getFirstConflictedItem = function () {
+               var conflictedItem;
+
+               $.each( this.getItems(), function ( index, filterItem ) {
+                       if ( filterItem.isSelected() && filterItem.isConflicted() ) {
+                               conflictedItem = filterItem;
+                               return false;
+                       }
+               } );
+
+               return conflictedItem;
+       };
+
        /**
         * Set filters and preserve a group relationship based on
         * the definition given by an object
         *
-        * @param {Array} filters Filter group definition
+        * @param {Array} filterGroups Filters definition
+        * @param {Object} [views] Extra views definition
+        *  Expected in the following format:
+        *  {
+        *     namespaces: {
+        *       label: 'namespaces', // Message key
+        *       trigger: ':',
+        *       groups: [
+        *         {
+        *            // Group info
+        *            name: 'namespaces' // Parameter name
+        *            title: 'namespaces' // Message key
+        *            type: 'string_options',
+        *            separator: ';',
+        *            labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
+        *            fullCoverage: true
+        *            items: []
+        *         }
+        *       ]
+        *     }
+        *  }
         */
-       mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filters ) {
-               var i, filterItem, selectedFilterNames, filterConflictResult, groupConflictResult,
+       mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filterGroups, views ) {
+               var filterConflictResult, groupConflictResult,
+                       allViews = {},
                        model = this,
                        items = [],
-                       supersetMap = {},
                        groupConflictMap = {},
                        filterConflictMap = {},
-                       addArrayElementsUnique = function ( arr, elements ) {
-                               elements = Array.isArray( elements ) ? elements : [ elements ];
-
-                               elements.forEach( function ( element ) {
-                                       if ( arr.indexOf( element ) === -1 ) {
-                                               arr.push( element );
-                                       }
-                               } );
-
-                               return arr;
-                       },
+                       /*!
+                        * Expand a conflict definition from group name to
+                        * the list of all included filters in that group.
+                        * We do this so that the direct relationship in the
+                        * models are consistently item->items rather than
+                        * mixing item->group with item->item.
+                        *
+                        * @param {Object} obj Conflict definition
+                        * @return {Object} Expanded conflict definition
+                        */
                        expandConflictDefinitions = function ( obj ) {
                                var result = {};
 
-                               $.each( obj, function ( group, conflicts ) {
-                                       var adjustedConflicts = {};
+                               $.each( obj, function ( key, conflicts ) {
+                                       var filterName,
+                                               adjustedConflicts = {};
+
                                        conflicts.forEach( function ( conflict ) {
+                                               var filter;
+
                                                if ( conflict.filter ) {
-                                                       adjustedConflicts[ conflict.filter ] = conflict;
+                                                       filterName = model.groups[ conflict.group ].getPrefixedName( conflict.filter );
+                                                       filter = model.getItemByName( filterName );
+
+                                                       // Rename
+                                                       adjustedConflicts[ filterName ] = $.extend(
+                                                               {},
+                                                               conflict,
+                                                               {
+                                                                       filter: filterName,
+                                                                       item: filter
+                                                               }
+                                                       );
                                                } else {
                                                        // This conflict is for an entire group. Split it up to
                                                        // represent each filter
                                                        // Get the relevant group items
                                                        model.groups[ conflict.group ].getItems().forEach( function ( groupItem ) {
                                                                // Rebuild the conflict
-                                                               adjustedConflicts[ groupItem.getName() ] = $.extend( {}, conflict, { filter: groupItem.getName() } );
+                                                               adjustedConflicts[ groupItem.getName() ] = $.extend(
+                                                                       {},
+                                                                       conflict,
+                                                                       {
+                                                                               filter: groupItem.getName(),
+                                                                               item: groupItem
+                                                                       }
+                                                               );
                                                        } );
                                                }
                                        } );
 
-                                       result[ group ] = adjustedConflicts;
+                                       result[ key ] = adjustedConflicts;
                                } );
 
                                return result;
                // Reset
                this.clearItems();
                this.groups = {};
+               this.views = {};
+
+               // Clone
+               filterGroups = OO.copy( filterGroups );
+
+               // Normalize definition from the server
+               filterGroups.forEach( function ( data ) {
+                       var i;
+                       // What's this information needs to be normalized
+                       data.whatsThis = {
+                               body: data.whatsThisBody,
+                               header: data.whatsThisHeader,
+                               linkText: data.whatsThisLinkText,
+                               url: data.whatsThisUrl
+                       };
 
-               filters.forEach( function ( data ) {
-                       var group = data.name;
+                       // Title is a msg-key
+                       data.title = data.title ? mw.msg( data.title ) : data.name;
 
-                       if ( !model.groups[ group ] ) {
-                               model.groups[ group ] = new mw.rcfilters.dm.FilterGroup( group, {
-                                       type: data.type,
-                                       title: mw.msg( data.title ),
-                                       separator: data.separator,
-                                       fullCoverage: !!data.fullCoverage
-                               } );
+                       // Filters are given to us with msg-keys, we need
+                       // to translate those before we hand them off
+                       for ( i = 0; i < data.filters.length; i++ ) {
+                               data.filters[ i ].label = data.filters[ i ].label ? mw.msg( data.filters[ i ].label ) : data.filters[ i ].name;
+                               data.filters[ i ].description = data.filters[ i ].description ? mw.msg( data.filters[ i ].description ) : '';
                        }
+               } );
 
-                       if ( data.conflicts ) {
-                               groupConflictMap[ group ] = data.conflicts;
+               // Collect views
+               allViews = {
+                       'default': {
+                               label: mw.msg( 'rcfilters-filterlist-title' ),
+                               groups: filterGroups
                        }
+               };
 
-                       selectedFilterNames = [];
-                       for ( i = 0; i < data.filters.length; i++ ) {
-                               data.filters[ i ].subset = data.filters[ i ].subset || [];
-                               data.filters[ i ].subset = data.filters[ i ].subset.map( function ( el ) {
-                                       return el.filter;
-                               } );
+               if ( views && mw.config.get( 'wgStructuredChangeFiltersEnableExperimentalViews' ) ) {
+                       // If we have extended views, add them in
+                       $.extend( true, allViews, views );
+               }
 
-                               filterItem = new mw.rcfilters.dm.FilterItem( data.filters[ i ].name, model.groups[ group ], {
-                                       group: group,
-                                       label: mw.msg( data.filters[ i ].label ),
-                                       description: mw.msg( data.filters[ i ].description ),
-                                       subset: data.filters[ i ].subset,
-                                       cssClass: data.filters[ i ].cssClass
-                               } );
+               // Go over all views
+               $.each( allViews, function ( viewName, viewData ) {
+                       // Define the view
+                       model.views[ viewName ] = {
+                               name: viewData.name,
+                               title: viewData.title,
+                               trigger: viewData.trigger
+                       };
 
-                               // For convenience, we should store each filter's "supersets" -- these are
-                               // the filters that have that item in their subset list. This will just
-                               // make it easier to go through whether the item has any other items
-                               // that affect it (and are selected) at any given time
-                               if ( data.filters[ i ].subset ) {
-                                       data.filters[ i ].subset.forEach( function ( subsetFilterName ) { // eslint-disable-line no-loop-func
-                                               supersetMap[ subsetFilterName ] = supersetMap[ subsetFilterName ] || [];
-                                               addArrayElementsUnique(
-                                                       supersetMap[ subsetFilterName ],
-                                                       filterItem.getName()
-                                               );
-                                       } );
-                               }
+                       // Go over groups
+                       viewData.groups.forEach( function ( groupData ) {
+                               var group = groupData.name;
 
-                               // Store conflicts
-                               if ( data.filters[ i ].conflicts ) {
-                                       filterConflictMap[ data.filters[ i ].name ] = data.filters[ i ].conflicts;
-                               }
+                               model.groups[ group ] = new mw.rcfilters.dm.FilterGroup(
+                                       group,
+                                       $.extend( true, {}, groupData, { view: viewName } )
+                               );
 
-                               if ( data.type === 'send_unselected_if_any' ) {
-                                       // Store the default parameter state
-                                       // For this group type, parameter values are direct
-                                       model.defaultParams[ data.filters[ i ].name ] = Number( !!data.filters[ i ].default );
-                               } else if (
-                                       data.type === 'string_options' &&
-                                       data.filters[ i ].default
-                               ) {
-                                       selectedFilterNames.push( data.filters[ i ].name );
-                               }
+                               model.groups[ group ].initializeFilters( groupData.filters, groupData.default );
+                               items = items.concat( model.groups[ group ].getItems() );
 
-                               model.groups[ group ].addItems( filterItem );
-                               items.push( filterItem );
-                       }
+                               // Prepare conflicts
+                               if ( groupData.conflicts ) {
+                                       // Group conflicts
+                                       groupConflictMap[ group ] = groupData.conflicts;
+                               }
 
-                       if ( data.type === 'string_options' ) {
-                               // Store the default parameter group state
-                               // For this group, the parameter is group name and value is the names
-                               // of selected items
-                               model.defaultParams[ group ] = model.sanitizeStringOptionGroup( group, selectedFilterNames ).join( model.groups[ group ].getSeparator() );
-                       }
+                               groupData.filters.forEach( function ( itemData ) {
+                                       var filterItem = model.groups[ group ].getItemByParamName( itemData.name );
+                                       // Filter conflicts
+                                       if ( itemData.conflicts ) {
+                                               filterConflictMap[ filterItem.getName() ] = itemData.conflicts;
+                                       }
+                               } );
+                       } );
                } );
 
+               // Add item references to the model, for lookup
+               this.addItems( items );
+
                // Expand conflicts
                groupConflictResult = expandConflictDefinitions( groupConflictMap );
                filterConflictResult = expandConflictDefinitions( filterConflictMap );
                        model.groups[ group ].setConflicts( conflicts );
                } );
 
-               items.forEach( function ( filterItem ) {
-                       // Apply the superset map
-                       filterItem.setSuperset( supersetMap[ filterItem.getName() ] );
+               // Set conflicts for items
+               $.each( filterConflictResult, function ( filterName, conflicts ) {
+                       var filterItem = model.getItemByName( filterName );
+                       // set conflicts for items in the group
+                       filterItem.setConflicts( conflicts );
+               } );
 
-                       // set conflicts for item
-                       if ( filterConflictResult[ filterItem.getName() ] ) {
-                               filterItem.setConflicts( filterConflictResult[ filterItem.getName() ] );
+               // Create a map between known parameters and their models
+               $.each( this.groups, function ( group, groupModel ) {
+                       if ( groupModel.getType() === 'send_unselected_if_any' ) {
+                               // Individual filters
+                               groupModel.getItems().forEach( function ( filterItem ) {
+                                       model.parameterMap[ filterItem.getParamName() ] = filterItem;
+                               } );
+                       } else if ( groupModel.getType() === 'string_options' ) {
+                               // Group
+                               model.parameterMap[ groupModel.getName() ] = groupModel;
                        }
                } );
 
-               // Add items to the model
-               this.addItems( items );
+               this.currentView = 'default';
 
+               // Finish initialization
                this.emit( 'initialize' );
        };
 
                return this.groups;
        };
 
+       /**
+        * Get the object that defines groups that match a certain view by their name.
+        *
+        * @param {string} [view] Requested view. If not given, uses current view
+        * @return {Object} Filter groups matching a display group
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.getFilterGroupsByView = function ( view ) {
+               var result = {};
+
+               view = view || this.getCurrentView();
+
+               $.each( this.groups, function ( groupName, groupModel ) {
+                       if ( groupModel.getView() === view ) {
+                               result[ groupName ] = groupModel;
+                       }
+               } );
+
+               return result;
+       };
+
+       /**
+        * Get an array of filters matching the given display group.
+        *
+        * @param {string} [view] Requested view. If not given, uses current view
+        * @return {mw.rcfilters.dm.FilterItem} Filter items matching the group
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersByView = function ( view ) {
+               var groups,
+                       result = [];
+
+               view = view || this.getCurrentView();
+
+               groups = this.getFilterGroupsByView( view );
+
+               $.each( groups, function ( groupName, groupModel ) {
+                       result = result.concat( groupModel.getItems() );
+               } );
+
+               return result;
+       };
+
+       /**
+        * Get the trigger for the requested view.
+        *
+        * @param {string} view View name
+        * @return {string} View trigger, if exists
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.getViewTrigger = function ( view ) {
+               return this.views[ view ] && this.views[ view ].trigger;
+       };
        /**
         * Get the value of a specific parameter
         *
        };
 
        /**
-        * Get the default parameters object
+        * Get an object representing default parameters state
         *
         * @return {Object} Default parameter values
         */
        mw.rcfilters.dm.FiltersViewModel.prototype.getDefaultParams = function () {
-               return this.defaultParams;
-       };
+               var result = {};
 
-       /**
-        * Set all filter states to default values
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.setFiltersToDefaults = function () {
-               var defaultFilterStates = this.getFiltersFromParameters( this.getDefaultParams() );
+               // Get default filter state
+               $.each( this.groups, function ( name, model ) {
+                       $.extend( true, result, model.getDefaultParams() );
+               } );
 
-               this.toggleFiltersSelected( defaultFilterStates );
+               return result;
        };
 
        /**
         * Analyze the groups and their filters and output an object representing
         * the state of the parameters they represent.
         *
-        * @param {Object} [filterGroups] An object defining the filter groups to
-        *  translate to parameters. Its structure must follow that of this.groups
-        *  see #getFilterGroups
+        * @param {Object} [filterDefinition] An object defining the filter values,
+        *  keyed by filter names.
         * @return {Object} Parameter state object
         */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getParametersFromFilters = function ( filterGroups ) {
-               var i, filterItems, anySelected, values,
+       mw.rcfilters.dm.FiltersViewModel.prototype.getParametersFromFilters = function ( filterDefinition ) {
+               var groupItemDefinition,
                        result = {},
-                       groupItems = filterGroups || this.getFilterGroups();
+                       groupItems = this.getFilterGroups();
+
+               if ( filterDefinition ) {
+                       groupItemDefinition = {};
+                       // Filter definition is "flat", but in effect
+                       // each group needs to tell us its result based
+                       // on the values in it. We need to split this list
+                       // back into groupings so we can "feed" it to the
+                       // loop below, and we need to expand it so it includes
+                       // all filters (set to false)
+                       this.getItems().forEach( function ( filterItem ) {
+                               groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {};
+                               groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = !!filterDefinition[ filterItem.getName() ];
+                       } );
+               }
 
                $.each( groupItems, function ( group, model ) {
-                       filterItems = model.getItems();
-
-                       if ( model.getType() === 'send_unselected_if_any' ) {
-                               // First, check if any of the items are selected at all.
-                               // If none is selected, we're treating it as if they are
-                               // all false
-                               anySelected = filterItems.some( function ( filterItem ) {
-                                       return filterItem.isSelected();
-                               } );
+                       $.extend(
+                               result,
+                               model.getParamRepresentation(
+                                       groupItemDefinition ?
+                                               groupItemDefinition[ group ] : null
+                               )
+                       );
+               } );
 
-                               // Go over the items and define the correct values
-                               for ( i = 0; i < filterItems.length; i++ ) {
-                                       result[ filterItems[ i ].getName() ] = anySelected ?
-                                               Number( !filterItems[ i ].isSelected() ) : 0;
-                               }
-                       } else if ( model.getType() === 'string_options' ) {
-                               values = [];
-                               for ( i = 0; i < filterItems.length; i++ ) {
-                                       if ( filterItems[ i ].isSelected() ) {
-                                               values.push( filterItems[ i ].getName() );
-                                       }
-                               }
+               return result;
+       };
 
-                               if ( values.length === filterItems.length ) {
-                                       result[ group ] = 'all';
-                               } else {
-                                       result[ group ] = values.join( model.getSeparator() );
-                               }
+       /**
+        * This is the opposite of the #getParametersFromFilters method; this goes over
+        * the given parameters and translates into a selected/unselected value in the filters.
+        *
+        * @param {Object} params Parameters query object
+        * @return {Object} Filter state object
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
+               var groupMap = {},
+                       model = this,
+                       result = {};
+
+               // Go over the given parameters, break apart to groupings
+               // The resulting object represents the group with its parameter
+               // values. For example:
+               // {
+               //    group1: {
+               //       param1: "1",
+               //       param2: "0",
+               //       param3: "1"
+               //    },
+               //    group2: "param4|param5"
+               // }
+               $.each( params, function ( paramName, paramValue ) {
+                       var itemOrGroup = model.parameterMap[ paramName ];
+
+                       if ( itemOrGroup instanceof mw.rcfilters.dm.FilterItem ) {
+                               groupMap[ itemOrGroup.getGroupName() ] = groupMap[ itemOrGroup.getGroupName() ] || {};
+                               groupMap[ itemOrGroup.getGroupName() ][ itemOrGroup.getParamName() ] = paramValue;
+                       } else if ( itemOrGroup instanceof mw.rcfilters.dm.FilterGroup ) {
+                               // This parameter represents a group (values are the filters)
+                               // this is equivalent to checking if the group is 'string_options'
+                               groupMap[ itemOrGroup.getName() ] = groupMap[ itemOrGroup.getName() ] || {};
+                               groupMap[ itemOrGroup.getName() ] = paramValue;
                        }
                } );
 
+               // Go over all groups, so we make sure we get the complete output
+               // even if the parameters don't include a certain group
+               $.each( this.groups, function ( groupName, groupModel ) {
+                       result = $.extend( true, {}, result, groupModel.getFilterRepresentation( groupMap[ groupName ] ) );
+               } );
+
                return result;
        };
 
        /**
         * Get the highlight parameters based on current filter configuration
         *
-        * @return {object} Object where keys are "<filter name>_color" and values
+        * @return {Object} Object where keys are "<filter name>_color" and values
         *                  are the selected highlight colors.
         */
        mw.rcfilters.dm.FiltersViewModel.prototype.getHighlightParameters = function () {
-               var result = { highlight: Number( this.isHighlightEnabled() ) };
+               var result = {};
+
+               this.getItems().forEach( function ( filterItem ) {
+                       result[ filterItem.getName() + '_color' ] = filterItem.getHighlightColor() || null;
+               } );
+               result.highlight = String( Number( this.isHighlightEnabled() ) );
+
+               return result;
+       };
+
+       /**
+        * Extract the highlight values from given object. Since highlights are
+        * the same for filter and parameters, it doesn't matter which one is
+        * given; values will be returned with a full list of the highlights
+        * with colors or null values.
+        *
+        * @param {Object} representation Object containing representation of
+        *  some or all highlight values
+        * @return {Object} Object where keys are "<filter name>_color" and values
+        *                  are the selected highlight colors. The returned object
+        *                  contains all available filters either with a color value
+        *                  or with null.
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.extractHighlightValues = function ( representation ) {
+               var result = {};
 
                this.getItems().forEach( function ( filterItem ) {
-                       result[ filterItem.getName() + '_color' ] = filterItem.getHighlightColor();
+                       var highlightName = filterItem.getName() + '_color';
+                       result[ highlightName ] = representation[ highlightName ] || null;
                } );
+
                return result;
        };
 
         * @param {string[]} valueArray Array of values
         * @return {string[]} Array of valid values
         */
-       mw.rcfilters.dm.FiltersViewModel.prototype.sanitizeStringOptionGroup = function( groupName, valueArray ) {
-               var result = [],
-                       validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
-                               return filterItem.getName();
-                       } );
-
-               if ( valueArray.indexOf( 'all' ) > -1 ) {
-                       // If anywhere in the values there's 'all', we
-                       // treat it as if only 'all' was selected.
-                       // Example: param=valid1,valid2,all
-                       // Result: param=all
-                       return [ 'all' ];
-               }
-
-               // Get rid of any dupe and invalid parameter, only output
-               // valid ones
-               // Example: param=valid1,valid2,invalid1,valid1
-               // Result: param=valid1,valid2
-               valueArray.forEach( function ( value ) {
-                       if (
-                               validNames.indexOf( value ) > -1 &&
-                               result.indexOf( value ) === -1
-                       ) {
-                               result.push( value );
-                       }
+       mw.rcfilters.dm.FiltersViewModel.prototype.sanitizeStringOptionGroup = function ( groupName, valueArray ) {
+               var validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
+                       return filterItem.getParamName();
                } );
 
-               return result;
+               return mw.rcfilters.utils.normalizeParamOptions( valueArray, validNames );
        };
 
        /**
                if ( this.defaultFiltersEmpty !== null ) {
                        // We only need to do this test once,
                        // because defaults are set once per session
-                       defaultFilters = this.getFiltersFromParameters();
+                       defaultFilters = this.getFiltersFromParameters( this.getDefaultParams() );
                        this.defaultFiltersEmpty = Object.keys( defaultFilters ).every( function ( filterName ) {
                                return !defaultFilters[ filterName ];
                        } );
                return this.defaultFiltersEmpty;
        };
 
-       /**
-        * This is the opposite of the #getParametersFromFilters method; this goes over
-        * the given parameters and translates into a selected/unselected value in the filters.
-        *
-        * @param {Object} params Parameters query object
-        * @return {Object} Filter state object
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
-               var i, filterItem,
-                       groupMap = {},
-                       model = this,
-                       base = this.getDefaultParams(),
-                       result = {};
-
-               params = $.extend( {}, base, params );
-
-               $.each( params, function ( paramName, paramValue ) {
-                       // Find the filter item
-                       filterItem = model.getItemByName( paramName );
-                       // Ignore if no filter item exists
-                       if ( filterItem ) {
-                               groupMap[ filterItem.getGroupName() ] = groupMap[ filterItem.getGroupName() ] || {};
-
-                               // Mark the group if it has any items that are selected
-                               groupMap[ filterItem.getGroupName() ].hasSelected = (
-                                       groupMap[ filterItem.getGroupName() ].hasSelected ||
-                                       !!Number( paramValue )
-                               );
-
-                               // Add the relevant filter into the group map
-                               groupMap[ filterItem.getGroupName() ].filters = groupMap[ filterItem.getGroupName() ].filters || [];
-                               groupMap[ filterItem.getGroupName() ].filters.push( filterItem );
-                       } else if ( model.groups.hasOwnProperty( paramName ) ) {
-                               // This parameter represents a group (values are the filters)
-                               // this is equivalent to checking if the group is 'string_options'
-                               groupMap[ paramName ] = { filters: model.groups[ paramName ].getItems() };
-                       }
-               } );
-
-               // Now that we know the groups' selection states, we need to go over
-               // the filters in the groups and mark their selected states appropriately
-               $.each( groupMap, function ( group, data ) {
-                       var paramValues, filterItem,
-                               allItemsInGroup = data.filters;
-
-                       if ( model.groups[ group ].getType() === 'send_unselected_if_any' ) {
-                               for ( i = 0; i < allItemsInGroup.length; i++ ) {
-                                       filterItem = allItemsInGroup[ i ];
-
-                                       result[ filterItem.getName() ] = data.hasSelected ?
-                                               // Flip the definition between the parameter
-                                               // state and the filter state
-                                               // This is what the 'toggleSelected' value of the filter is
-                                               !Number( params[ filterItem.getName() ] ) :
-                                               // Otherwise, there are no selected items in the
-                                               // group, which means the state is false
-                                               false;
-                               }
-                       } else if ( model.groups[ group ].getType() === 'string_options' ) {
-                               paramValues = model.sanitizeStringOptionGroup( group, params[ group ].split( model.groups[ group ].getSeparator() ) );
-
-                               for ( i = 0; i < allItemsInGroup.length; i++ ) {
-                                       filterItem = allItemsInGroup[ i ];
-
-                                       result[ filterItem.getName() ] = (
-                                                       // If it is the word 'all'
-                                                       paramValues.length === 1 && paramValues[ 0 ] === 'all' ||
-                                                       // All values are written
-                                                       paramValues.length === model.groups[ group ].getItemCount()
-                                               ) ?
-                                               // All true (either because all values are written or the term 'all' is written)
-                                               // is the same as all filters set to false
-                                               false :
-                                               // Otherwise, the filter is selected only if it appears in the parameter values
-                                               paramValues.indexOf( filterItem.getName() ) > -1;
-                               }
-                       }
-               } );
-               return result;
-       };
-
        /**
         * Get the item that matches the given name
         *
         * @param {boolean} [isSelected] Filter selected state
         */
        mw.rcfilters.dm.FiltersViewModel.prototype.toggleFilterSelected = function ( name, isSelected ) {
-               this.getItemByName( name ).toggleSelected( isSelected );
+               var item = this.getItemByName( name );
+
+               if ( item ) {
+                       item.toggleSelected( isSelected );
+               }
        };
 
        /**
         * Find items whose labels match the given string
         *
         * @param {string} query Search string
+        * @param {boolean} [returnFlat] Return a flat array. If false, the result
+        *  is an object whose keys are the group names and values are an array of
+        *  filters per group. If set to true, returns an array of filters regardless
+        *  of their groups.
         * @return {Object} An object of items to show
         *  arranged by their group names
         */
-       mw.rcfilters.dm.FiltersViewModel.prototype.findMatches = function ( query ) {
-               var i,
+       mw.rcfilters.dm.FiltersViewModel.prototype.findMatches = function ( query, returnFlat ) {
+               var i, searchIsEmpty,
                        groupTitle,
                        result = {},
-                       items = this.getItems();
+                       flatResult = [],
+                       view = this.getViewByTrigger( query.substr( 0, 1 ) ),
+                       items = this.getFiltersByView( view );
 
-               // Normalize so we can search strings regardless of case
+               // Normalize so we can search strings regardless of case and view
                query = query.toLowerCase();
+               if ( view !== 'default' ) {
+                       query = query.substr( 1 );
+               }
+
+               // Check if the search if actually empty; this can be a problem when
+               // we use prefixes to denote different views
+               searchIsEmpty = query.length === 0;
 
                // item label starting with the query string
                for ( i = 0; i < items.length; i++ ) {
-                       if ( items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ) {
+                       if (
+                               searchIsEmpty ||
+                               items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ||
+                               (
+                                       // For tags, we want the parameter name to be included in the search
+                                       view === 'tags' &&
+                                       items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
+                               )
+                       ) {
                                result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
                                result[ items[ i ].getGroupName() ].push( items[ i ] );
+                               flatResult.push( items[ i ] );
                        }
                }
 
                        for ( i = 0; i < items.length; i++ ) {
                                groupTitle = items[ i ].getGroupModel().getTitle();
                                if (
+                                       searchIsEmpty ||
                                        items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
                                        items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
-                                       groupTitle.toLowerCase().indexOf( query ) > -1
+                                       groupTitle.toLowerCase().indexOf( query ) > -1 ||
+                                       (
+                                               // For tags, we want the parameter name to be included in the search
+                                               view === 'tags' &&
+                                               items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
+                                       )
                                ) {
                                        result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
                                        result[ items[ i ].getGroupName() ].push( items[ i ] );
+                                       flatResult.push( items[ i ] );
                                }
                        }
                }
 
-               return result;
+               return returnFlat ? flatResult : result;
        };
 
        /**
                } );
        };
 
+       /**
+        * Get items that allow highlights even if they're not currently highlighted
+        *
+        * @return {mw.rcfilters.dm.FilterItem[]} Items supporting highlights
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.getItemsSupportingHighlights = function () {
+               return this.getItems().filter( function ( filterItem ) {
+                       return filterItem.isHighlightSupported();
+               } );
+       };
+
+       /**
+        * Switch the current view
+        *
+        * @param {string} view View name
+        * @fires update
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.switchView = function ( view ) {
+               if ( this.views[ view ] && this.currentView !== view ) {
+                       this.currentView = view;
+                       this.emit( 'update' );
+               }
+       };
+
+       /**
+        * Get the current view
+        *
+        * @return {string} Current view
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentView = function () {
+               return this.currentView;
+       };
+
+       /**
+        * Get the label for the current view
+        *
+        * @return {string} Label for the current view
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentViewLabel = function () {
+               return this.views[ this.getCurrentView() ].title;
+       };
+
+       /**
+        * Get an array of all available view names
+        *
+        * @return {string} Available view names
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.getAvailableViews = function () {
+               return Object.keys( this.views );
+       };
+
+       /**
+        * Get the view that fits the given trigger
+        *
+        * @param {string} trigger Trigger
+        * @return {string} Name of view
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.getViewByTrigger = function ( trigger ) {
+               var result = 'default';
+
+               $.each( this.views, function ( name, data ) {
+                       if ( data.trigger === trigger ) {
+                               result = name;
+                       }
+               } );
+
+               return result;
+       };
+
        /**
         * Toggle the highlight feature on and off.
         * Propagate the change to filter items.
                return !!this.highlightEnabled;
        };
 
+       /**
+        * Toggle the inverted namespaces property on and off.
+        * Propagate the change to namespace filter items.
+        *
+        * @param {boolean} enable Inverted property is enabled
+        * @fires invertChange
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.toggleInvertedNamespaces = function ( enable ) {
+               enable = enable === undefined ? !this.invertedNamespaces : enable;
+
+               if ( this.invertedNamespaces !== enable ) {
+                       this.invertedNamespaces = enable;
+
+                       this.getFiltersByView( 'namespaces' ).forEach( function ( filterItem ) {
+                               filterItem.toggleInverted( this.invertedNamespaces );
+                       }.bind( this ) );
+
+                       this.emit( 'invertChange', this.invertedNamespaces );
+               }
+       };
+
+       /**
+        * Check if the namespaces selection is set to be inverted
+        * @return {boolean}
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.areNamespacesInverted = function () {
+               return !!this.invertedNamespaces;
+       };
+
        /**
         * Set highlight color for a specific filter item
         *