RCFilters: Add 'views' concept and a namespace view to RCFilters
authorMoriel Schottlender <moriel@gmail.com>
Thu, 11 May 2017 00:28:26 +0000 (17:28 -0700)
committerMoriel Schottlender <moriel@gmail.com>
Wed, 14 Jun 2017 19:57:13 +0000 (12:57 -0700)
Enhanced RCFilters: Add the ability to filter by namespaces to RCFilters.
🎉 🎁 🎊

- Add the ability to separate groups of filters by 'views'
- Add the first views as 'default' (for predefined filters)
  and 'namespace' as the list of namespaces.
- Add 'nsinvert' to namespace group
- Allow highlighting namespaces
- Allow searching on either view, depending on prefix
- Add a way to switch views by typing prefix, clicking the
  'Namespaces' button or clicking a tag (either namespace
  or filter tag, changes the view accordingly, and adds
  or removes the prefix from the input to stay consistent)
- Add an optional wrapper text for tags, so we can represent
  them with their respective prefixes and (if needed) with
  a special message for inverted state.
- Add unit tests and make pass
- Bonus: Fix issue with URL not updating (and not being updated)
  the inverted and highlight enabled states.

Bug: T159942
Bug: T163521
Bug: T164130
Change-Id: I7e83f0800cbeb289dfd3461c1c5a197c053147ca

24 files changed:
includes/DefaultSettings.php
includes/changes/ChangesList.php
includes/specials/SpecialRecentchanges.php
languages/i18n/en.json
languages/i18n/qqq.json
resources/Resources.php
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ItemModel.js
resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js
resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js
resources/src/mediawiki.rcfilters/mw.rcfilters.init.js
resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuHeaderWidget.less
resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less
resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ItemMenuOptionWidget.less
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuHeaderWidget.js
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ItemMenuOptionWidget.js
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MenuSelectWidget.js
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.TagItemWidget.js
tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js
tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js

index 9436aa6..191787e 100644 (file)
@@ -6767,6 +6767,12 @@ $wgUseRCPatrol = true;
  */
 $wgStructuredChangeFiltersEnableSaving = true;
 
+/**
+ * Whether to show the new experimental views (like namespaces, tags, and users) in
+ * RecentChanges filters
+ */
+$wgStructuredChangeFiltersEnableExperimentalViews = false;
+
 /**
  * Use new page patrolling to check new pages on Special:Newpages
  */
index 00d842f..5aa693d 100644 (file)
@@ -177,6 +177,8 @@ class ChangesList extends ContextSource {
                } else {
                        $classes[] = Sanitizer::escapeClass( self::CSS_CLASS_PREFIX . 'ns' .
                                $rc->mAttribs['rc_namespace'] . '-' . $rc->mAttribs['rc_title'] );
+                       $classes[] = Sanitizer::escapeClass( self::CSS_CLASS_PREFIX . 'ns-' .
+                               $rc->mAttribs['rc_namespace'] );
                }
 
                // Indicate watched status on the line to allow for more
index acfc1c0..cbf2e37 100644 (file)
@@ -138,7 +138,8 @@ class SpecialRecentChanges extends ChangesListSpecialPage {
         * @param string $subpage
         */
        public function execute( $subpage ) {
-               global $wgStructuredChangeFiltersEnableSaving;
+               global $wgStructuredChangeFiltersEnableSaving,
+                       $wgStructuredChangeFiltersEnableExperimentalViews;
 
                // Backwards-compatibility: redirect to new feed URLs
                $feedFormat = $this->getRequest()->getVal( 'feed' );
@@ -184,6 +185,10 @@ class SpecialRecentChanges extends ChangesListSpecialPage {
                                'wgStructuredChangeFiltersEnableSaving',
                                $wgStructuredChangeFiltersEnableSaving
                        );
+                       $out->addJsConfigVars(
+                               'wgStructuredChangeFiltersEnableExperimentalViews',
+                               $wgStructuredChangeFiltersEnableExperimentalViews
+                       );
                }
        }
 
index 9f34b7b..bcb9f2d 100644 (file)
        "rcfilters-filter-lastrevision-description": "The most recent change to a page.",
        "rcfilters-filter-previousrevision-label": "Earlier revisions",
        "rcfilters-filter-previousrevision-description": "All changes that are not the most recent change to a page.",
+       "rcfilters-filter-excluded": "Excluded",
+       "rcfilters-tag-prefix-namespace": ":$1",
+       "rcfilters-tag-prefix-namespace-inverted": "<strong>:not</strong> $1",
        "rcnotefrom": "Below {{PLURAL:$5|is the change|are the changes}} since <strong>$3, $4</strong> (up to <strong>$1</strong> shown).",
        "rclistfromreset": "Reset date selection",
        "rclistfrom": "Show new changes starting from $2, $3",
index 6dfd73f..1aeb6a3 100644 (file)
        "rcfilters-filter-lastrevision-description": "Description for the filter for showing changes on last revision of a page.",
        "rcfilters-filter-previousrevision-label": "Title for the filter for showing changes on previous revisions of a page.",
        "rcfilters-filter-previousrevision-description": "Description for the filter for showing changes on previous revisions of a page.",
+       "rcfilters-filter-excluded": "Label for a menu item in [[Special:RecentChanges]] noting that the item is being excluded from the results.",
+       "rcfilters-tag-prefix-namespace": "Prefix for the namespace tags in [[Special:RecentChanges]]. Namespace tags use a colon (:) as prefix. Please keep this format.\n\nParameters:\n* $1 - Filter name.",
+       "rcfilters-tag-prefix-namespace-inverted": "Prefix for the namespace inverted tags in [[Special:RecentChanges]]. Namespace tags use a colon (:) as prefix. Please keep this format.\n\nParameters:\n* $1 - Filter name.",
        "rcnotefrom": "This message is displayed at [[Special:RecentChanges]] when viewing recentchanges from some specific time.\n\nThe corresponding message is {{msg-mw|Rclistfrom}}.\n\nParameters:\n* $1 - the maximum number of changes that are displayed\n* $2 - (Optional) a date and time\n* $3 - a date\n* $4 - a time\n* $5 - Number of changes are displayed, for use with PLURAL",
        "rclistfromreset": "Used on [[Special:RecentChanges]] to reset a selection of a certain date range.",
        "rclistfrom": "Used on [[Special:RecentChanges]]. Parameters:\n* $1 - (Currently not use) date and time. The date and the time adds to the rclistfrom description.\n* $2 - time. The time adds to the rclistfrom link description (with split of date and time).\n* $3 - date. The date adds to the rclistfrom link description (with split of date and time).\n\nThe corresponding message is {{msg-mw|Rcnotefrom}}.",
index cc52438..87a62cd 100644 (file)
@@ -1837,6 +1837,12 @@ return [
                        'rcfilters-noresults-conflict',
                        'rcfilters-state-message-subset',
                        'rcfilters-state-message-fullcoverage',
+                       'rcfilters-filter-excluded',
+                       'rcfilters-tag-prefix-namespace',
+                       'rcfilters-tag-prefix-namespace-inverted',
+                       'blanknamespace',
+                       'namespaces',
+                       'invert',
                        'recentchanges-noresult',
                        'quotation-marks',
                ],
@@ -1850,6 +1856,7 @@ return [
                        'oojs-ui.styles.icons-editing-core',
                        'oojs-ui.styles.icons-editing-styling',
                        'oojs-ui.styles.icons-interactions',
+                       'oojs-ui.styles.icons-content',
                ],
        ],
        'mediawiki.special' => [
index dd698cd..bec40b4 100644 (file)
@@ -9,11 +9,16 @@
         * @param {string} name Group name
         * @param {Object} [config] Configuration options
         * @cfg {string} [type='send_unselected_if_any'] Group type
+        * @cfg {string} [view='default'] Name of the display group this group
+        *  is a part of.
         * @cfg {string} [title] Group title
         * @cfg {string} [separator='|'] Value separator for 'string_options' groups
         * @cfg {boolean} [active] Group is active
         * @cfg {boolean} [fullCoverage] This filters in this group collectively cover all results
         * @cfg {Object} [conflicts] Defines the conflicts for this filter group
+        * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
+        *  group. If the prefix has 'invert' state, the parameter is expected to be an object
+        *  with 'default' and 'inverted' as keys.
         * @cfg {Object} [whatsThis] Defines the messages that should appear for the 'what's this' popup
         * @cfg {string} [whatsThis.header] The header of the whatsThis popup message
         * @cfg {string} [whatsThis.body] The body of the whatsThis popup message
 
                this.name = name;
                this.type = config.type || 'send_unselected_if_any';
+               this.view = config.view || 'default';
                this.title = config.title;
                this.separator = config.separator || '|';
+               this.labelPrefixKey = config.labelPrefixKey;
 
                this.active = !!config.active;
                this.fullCoverage = !!config.fullCoverage;
                        var subsetNames = [],
                                filterItem = new mw.rcfilters.dm.FilterItem( filter.name, model, {
                                        group: model.getName(),
-                                       label: filter.label ? mw.msg( filter.label ) : filter.name,
-                                       description: filter.description ? mw.msg( filter.description ) : '',
-                                       cssClass: filter.cssClass
+                                       label: filter.label || filter.name,
+                                       description: filter.description || '',
+                                       labelPrefixKey: model.labelPrefixKey,
+                                       cssClass: filter.cssClass,
+                                       identifiers: filter.identifiers
                                } );
 
                        filter.subset = filter.subset || [];
                return this.type;
        };
 
+       /**
+        * Get display group
+        *
+        * @return {string} Display group
+        */
+       mw.rcfilters.dm.FilterGroup.prototype.getView = function () {
+               return this.view;
+       };
+
        /**
         * Get the prefix used for the filter names inside this group.
         *
index 3c2f8d7..53a1170 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' } );
                this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
         * 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 */
 
        /**
         * the definition given by an object
         *
         * @param {Array} filters Filter group definition
+        * @param {Object} [namespaces] Namespace definition
         */
-       mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filters ) {
+       mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filters, namespaces ) {
                var filterItem, filterConflictResult, groupConflictResult,
                        model = this,
                        items = [],
+                       namespaceDefinition = [],
                        groupConflictMap = {},
                        filterConflictMap = {},
                        /*!
                // Reset
                this.clearItems();
                this.groups = {};
+               this.views = {};
 
+               // Filters
+               this.views.default = { name: 'default', label: mw.msg( 'rcfilters-filterlist-title' ) };
                filters.forEach( function ( data ) {
                        var i,
                                group = data.name;
                        if ( !model.groups[ group ] ) {
                                model.groups[ group ] = new mw.rcfilters.dm.FilterGroup( group, {
                                        type: data.type,
-                                       title: mw.msg( data.title ),
+                                       title: data.title ? mw.msg( data.title ) : group,
                                        separator: data.separator,
                                        fullCoverage: !!data.fullCoverage,
                                        whatsThis: {
                                        }
                                } );
                        }
+
+                       // 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 ) : '';
+                       }
+
                        model.groups[ group ].initializeFilters( data.filters, data.default );
                        items = items.concat( model.groups[ group ].getItems() );
 
                        }
                } );
 
+               namespaces = namespaces || {};
+               if (
+                       mw.config.get( 'wgStructuredChangeFiltersEnableExperimentalViews' ) &&
+                       !$.isEmptyObject( namespaces )
+               ) {
+                       // Namespaces group
+                       this.views.namespaces = { name: 'namespaces', label: mw.msg( 'namespaces' ), trigger: ':' };
+                       $.each( namespaces, function ( namespaceID, label ) {
+                               // Build and clean up the definition
+                               namespaceDefinition.push( {
+                                       name: namespaceID,
+                                       label: label || mw.msg( 'blanknamespace' ),
+                                       description: '',
+                                       identifiers: [
+                                               ( namespaceID < 0 || namespaceID % 2 === 0 ) ?
+                                                       'subject' : 'talk'
+                                       ],
+                                       cssClass: 'mw-changeslist-ns-' + namespaceID
+                               } );
+                       } );
+
+                       // Add the group
+                       model.groups.namespace = new mw.rcfilters.dm.FilterGroup(
+                               'namespace', // Parameter name is singular
+                               {
+                                       type: 'string_options',
+                                       view: 'namespaces',
+                                       title: 'namespaces', // Message key
+                                       separator: ';',
+                                       labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
+                                       fullCoverage: true
+                               }
+                       );
+                       // Add namespace items to group
+                       model.groups.namespace.initializeFilters( namespaceDefinition );
+                       items = items.concat( model.groups.namespace.getItems() );
+               }
+
                // Add item references to the model, for lookup
                this.addItems( items );
-
                // Expand conflicts
                groupConflictResult = expandConflictDefinitions( groupConflictMap );
                filterConflictResult = expandConflictDefinitions( filterConflictMap );
                        }
                } );
 
+               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 default filter state
                $.each( this.groups, function ( name, model ) {
-                       result = $.extend( true, {}, result, model.getDefaultParams() );
+                       $.extend( true, result, model.getDefaultParams() );
                } );
 
-               // Get default highlight state
-               result = $.extend( true, {}, result, this.getHighlightParameters() );
-
                return result;
        };
 
         *  arranged by their group names
         */
        mw.rcfilters.dm.FiltersViewModel.prototype.findMatches = function ( query, returnFlat ) {
-               var i,
+               var i, searchIsEmpty,
                        groupTitle,
                        result = {},
                        flatResult = [],
-                       items = this.getItems();
+                       view = query.indexOf( this.getViewTrigger( 'namespaces' ) ) === 0 ? 'namespaces' : 'default',
+                       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 === 'namespaces' ) {
+                       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
+                       ) {
                                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
                } );
        };
 
+       /**
+        * 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() ].label;
+       };
+
        /**
         * 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
         *
index 675fcc7..aa82e21 100644 (file)
@@ -9,6 +9,9 @@
         * @param {Object} config Configuration object
         * @cfg {string} [label] The label for the filter
         * @cfg {string} [description] The description of the filter
+        * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
+        *  group. If the prefix has 'invert' state, the parameter is expected to be an object
+        *  with 'default' and 'inverted' as keys.
         * @cfg {boolean} [active=true] The filter is active and affecting the result
         * @cfg {boolean} [selected] The item is selected
         * @cfg {boolean} [inverted] The item is inverted, meaning the search is excluding
@@ -16,6 +19,8 @@
         * @cfg {string} [namePrefix='item_'] A prefix to add to the param name to act as a unique
         *  identifier
         * @cfg {string} [cssClass] The class identifying the results that match this filter
+        * @cfg {string[]} [identifiers] An array of identifiers for this item. They will be
+        *  added and considered in the view.
         */
        mw.rcfilters.dm.ItemModel = function MwRcfiltersDmItemModel( param, config ) {
                config = config || {};
                this.name = this.namePrefix + param;
 
                this.label = config.label || this.name;
-               this.description = config.description;
+               this.labelPrefixKey = config.labelPrefixKey;
+               this.description = config.description || '';
                this.selected = !!config.selected;
 
                this.inverted = !!config.inverted;
+               this.identifiers = config.identifiers || [];
 
                // Highlight
                this.cssClass = config.cssClass;
                return this.name;
        };
 
+       /**
+        * Get a prefixed label
+        *
+        * @return {string} Prefixed label
+        */
+       mw.rcfilters.dm.ItemModel.prototype.getPrefixedLabel = function () {
+               if ( this.labelPrefixKey ) {
+                       if ( typeof this.labelPrefixKey === 'string' ) {
+                               return mw.message( this.labelPrefixKey, this.getLabel() ).parse();
+                       } else {
+                               return mw.message(
+                                       this.labelPrefixKey[
+                                               // Only use inverted-prefix if the item is selected
+                                               // Highlight-only an inverted item makes no sense
+                                               this.isInverted() && this.isSelected() ?
+                                                       'inverted' : 'default'
+                                       ],
+                                       this.getLabel()
+                               ).parse();
+                       }
+               } else {
+                       return this.getLabel();
+               }
+       };
+
        /**
         * Get the param name or value of this filter
         *
                return this.cssClass;
        };
 
+       /**
+        * Get the item's identifiers
+        *
+        * @return {string[]}
+        */
+       mw.rcfilters.dm.ItemModel.prototype.getIdentifiers = function () {
+               return this.identifiers;
+       };
+
        /**
         * Toggle the highlight feature on and off for this filter.
         * It only works if highlight is supported for this filter.
index c5672ae..5e430c3 100644 (file)
         * Initialize the filter and parameter states
         *
         * @param {Array} filterStructure Filter definition and structure for the model
+        * @param {Object} [namespaceStructure] Namespace definition
         */
-       mw.rcfilters.Controller.prototype.initialize = function ( filterStructure ) {
+       mw.rcfilters.Controller.prototype.initialize = function ( filterStructure, namespaceStructure ) {
                var parsedSavedQueries,
                        uri = new mw.Uri(),
                        $changesList = $( '.mw-changeslist' ).first().contents();
 
                // Initialize the model
-               this.filtersModel.initializeFilters( filterStructure );
-
+               this.filtersModel.initializeFilters( filterStructure, namespaceStructure );
                this._buildBaseFilterState();
+
                this.uriProcessor = new mw.rcfilters.UriProcessor(
                        this.filtersModel
                );
                                $( 'fieldset.rcoptions' ).first()
                        );
                }
+
                this.initializing = false;
+               this.switchView( 'default' );
+       };
+
+       /**
+        * Switch the view of the filters model
+        *
+        * @param {string} view Requested view
+        */
+       mw.rcfilters.Controller.prototype.switchView = function ( view ) {
+               this.filtersModel.switchView( view );
        };
 
        /**
                }
        };
 
+       /**
+        * Toggle the namespaces inverted feature on and off
+        */
+       mw.rcfilters.Controller.prototype.toggleInvertedNamespaces = function () {
+               this.filtersModel.toggleInvertedNamespaces();
+               this.updateChangesList();
+       };
+
        /**
         * Set the highlight color for a filter item
         *
                        label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ),
                        {
                                filters: this.filtersModel.getSelectedState(),
-                               highlights: highlightedItems
+                               highlights: highlightedItems,
+                               invert: this.filtersModel.areNamespacesInverted()
                        }
                );
 
                        // Update model state from filters
                        this.filtersModel.toggleFiltersSelected( data.filters );
 
+                       // Update namespace inverted property
+                       this.filtersModel.toggleInvertedNamespaces( !!Number( data.invert ) );
+
                        // Update highlight state
                        this.filtersModel.toggleHighlight( !!Number( highlights.highlight ) );
                        this.filtersModel.getItems().forEach( function ( filterItem ) {
                return this.savedQueriesModel.findMatchingQuery(
                        {
                                filters: this.filtersModel.getSelectedState(),
-                               highlights: highlightedItems
+                               highlights: highlightedItems,
+                               invert: this.filtersModel.areNamespacesInverted()
                        }
                );
        };
 
                this.baseFilterState = {
                        filters: this.filtersModel.getFiltersFromParameters( defaultParams ),
-                       highlights: highlightedItems
+                       highlights: highlightedItems,
+                       invert: false
                };
        };
 
                                }
                        } );
 
-                       return $.extend( true, {}, savedParams, savedHighlights );
+                       return $.extend( true, {}, savedParams, savedHighlights, { invert: data.invert } );
                }
 
                return $.extend(
index a691c11..b7852d0 100644 (file)
@@ -80,6 +80,8 @@
                        )
                );
 
+               this.filtersModel.toggleInvertedNamespaces( !!Number( parameters.invert ) );
+
                // Update highlight state
                this.filtersModel.toggleHighlight( !!Number( parameters.highlight ) );
                this.filtersModel.getItems().forEach( function ( filterItem ) {
                        {},
                        this.filtersModel.getParametersFromFilters(),
                        this.filtersModel.getHighlightParameters(),
-                       { highlight: String( Number( this.filtersModel.isHighlightEnabled() ) ) }
+                       {
+                               highlight: String( Number( this.filtersModel.isHighlightEnabled() ) ),
+                               invert: String( Number( this.filtersModel.areNamespacesInverted() ) )
+                       }
                );
        };
 
                        uriQuery,
                        this.filtersModel.getParametersFromFilters( filterRepresentation ),
                        this.filtersModel.extractHighlightValues( uriQuery ),
-                       { highlight: String( Number( uriQuery.highlight ) ) }
+                       {
+                               highlight: String( Number( uriQuery.highlight ) ),
+                               invert: String( Number( uriQuery.invert ) )
+                       }
                );
        };
 
                        {},
                        emptyParams,
                        emptyHighlights,
-                       { highlight: '0' }
+                       { highlight: '0', invert: '0' }
                );
        };
 }( mediaWiki, jQuery ) );
index 6e62436..03edca3 100644 (file)
@@ -24,7 +24,7 @@
                        new mw.rcfilters.ui.ChangesListWrapperWidget(
                                filtersModel, changesListModel, $( '.mw-changeslist, .mw-changeslist-empty' ) );
 
-                       controller.initialize( mw.config.get( 'wgStructuredChangeFilters' ) );
+                       controller.initialize( mw.config.get( 'wgStructuredChangeFilters' ), mw.config.get( 'wgFormattedNamespaces' ) );
 
                        // eslint-disable-next-line no-new
                        new mw.rcfilters.ui.FormWrapperWidget(
index 4914dd9..24907b9 100644 (file)
@@ -12,6 +12,7 @@
                border-bottom: 1px solid #c8ccd1;
                background: #f8f9fa;
 
+               &-invert,
                &-highlight {
                        width: 1em;
                        vertical-align: middle;
index 1029d54..00ec87c 100644 (file)
@@ -3,4 +3,8 @@
        width: 100%;
        // Make sure this uses the interface direction, not the content direction
        direction: ltr;
+
+       &-namespaceToggle {
+               margin-top: 1em;
+       }
 }
index 44c5529..86bfafb 100644 (file)
@@ -1,6 +1,7 @@
 @import 'mediawiki.mixins';
 
 .mw-rcfilters-ui-itemMenuOptionWidget {
+       min-height: 3.5em;
        padding: 0 0.5em;
        .box-sizing( border-box );
 
@@ -8,6 +9,15 @@
                border-bottom: solid 1px #eaecf0; // Base 80 AAA
        }
 
+       &-view-namespaces {
+               border-top: 5px solid #ccc;
+
+               &:first-child,
+               &.mw-rcfilters-ui-itemMenuOptionWidget-identifier-subject + &.mw-rcfilters-ui-itemMenuOptionWidget-identifier-talk {
+                       border-top: 0;
+               }
+       }
+
        &:hover {
                background-color: #fbfbfb;
        }
                }
        }
 
+       .mw-rcfilters-ui-cell {
+               vertical-align: middle;
+       }
+
+       &-excludeLabel {
+               width: 5em;
+               padding-left: 1em;
+               color: #54595d; // Base20 AAA
+       }
+
        &-highlightButton {
                width: 4em;
                padding-left: 1em;
index 15e7eee..b8b68a7 100644 (file)
                        classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-hightlightButton' ]
                } );
 
+               // Invert namespaces button
+               this.invertNamespacesButton = new OO.ui.ToggleButtonWidget( {
+                       icon: '',
+                       label: mw.msg( 'invert' ),
+                       classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-invertNamespacesButton' ]
+               } );
+               this.invertNamespacesButton.toggle( this.model.getCurrentView() === 'namespaces' );
+
                // Events
                this.highlightButton
                        .connect( this, { click: 'onHighlightButtonClick' } );
-               this.model.connect( this, { highlightChange: 'onModelHighlightChange' } );
+               this.invertNamespacesButton
+                       .connect( this, { click: 'onInvertNamespacesButtonClick' } );
+               this.model.connect( this, {
+                       highlightChange: 'onModelHighlightChange',
+                       invertChange: 'onModelInvertChange',
+                       update: 'onModelUpdate'
+               } );
 
                // Initialize
                this.$element
                                                                        .addClass( 'mw-rcfilters-ui-cell' )
                                                                        .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-title' )
                                                                        .append( this.$label ),
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-invert' )
+                                                                       .append( this.invertNamespacesButton.$element ),
                                                                $( '<div>' )
                                                                        .addClass( 'mw-rcfilters-ui-cell' )
                                                                        .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-highlight' )
 
        /* Methods */
 
+       /**
+        * Respond to model update event
+        */
+       mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.onModelUpdate = function () {
+               this.setLabel( this.model.getCurrentViewLabel() );
+
+               this.invertNamespacesButton.toggle( this.model.getCurrentView() === 'namespaces' );
+       };
+
        /**
         * Respond to model highlight change event
         *
                this.highlightButton.setActive( highlightEnabled );
        };
 
+       /**
+        * Respond to model invert change event
+        *
+        * @param {boolean} isInverted Namespaces selection is inverted
+        */
+       mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.onModelInvertChange = function ( isInverted ) {
+               this.invertNamespacesButton.setActive( isInverted );
+       };
+
        /**
         * Respond to highlight button click
         */
        mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.onHighlightButtonClick = function () {
                this.controller.toggleHighlight();
        };
+
+       /**
+        * Respond to highlight button click
+        */
+       mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.onInvertNamespacesButtonClick = function () {
+               this.controller.toggleInvertedNamespaces();
+       };
 }( mediaWiki, jQuery ) );
index e14c1fa..b1927c6 100644 (file)
                this.resetButton.$element.on( 'mousedown', function ( e ) { e.stopPropagation(); } );
                this.model.connect( this, {
                        initialize: 'onModelInitialize',
+                       update: 'onModelUpdate',
                        itemUpdate: 'onModelItemUpdate',
                        highlightChange: 'onModelHighlightChange'
                } );
+               this.input.connect( this, { change: 'onInputChange' } );
                this.queriesModel.connect( this, { itemUpdate: 'onSavedQueriesItemUpdate' } );
 
                // The filter list and button should appear side by side regardless of how
                this.$element
                        .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget' );
 
-               this.populateFromModel();
                this.reevaluateResetRestoreState();
        };
 
 
        /* Methods */
 
+       /**
+        * Respond to input change event
+        *
+        * @param {string} value Value of the input
+        */
+       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onInputChange = function ( value ) {
+               var view = 'default';
+
+               if ( value.indexOf( this.model.getViewTrigger( 'namespaces' ) ) === 0 ) {
+                       view = 'namespaces';
+               }
+
+               this.controller.switchView( view );
+       };
        /**
         * Respond to query button click
         */
                } else {
                        // Clear selection
                        this.selectTag( null );
+
+                       // Clear input if the only thing in the input is the prefix
+                       if (
+                               this.input.getValue() === this.model.getViewTrigger( this.model.getCurrentView() )
+                       ) {
+                               // Clear the input
+                               this.input.setValue( '' );
+                       }
                }
        };
 
         * Respond to model initialize event
         */
        mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelInitialize = function () {
-               this.populateFromModel();
-
                this.setSavedQueryVisibility();
        };
 
+       /**
+        * Respond to model update event
+        */
+       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelUpdate = function () {
+               this.updateElementsForView();
+       };
+
+       /**
+        * Update the elements in the widget to the current view
+        */
+       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.updateElementsForView = function () {
+               var view = this.model.getCurrentView(),
+                       inputValue = this.input.getValue(),
+                       newInputValue = inputValue;
+
+               switch ( view ) {
+                       case 'namespaces':
+                               if ( inputValue.indexOf( this.model.getViewTrigger( 'namespaces' ) ) !== 0 ) {
+                                       // Add the prefix to the input
+                                       newInputValue = this.model.getViewTrigger( 'namespaces' ) + inputValue;
+                               }
+                               break;
+                       default:
+                       case 'default':
+                               if ( inputValue.indexOf( this.model.getViewTrigger( 'namespaces' ) ) === 0 ) {
+                                       // Remove the prefix
+                                       newInputValue = inputValue.substr( 1 );
+                               }
+                               break;
+               }
+
+               // Update input
+               this.input.setValue( newInputValue );
+       };
+
        /**
         * Set the visibility of the saved query button
         */
                        );
                }
        };
+
        /**
         * Respond to model itemUpdate event
         *
         */
        mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.isAllowedData = function ( data ) {
                return (
-                       this.menu.getItemFromData( data ) &&
+                       this.model.getItemByName( data ) &&
                        !this.isDuplicateData( data )
                );
        };
         */
        mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onTagSelect = function ( tagItem ) {
                var widget = this,
-                       menuOption = this.menu.getItemFromData( tagItem.getData() ),
+                       menuOption = this.menu.getItemFromModel( tagItem.getModel() ),
                        oldInputValue = this.input.getValue();
 
                // Reset input
                this.input.setValue( '' );
 
+               // Switch view
+               this.controller.switchView( tagItem.getView() );
+
                // Parent method
                mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onTagSelect.call( this, tagItem );
 
                );
        };
 
-       /**
-        * Populate the menu from the model
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.populateFromModel = function () {
-               var widget = this,
-                       items = [];
-
-               // Reset
-               this.getMenu().clearItems();
-
-               $.each( this.model.getFilterGroups(), function ( groupName, groupModel ) {
-                       items.push(
-                               // Group section
-                               new mw.rcfilters.ui.FilterMenuSectionOptionWidget(
-                                       widget.controller,
-                                       groupModel,
-                                       {
-                                               $overlay: widget.$overlay
-                                       }
-                               )
-                       );
-
-                       // Add items
-                       widget.model.getGroupFilters( groupName ).forEach( function ( filterItem ) {
-                               items.push(
-                                       new mw.rcfilters.ui.FilterMenuOptionWidget(
-                                               widget.controller,
-                                               filterItem,
-                                               {
-                                                       $overlay: widget.$overlay
-                                               }
-                                       )
-                               );
-                       } );
-               } );
-
-               // Add all items to the menu
-               this.getMenu().addItems( items );
-       };
-
        /**
         * @inheritdoc
         */
index ebef62f..e007621 100644 (file)
                        { $overlay: this.$overlay }
                );
 
+               this.namespaceButton = new OO.ui.ButtonWidget( {
+                       label: mw.msg( 'namespaces' ),
+                       icon: 'article',
+                       classes: [ 'mw-rcfilters-ui-filterWrapperWidget-namespaceToggle' ]
+               } );
+               this.namespaceButton.setActive( this.model.getCurrentView() === 'namespaces' );
+
+               // Events
+               this.model.connect( this, { update: 'onModelUpdate' } );
+               this.namespaceButton.connect( this, { click: 'onNamespaceToggleClick' } );
+
                // Initialize
                this.$element
                        .addClass( 'mw-rcfilters-ui-filterWrapperWidget' );
                }
 
                this.$element.append(
-                       this.filterTagWidget.$element
+                       this.filterTagWidget.$element,
+                       this.namespaceButton.$element
                );
+               this.namespaceButton.toggle( !!mw.config.get( 'wgStructuredChangeFiltersEnableExperimentalViews' ) );
        };
 
        /* Initialization */
 
        OO.inheritClass( mw.rcfilters.ui.FilterWrapperWidget, OO.ui.Widget );
        OO.mixinClass( mw.rcfilters.ui.FilterWrapperWidget, OO.ui.mixin.PendingElement );
+
+       /* Methods */
+
+       /**
+        * Respond to model update event
+        */
+       mw.rcfilters.ui.FilterWrapperWidget.prototype.onModelUpdate = function () {
+               // Synchronize the state of the toggle button with the current view
+               this.namespaceButton.setActive( this.model.getCurrentView() === 'namespaces' );
+       };
+
+       /**
+        * Respond to namespace toggle button click
+        */
+       mw.rcfilters.ui.FilterWrapperWidget.prototype.onNamespaceToggleClick = function () {
+               this.controller.switchView( 'namespaces' );
+               this.filterTagWidget.focus();
+       };
 }( mediaWiki ) );
index d17ffff..477290e 100644 (file)
         */
        mw.rcfilters.ui.FormWrapperWidget.prototype.cleanUpFieldset = function () {
                var $namespaceSelect = this.$element.find( '#namespace' ),
-                       $namespaceCheckboxes = this.$element.find( '#nsassociated, #nsinvert' ),
                        collapseCookieName = 'changeslist-state';
 
                this.$element.find( '.rcshowhideoption[data-feature-in-structured-ui=1]' ).each( function () {
                        this.parentNode.removeChild( this );
                } );
 
-               // Bind namespace select to change event
-               // see resources/src/mediawiki.special/mediawiki.special.recentchanges.js
-               $namespaceCheckboxes.prop( 'disabled', $namespaceSelect.val() === '' );
-               $namespaceSelect.on( 'change', function () {
-                       $namespaceCheckboxes.prop( 'disabled', $( this ).val() === '' );
-               } );
+               // Hide namespaces
+               if ( mw.config.get( 'wgStructuredChangeFiltersEnableExperimentalViews' ) ) {
+                       $namespaceSelect.closest( 'tr' ).detach();
+               }
 
                // Collapse legend
                // see resources/src/mediawiki.special/mediawiki.special.changelist.legend.js
index a88d119..f2e9b1d 100644 (file)
@@ -11,6 +11,7 @@
         */
        mw.rcfilters.ui.ItemMenuOptionWidget = function MwRcfiltersUiItemMenuOptionWidget( controller, model, config ) {
                var layout,
+                       classes = [],
                        $label = $( '<div>' )
                                .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label' );
 
                );
                this.highlightButton.toggle( this.model.isHighlightEnabled() );
 
+               this.excludeLabel = new OO.ui.LabelWidget( {
+                       label: mw.msg( 'rcfilters-filter-excluded' )
+               } );
+               this.excludeLabel.toggle( this.model.isSelected() && this.model.isInverted() );
+
                layout = new OO.ui.FieldLayout( this.checkboxWidget, {
                        label: $label,
                        align: 'inline'
@@ -71,6 +77,7 @@
 
                this.$element
                        .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget' )
+                       .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-view-' + this.model.getGroupModel().getView() )
                        .append(
                                $( '<div>' )
                                        .addClass( 'mw-rcfilters-ui-table' )
                                                                $( '<div>' )
                                                                        .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-itemCheckbox' )
                                                                        .append( layout.$element ),
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-excludeLabel' )
+                                                                       .append( this.excludeLabel.$element ),
                                                                $( '<div>' )
                                                                        .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-highlightButton' )
                                                                        .append( this.highlightButton.$element )
                                                        )
                                        )
                        );
+
+               if ( this.model.getIdentifiers() ) {
+                       this.model.getIdentifiers().forEach( function ( ident ) {
+                               classes.push( 'mw-rcfilters-ui-itemMenuOptionWidget-identifier-' + ident );
+                       } );
+
+                       this.$element.addClass( classes.join( ' ' ) );
+               }
        };
 
        /* Initialization */
                this.checkboxWidget.setSelected( this.model.isSelected() );
 
                this.highlightButton.toggle( this.model.isHighlightEnabled() );
+               this.excludeLabel.toggle( this.model.isSelected() && this.model.isInverted() );
        };
 
        /**
index 91de969..d971faf 100644 (file)
@@ -18,6 +18,8 @@
 
                this.controller = controller;
                this.model = model;
+               this.currentView = '';
+               this.views = {};
 
                this.inputValue = '';
                this.$overlay = config.$overlay || this.$element;
                        classes: [ 'mw-rcfilters-ui-menuSelectWidget-noresults' ]
                } );
 
+               // Events
+               this.model.connect( this, {
+                       update: 'onModelUpdate',
+                       initialize: 'onModelInitialize'
+               } );
+
+               // Initialization
                this.$element
                        .addClass( 'mw-rcfilters-ui-menuSelectWidget' )
                        .append( header.$element )
@@ -64,6 +73,7 @@
                                        .addClass( 'mw-rcfilters-ui-menuSelectWidget-footer' )
                        );
                }
+               this.switchView( this.model.getCurrentView() );
        };
 
        /* Initialize */
 
        /* Methods */
 
+       /**
+        * Respond to model update event
+        */
+       mw.rcfilters.ui.MenuSelectWidget.prototype.onModelUpdate = function () {
+               // Change view
+               this.switchView( this.model.getCurrentView() );
+       };
+
+       /**
+        * Respond to model initialize event. Populate the menu from the model
+        */
+       mw.rcfilters.ui.MenuSelectWidget.prototype.onModelInitialize = function () {
+               var widget = this,
+                       viewGroupCount = {},
+                       groups = this.model.getFilterGroups();
+
+               // Reset
+               this.clearItems();
+
+               // Count groups per view
+               $.each( groups, function ( groupName, groupModel ) {
+                       viewGroupCount[ groupModel.getView() ] = viewGroupCount[ groupModel.getView() ] || 0;
+                       viewGroupCount[ groupModel.getView() ]++;
+               } );
+
+               $.each( groups, function ( groupName, groupModel ) {
+                       var currentItems = [],
+                               view = groupModel.getView();
+
+                       if ( viewGroupCount[ view ] > 1 ) {
+                               // Only add a section header if there is more than
+                               // one group
+                               currentItems.push(
+                                       // Group section
+                                       new mw.rcfilters.ui.FilterMenuSectionOptionWidget(
+                                               widget.controller,
+                                               groupModel,
+                                               {
+                                                       $overlay: widget.$overlay
+                                               }
+                                       )
+                               );
+                       }
+
+                       // Add items
+                       widget.model.getGroupFilters( groupName ).forEach( function ( filterItem ) {
+                               currentItems.push(
+                                       new mw.rcfilters.ui.FilterMenuOptionWidget(
+                                               widget.controller,
+                                               filterItem,
+                                               {
+                                                       $overlay: widget.$overlay
+                                               }
+                                       )
+                               );
+                       } );
+
+                       // Cache the items per view, so we can switch between them
+                       // without rebuilding the widgets each time
+                       widget.views[ view ] = widget.views[ view ] || [];
+                       widget.views[ view ] = widget.views[ view ].concat( currentItems );
+               } );
+
+               this.switchView( this.model.getCurrentView() );
+       };
+
+       /**
+        * Switch view
+        *
+        * @param {string} [viewName] View name. If not given, default is used.
+        */
+       mw.rcfilters.ui.MenuSelectWidget.prototype.switchView = function ( viewName ) {
+               viewName = viewName || 'default';
+
+               if ( this.views[ viewName ] && this.currentView !== viewName ) {
+                       this.clearItems();
+                       this.addItems( this.views[ viewName ] );
+
+                       this.$element
+                               .data( 'view', viewName )
+                               .removeClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + this.currentView )
+                               .addClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + viewName );
+
+                       this.currentView = viewName;
+               }
+       };
+
        /**
         * @fires itemVisibilityChange
         * @inheritdoc
                }
        };
 
+       /**
+        * Get the option widget that matches the model given
+        *
+        * @param {mw.rcfilters.dm.ItemModel} model Item model
+        * @return {mw.rcfilters.ui.ItemMenuOptionWidget} Option widget
+        */
+       mw.rcfilters.ui.MenuSelectWidget.prototype.getItemFromModel = function ( model ) {
+               return this.views[ model.getGroupModel().getView() ].filter( function ( item ) {
+                       return item.getName() === model.getName();
+               } )[ 0 ];
+       };
+
        /**
         * Override the item matcher to use the model's match process
         *
index 637dbdc..886f6d4 100644 (file)
@@ -22,7 +22,7 @@
 
                mw.rcfilters.ui.TagItemWidget.parent.call( this, $.extend( {
                        data: this.model.getName(),
-                       label: this.model.getLabel()
+                       label: $( '<div>' ).html( this.model.getPrefixedLabel() ).contents()
                }, config ) );
 
                this.$overlay = config.$overlay || this.$element;
@@ -78,6 +78,9 @@
        mw.rcfilters.ui.TagItemWidget.prototype.onModelUpdate = function () {
                this.setCurrentMuteState();
 
+               // Update label if needed
+               this.setLabel( $( '<div>' ).html( this.model.getPrefixedLabel() ).contents() );
+
                this.setHighlightColor();
        };
 
                return this.model.getName();
        };
 
+       /**
+        * Get item model
+        *
+        * @return {string} Filter model
+        */
+       mw.rcfilters.ui.TagItemWidget.prototype.getModel = function () {
+               return this.model;
+       };
+
+       /**
+        * Get item view
+        *
+        * @return {string} Filter view
+        */
+       mw.rcfilters.ui.TagItemWidget.prototype.getView = function () {
+               return this.model.getGroupModel().getView();
+       };
+
        /**
         * Remove and destroy external elements of this widget
         */
index 38ade4d..edaaa39 100644 (file)
@@ -59,6 +59,7 @@
                                filter4: '0',
                                group3: '',
                                highlight: '0',
+                               invert: '0',
                                group1__filter1_color: null,
                                group1__filter2_color: null,
                                group2__filter3_color: null,
                        } ),
                        'Highlight parameters in Uri query set highlight state in the model'
                );
+
+               uriProcessor.updateModelBasedOnQuery( { invert: '1', urlversion: '2' } );
+               assert.deepEqual(
+                       uriProcessor.getUriParametersFromModel(),
+                       $.extend( true, {}, baseParams, {
+                               invert: '1'
+                       } ),
+                       'Invert parameter in Uri query set invert state in the model'
+               );
        } );
 
        QUnit.test( 'isNewState', function ( assert ) {
index 714739b..233ec76 100644 (file)
@@ -10,6 +10,9 @@
                        'group2filter1-desc': 'Description of Filter 1 in Group 2',
                        'group2filter2-label': 'xGroup 2: Filter 2',
                        'group2filter2-desc': 'Description of Filter 2 in Group 2'
+               },
+               config: {
+                       wgStructuredChangeFiltersEnableExperimentalViews: true
                }
        } ) );
 
                                        }
                                ]
                        } ],
+                       namespaces = {
+                               0: 'Main',
+                               1: 'Talk',
+                               2: 'User',
+                               3: 'User talk'
+                       },
                        model = new mw.rcfilters.dm.FiltersViewModel();
 
-               model.initializeFilters( definition );
+               model.initializeFilters( definition, namespaces );
 
                assert.ok(
                        model.getItemByName( 'group1__filter1' ) instanceof mw.rcfilters.dm.FilterItem &&
                        model.getItemByName( 'group2__filter2' ) instanceof mw.rcfilters.dm.FilterItem &&
                        model.getItemByName( 'group3__filter1' ) instanceof mw.rcfilters.dm.FilterItem &&
                        model.getItemByName( 'group3__filter2' ) instanceof mw.rcfilters.dm.FilterItem,
+                       model.getItemByName( 'namespace__0' ) instanceof mw.rcfilters.dm.FilterItem,
+                       model.getItemByName( 'namespace__1' ) instanceof mw.rcfilters.dm.FilterItem,
+                       model.getItemByName( 'namespace__2' ) instanceof mw.rcfilters.dm.FilterItem,
+                       model.getItemByName( 'namespace__3' ) instanceof mw.rcfilters.dm.FilterItem,
                        'Filters instantiated and stored correctly'
                );
 
                                group2__filter1: false,
                                group2__filter2: false,
                                group3__filter1: false,
-                               group3__filter2: false
+                               group3__filter2: false,
+                               namespace__0: false,
+                               namespace__1: false,
+                               namespace__2: false,
+                               namespace__3: false
                        },
                        'Initial state of filters'
                );
                                group2__filter1: false,
                                group2__filter2: true,
                                group3__filter1: true,
-                               group3__filter2: false
+                               group3__filter2: false,
+                               namespace__0: false,
+                               namespace__1: false,
+                               namespace__2: false,
+                               namespace__3: false
                        },
                        'Updating filter states correctly'
                );
                assert.deepEqual(
                        model.getDefaultParams(),
                        {
-                               group1__hidefilter1_color: null,
-                               group1__hidefilter2_color: null,
-                               group1__hidefilter3_color: null,
-                               group2__hidefilter4_color: null,
-                               group2__hidefilter5_color: null,
-                               group2__hidefilter6_color: null,
-                               group3__filter7_color: null,
-                               group3__filter8_color: null,
-                               group3__filter9_color: null,
-                               highlight: '0',
                                hidefilter1: '1',
                                hidefilter2: '0',
                                hidefilter3: '1',
                                        }
                                ]
                        } ],
+                       namespaces = {
+                               0: 'Main',
+                               1: 'Talk',
+                               2: 'User',
+                               3: 'User talk'
+                       },
                        testCases = [
                                {
                                        query: 'group',
                                                group2: [ 'group2__filter1', 'group2__filter2' ]
                                        },
                                        reason: 'Finds filters containing the query string in their group title'
+                               },
+                               {
+                                       query: ':Main',
+                                       expectedMatches: {
+                                               namespace: [ 'namespace__0' ]
+                                       },
+                                       reason: 'Finds namespaces when using : prefix'
+                               },
+                               {
+                                       query: ':group',
+                                       expectedMatches: {},
+                                       reason: 'Finds no results if using namespaces prefix (:) to search for filter title'
                                }
                        ],
                        model = new mw.rcfilters.dm.FiltersViewModel(),
                                return result;
                        };
 
-               model.initializeFilters( definition );
+               model.initializeFilters( definition, namespaces );
 
                testCases.forEach( function ( testCase ) {
                        matches = model.findMatches( testCase.query );