From: Moriel Schottlender Date: Thu, 11 May 2017 00:28:26 +0000 (-0700) Subject: RCFilters: Add 'views' concept and a namespace view to RCFilters X-Git-Tag: 1.31.0-rc.0~2964^2~1 X-Git-Url: http://git.cyclocoop.org/%22%20.%20generer_url_ecrire%28%22auteur_infos%22%2C%20%22id_auteur=%24id%22%29%20.%20%22?a=commitdiff_plain;h=51ce88abb96a76f92a587163618da55997a63301;p=lhc%2Fweb%2Fwiklou.git RCFilters: Add 'views' concept and a namespace view to RCFilters 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 --- diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index 9436aa66a8..191787ea9e 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -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 */ diff --git a/includes/changes/ChangesList.php b/includes/changes/ChangesList.php index 00d842f4cb..5aa693ddd9 100644 --- a/includes/changes/ChangesList.php +++ b/includes/changes/ChangesList.php @@ -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 diff --git a/includes/specials/SpecialRecentchanges.php b/includes/specials/SpecialRecentchanges.php index acfc1c0e7b..cbf2e370a0 100644 --- a/includes/specials/SpecialRecentchanges.php +++ b/includes/specials/SpecialRecentchanges.php @@ -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 + ); } } diff --git a/languages/i18n/en.json b/languages/i18n/en.json index 9f34b7be23..bcb9f2df92 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -1435,6 +1435,9 @@ "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": ":not $1", "rcnotefrom": "Below {{PLURAL:$5|is the change|are the changes}} since $3, $4 (up to $1 shown).", "rclistfromreset": "Reset date selection", "rclistfrom": "Show new changes starting from $2, $3", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index 6dfd73fbd8..1aeb6a31db 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -1625,6 +1625,9 @@ "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}}.", diff --git a/resources/Resources.php b/resources/Resources.php index cc524383d9..87a62cd4fb 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -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' => [ diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js index dd698cd231..bec40b4d99 100644 --- a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js @@ -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 @@ -29,8 +34,10 @@ 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; @@ -75,9 +82,11 @@ 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 || []; @@ -536,6 +545,15 @@ 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. * diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js index 3c2f8d7f67..53a11707bd 100644 --- a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js @@ -16,8 +16,12 @@ 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' ] } ); @@ -36,6 +40,12 @@ * Filter list is initialized */ + /** + * @event update + * + * Model has been updated + */ + /** * @event itemUpdate * @param {mw.rcfilters.dm.FilterItem} item Filter item updated @@ -50,6 +60,13 @@ * 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 */ /** @@ -190,11 +207,13 @@ * 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 = {}, /*! @@ -258,7 +277,10 @@ // 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; @@ -266,7 +288,7 @@ 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: { @@ -277,6 +299,14 @@ } } ); } + + // 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() ); @@ -295,9 +325,46 @@ } } ); + 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 ); @@ -327,6 +394,8 @@ } } ); + this.currentView = 'default'; + // Finish initialization this.emit( 'initialize' ); }; @@ -349,6 +418,56 @@ 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 * @@ -407,12 +526,9 @@ // 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; }; @@ -673,18 +789,29 @@ * 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 ] ); @@ -696,6 +823,7 @@ 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 @@ -733,6 +861,37 @@ } ); }; + /** + * 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. @@ -762,6 +921,35 @@ 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 * diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ItemModel.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ItemModel.js index 675fcc72ad..aa82e218f8 100644 --- a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ItemModel.js +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ItemModel.js @@ -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 || {}; @@ -28,10 +33,12 @@ 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; @@ -75,6 +82,31 @@ 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 * @@ -207,6 +239,15 @@ 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. diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js index c5672ae499..5e430c3ea9 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js @@ -24,16 +24,17 @@ * 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 ); @@ -85,7 +86,18 @@ $( '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 ); }; /** @@ -175,6 +187,14 @@ } }; + /** + * 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 * @@ -220,7 +240,8 @@ label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ), { filters: this.filtersModel.getSelectedState(), - highlights: highlightedItems + highlights: highlightedItems, + invert: this.filtersModel.areNamespacesInverted() } ); @@ -291,6 +312,9 @@ // 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 ) { @@ -327,7 +351,8 @@ return this.savedQueriesModel.findMatchingQuery( { filters: this.filtersModel.getSelectedState(), - highlights: highlightedItems + highlights: highlightedItems, + invert: this.filtersModel.areNamespacesInverted() } ); }; @@ -370,7 +395,8 @@ this.baseFilterState = { filters: this.filtersModel.getFiltersFromParameters( defaultParams ), - highlights: highlightedItems + highlights: highlightedItems, + invert: false }; }; @@ -527,7 +553,7 @@ } } ); - return $.extend( true, {}, savedParams, savedHighlights ); + return $.extend( true, {}, savedParams, savedHighlights, { invert: data.invert } ); } return $.extend( diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js index a691c11f59..b7852d04b7 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js @@ -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 ) { @@ -106,7 +108,10 @@ {}, this.filtersModel.getParametersFromFilters(), this.filtersModel.getHighlightParameters(), - { highlight: String( Number( this.filtersModel.isHighlightEnabled() ) ) } + { + highlight: String( Number( this.filtersModel.isHighlightEnabled() ) ), + invert: String( Number( this.filtersModel.areNamespacesInverted() ) ) + } ); }; @@ -125,7 +130,10 @@ uriQuery, this.filtersModel.getParametersFromFilters( filterRepresentation ), this.filtersModel.extractHighlightValues( uriQuery ), - { highlight: String( Number( uriQuery.highlight ) ) } + { + highlight: String( Number( uriQuery.highlight ) ), + invert: String( Number( uriQuery.invert ) ) + } ); }; @@ -261,7 +269,7 @@ {}, emptyParams, emptyHighlights, - { highlight: '0' } + { highlight: '0', invert: '0' } ); }; }( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js index 6e62436267..03edca30cf 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js @@ -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( diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuHeaderWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuHeaderWidget.less index 4914dd9740..24907b9eb4 100644 --- a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuHeaderWidget.less +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuHeaderWidget.less @@ -12,6 +12,7 @@ border-bottom: 1px solid #c8ccd1; background: #f8f9fa; + &-invert, &-highlight { width: 1em; vertical-align: middle; diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less index 1029d54f9e..00ec87c822 100644 --- a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less @@ -3,4 +3,8 @@ width: 100%; // Make sure this uses the interface direction, not the content direction direction: ltr; + + &-namespaceToggle { + margin-top: 1em; + } } diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ItemMenuOptionWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ItemMenuOptionWidget.less index 44c5529636..86bfafb34a 100644 --- a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ItemMenuOptionWidget.less +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ItemMenuOptionWidget.less @@ -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; } @@ -44,6 +54,16 @@ } } + .mw-rcfilters-ui-cell { + vertical-align: middle; + } + + &-excludeLabel { + width: 5em; + padding-left: 1em; + color: #54595d; // Base20 AAA + } + &-highlightButton { width: 4em; padding-left: 1em; diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuHeaderWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuHeaderWidget.js index 15e7eee765..b8b68a73cb 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuHeaderWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuHeaderWidget.js @@ -32,10 +32,24 @@ 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 @@ -52,6 +66,10 @@ .addClass( 'mw-rcfilters-ui-cell' ) .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-title' ) .append( this.$label ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-invert' ) + .append( this.invertNamespacesButton.$element ), $( '
' ) .addClass( 'mw-rcfilters-ui-cell' ) .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-highlight' ) @@ -68,6 +86,15 @@ /* 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 * @@ -77,10 +104,26 @@ 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 ) ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js index e14c1fa9e5..b1927c62a0 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js @@ -98,9 +98,11 @@ 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 @@ -150,7 +152,6 @@ this.$element .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget' ); - this.populateFromModel(); this.reevaluateResetRestoreState(); }; @@ -160,6 +161,20 @@ /* 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 */ @@ -201,6 +216,14 @@ } 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( '' ); + } } }; @@ -240,11 +263,44 @@ * 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 */ @@ -262,6 +318,7 @@ ); } }; + /** * Respond to model itemUpdate event * @@ -292,7 +349,7 @@ */ mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.isAllowedData = function ( data ) { return ( - this.menu.getItemFromData( data ) && + this.model.getItemByName( data ) && !this.isDuplicateData( data ) ); }; @@ -337,12 +394,15 @@ */ 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 ); @@ -442,46 +502,6 @@ ); }; - /** - * 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 */ diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js index ebef62fb53..e0076213ab 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js @@ -33,6 +33,17 @@ { $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' ); @@ -51,12 +62,32 @@ } 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 ) ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js index d17fffffe6..477290ee2d 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js @@ -114,7 +114,6 @@ */ 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 () { @@ -130,12 +129,10 @@ 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 diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ItemMenuOptionWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ItemMenuOptionWidget.js index a88d119fa8..f2e9b1db35 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ItemMenuOptionWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ItemMenuOptionWidget.js @@ -11,6 +11,7 @@ */ mw.rcfilters.ui.ItemMenuOptionWidget = function MwRcfiltersUiItemMenuOptionWidget( controller, model, config ) { var layout, + classes = [], $label = $( '
' ) .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label' ); @@ -55,6 +56,11 @@ ); 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( $( '
' ) .addClass( 'mw-rcfilters-ui-table' ) @@ -81,12 +88,23 @@ $( '
' ) .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-itemCheckbox' ) .append( layout.$element ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-excludeLabel' ) + .append( this.excludeLabel.$element ), $( '
' ) .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 */ @@ -107,6 +125,7 @@ this.checkboxWidget.setSelected( this.model.isSelected() ); this.highlightButton.toggle( this.model.isHighlightEnabled() ); + this.excludeLabel.toggle( this.model.isSelected() && this.model.isInverted() ); }; /** diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MenuSelectWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MenuSelectWidget.js index 91de969404..d971faf56c 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MenuSelectWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MenuSelectWidget.js @@ -18,6 +18,8 @@ this.controller = controller; this.model = model; + this.currentView = ''; + this.views = {}; this.inputValue = ''; this.$overlay = config.$overlay || this.$element; @@ -50,6 +52,13 @@ 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 */ @@ -80,6 +90,93 @@ /* 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 @@ -119,6 +216,18 @@ } }; + /** + * 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 * diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.TagItemWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.TagItemWidget.js index 637dbdce16..886f6d4c80 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.TagItemWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.TagItemWidget.js @@ -22,7 +22,7 @@ mw.rcfilters.ui.TagItemWidget.parent.call( this, $.extend( { data: this.model.getName(), - label: this.model.getLabel() + label: $( '
' ).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( $( '
' ).html( this.model.getPrefixedLabel() ).contents() ); + this.setHighlightColor(); }; @@ -169,6 +172,24 @@ 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 */ diff --git a/tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js b/tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js index 38ade4ddcd..edaaa39f6e 100644 --- a/tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js +++ b/tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js @@ -59,6 +59,7 @@ filter4: '0', group3: '', highlight: '0', + invert: '0', group1__filter1_color: null, group1__filter2_color: null, group2__filter3_color: null, @@ -100,6 +101,15 @@ } ), '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 ) { diff --git a/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js b/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js index 714739b680..233ec761ee 100644 --- a/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js +++ b/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js @@ -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 } } ) ); @@ -63,9 +66,15 @@ } ] } ], + 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 && @@ -74,6 +83,10 @@ 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' ); @@ -85,7 +98,11 @@ 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' ); @@ -103,7 +120,11 @@ 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' ); @@ -188,16 +209,6 @@ 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', @@ -245,6 +256,12 @@ } ] } ], + namespaces = { + 0: 'Main', + 1: 'Talk', + 2: 'User', + 3: 'User talk' + }, testCases = [ { query: 'group', @@ -269,6 +286,18 @@ 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(), @@ -282,7 +311,7 @@ return result; }; - model.initializeFilters( definition ); + model.initializeFilters( definition, namespaces ); testCases.forEach( function ( testCase ) { matches = model.findMatches( testCase.query );