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
*/
$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
*/
} 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
* @param string $subpage
*/
public function execute( $subpage ) {
- global $wgStructuredChangeFiltersEnableSaving;
+ global $wgStructuredChangeFiltersEnableSaving,
+ $wgStructuredChangeFiltersEnableExperimentalViews;
// Backwards-compatibility: redirect to new feed URLs
$feedFormat = $this->getRequest()->getVal( 'feed' );
'wgStructuredChangeFiltersEnableSaving',
$wgStructuredChangeFiltersEnableSaving
);
+ $out->addJsConfigVars(
+ 'wgStructuredChangeFiltersEnableExperimentalViews',
+ $wgStructuredChangeFiltersEnableExperimentalViews
+ );
}
}
"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",
"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}}.",
'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',
],
'oojs-ui.styles.icons-editing-core',
'oojs-ui.styles.icons-editing-styling',
'oojs-ui.styles.icons-interactions',
+ 'oojs-ui.styles.icons-content',
],
],
'mediawiki.special' => [
* @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.
*
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
*
* @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
* @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.
* 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(
)
);
+ 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 ) );
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(
border-bottom: 1px solid #c8ccd1;
background: #f8f9fa;
+ &-invert,
&-highlight {
width: 1em;
vertical-align: middle;
width: 100%;
// Make sure this uses the interface direction, not the content direction
direction: ltr;
+
+ &-namespaceToggle {
+ margin-top: 1em;
+ }
}
@import 'mediawiki.mixins';
.mw-rcfilters-ui-itemMenuOptionWidget {
+ min-height: 3.5em;
padding: 0 0.5em;
.box-sizing( border-box );
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;
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 ) );
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
*/
{ $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 ) );
*/
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
*/
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'
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() );
};
/**
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 )
.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
*
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;
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
*/
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 ) {
'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 );