'previewonfirst' => 0,
'previewontop' => 1,
'rcdays' => 7,
+ 'rcenhancedfilters' => 0,
'rclimit' => 50,
'rows' => 25,
'showhiddencats' => 0,
/**
* An array of preferences to not show for the user
*/
-$wgHiddenPrefs = [];
+$wgHiddenPrefs = [
+ 'rcenhancedfilters',
+];
/**
* Characters to prevent during new account creations.
parent::addModules();
$out = $this->getOutput();
$out->addModules( 'mediawiki.special.recentchanges' );
+ if ( $this->getUser()->getOption(
+ 'rcenhancedfilters',
+ /*default=*/ null,
+ /*ignoreHidden=*/ true
+ )
+ ) {
+ $out->addModules( 'mediawiki.rcfilters.filters' );
+ }
}
/**
"recentchanges-legend-unpatrolled": "{{int:recentchanges-label-unpatrolled}}",
"recentchanges-legend-plusminus": "(<em>±123</em>)",
"recentchanges-submit": "Show",
+ "rcfilters-activefilters": "Active filters",
+ "rcfilters-search-placeholder": "Filter recent changes (browse or start typing)",
+ "rcfilters-invalid-filter": "Invalid filter",
+ "rcfilters-filterlist-title": "Filters",
+ "rcfilters-filterlist-noresults": "No filters found",
+ "rcfilters-filtergroup-authorship": "Edit authorship",
+ "rcfilters-filter-editsbyself-label": "Your own edits",
+ "rcfilters-filter-editsbyself-description": "Edits by you.",
+ "rcfilters-filter-editsbyother-label": "Edits by others",
+ "rcfilters-filter-editsbyother-description": "Edits created by other users (not you.)",
"rcnotefrom": "Below {{PLURAL:$5|is the change|are the changes}} since <strong>$3, $4</strong> (up to <strong>$1</strong> shown).",
"rclistfrom": "Show new changes starting from $2, $3",
"rcshowhideminor": "$1 minor edits",
"recentchanges-legend-unpatrolled": "Used as legend on [[Special:RecentChanges]] and [[Special:Watchlist]].\n\nRefers to {{msg-mw|Recentchanges-label-unpatrolled}}.",
"recentchanges-legend-plusminus": "{{optional}}\nA plus/minus sign with a number for the legend.",
"recentchanges-submit": "Label for submit button in [[Special:RecentChanges]]\n{{Identical|Show}}",
+ "rcfilters-activefilters": "Title for the filters selection showing the active filters.",
+ "rcfilters-search-placeholder": "Placeholder for the filter search input.",
+ "rcfilters-invalid-filter": "A label for an ivalid filter.",
+ "rcfilters-filterlist-title": "Title for the filters list.",
+ "rcfilters-filterlist-noresults": "Message showing no results found for searching a filter.",
+ "rcfilters-filtergroup-authorship": "Title for the filter group for edit authorship.",
+ "rcfilters-filter-editsbyself-label": "Label for the filter for showing edits made by the current user.",
+ "rcfilters-filter-editsbyself-description": "Description for the filter for showing edits made by the current user.",
+ "rcfilters-filter-editsbyother-label": "Label for the filter for showing edits made by anyone other than the current user.",
+ "rcfilters-filter-editsbyother-description": "Description for the filter for showing edits made by anyone other than the current user.",
"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",
"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}}.",
"rcshowhideminor": "Option text in [[Special:RecentChanges]]. Parameters:\n* $1 - the \"show/hide\" command, with the text taken from either {{msg-mw|rcshowhideminor-show}} or {{msg-mw|rcshowhideminor-hide}}\n{{Identical|Minor edit}}",
/* MediaWiki Special pages */
+ 'mediawiki.rcfilters.filters' => [
+ 'scripts' => [
+ 'resources/src/mediawiki.rcfilters/mw.rcfilters.js',
+ 'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js',
+ 'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js',
+ 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FiltersListWidget.js',
+ 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterGroupWidget.js',
+ 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemWidget.js',
+ 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.js',
+ 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js',
+ 'resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js',
+ 'resources/src/mediawiki.rcfilters/mw.rcfilters.init.js',
+ ],
+ 'styles' => [
+ 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.less',
+ 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemWidget.less',
+ 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterGroupWidget.less',
+ 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FiltersListWidget.less',
+ 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less',
+ 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.less',
+ ],
+ 'messages' => [
+ 'rcfilters-activefilters',
+ 'rcfilters-search-placeholder',
+ 'rcfilters-invalid-filter',
+ 'rcfilters-filterlist-title',
+ 'rcfilters-filterlist-noresults',
+ 'rcfilters-filtergroup-authorship',
+ 'rcfilters-filter-editsbyself-label',
+ 'rcfilters-filter-editsbyself-description',
+ 'rcfilters-filter-editsbyother-label',
+ 'rcfilters-filter-editsbyother-description',
+ ],
+ 'dependencies' => [
+ 'oojs-ui',
+ 'mediawiki.Uri',
+ ],
+ ],
'mediawiki.special' => [
'styles' => 'resources/src/mediawiki.special/mediawiki.special.css',
'targets' => [ 'desktop', 'mobile' ],
--- /dev/null
+( function ( mw ) {
+ /**
+ * Filter item model
+ *
+ * @mixins OO.EventEmitter
+ *
+ * @constructor
+ * @param {string} name Filter name
+ * @param {Object} config Configuration object
+ * @cfg {string} [group] The group this item belongs to
+ * @cfg {string} [label] The label for the filter
+ * @cfg {string} [description] The description of the filter
+ * @cfg {boolean} [selected] Filter is selected
+ */
+ mw.rcfilters.dm.FilterItem = function MwRcfiltersDmFilterItem( name, config ) {
+ config = config || {};
+
+ // Mixin constructor
+ OO.EventEmitter.call( this );
+
+ this.name = name;
+ this.group = config.group || '';
+ this.label = config.label || this.name;
+ this.description = config.description;
+
+ this.selected = !!config.selected;
+ };
+
+ /* Initialization */
+
+ OO.initClass( mw.rcfilters.dm.FilterItem );
+ OO.mixinClass( mw.rcfilters.dm.FilterItem, OO.EventEmitter );
+
+ /* Events */
+
+ /**
+ * @event update
+ *
+ * The state of this filter has changed
+ */
+
+ /* Methods */
+
+ /**
+ * Get the name of this filter
+ *
+ * @return {string} Filter name
+ */
+ mw.rcfilters.dm.FilterItem.prototype.getName = function () {
+ return this.name;
+ };
+
+ /**
+ * Get the group name this filter belongs to
+ *
+ * @return {string} Filter group name
+ */
+ mw.rcfilters.dm.FilterItem.prototype.getGroup = function () {
+ return this.group;
+ };
+
+ /**
+ * Get the label of this filter
+ *
+ * @return {string} Filter label
+ */
+ mw.rcfilters.dm.FilterItem.prototype.getLabel = function () {
+ return this.label;
+ };
+
+ /**
+ * Get the description of this filter
+ *
+ * @return {string} Filter description
+ */
+ mw.rcfilters.dm.FilterItem.prototype.getDescription = function () {
+ return this.description;
+ };
+
+ /**
+ * Get the selected state of this filter
+ *
+ * @return {boolean} Filter is selected
+ */
+ mw.rcfilters.dm.FilterItem.prototype.isSelected = function () {
+ return this.selected;
+ };
+
+ /**
+ * Toggle the selected state of the item
+ *
+ * @param {boolean} [isSelected] Filter is selected
+ * @fires update
+ */
+ mw.rcfilters.dm.FilterItem.prototype.toggleSelected = function ( isSelected ) {
+ isSelected = isSelected === undefined ? !this.selected : isSelected;
+
+ if ( this.selected !== isSelected ) {
+ this.selected = isSelected;
+ this.emit( 'update' );
+ }
+ };
+}( mediaWiki ) );
--- /dev/null
+( function ( mw, $ ) {
+ /**
+ * View model for the filters selection and display
+ *
+ * @mixins OO.EventEmitter
+ * @mixins OO.EmitterList
+ *
+ * @constructor
+ */
+ mw.rcfilters.dm.FiltersViewModel = function MwRcfiltersDmFiltersViewModel() {
+ // Mixin constructor
+ OO.EventEmitter.call( this );
+ OO.EmitterList.call( this );
+
+ this.groups = {};
+
+ // Events
+ this.aggregate( { update: 'itemUpdate' } );
+ };
+
+ /* Initialization */
+ OO.initClass( mw.rcfilters.dm.FiltersViewModel );
+ OO.mixinClass( mw.rcfilters.dm.FiltersViewModel, OO.EventEmitter );
+ OO.mixinClass( mw.rcfilters.dm.FiltersViewModel, OO.EmitterList );
+
+ /* Events */
+
+ /**
+ * @event initialize
+ *
+ * Filter list is initialized
+ */
+
+ /**
+ * @event itemUpdate
+ * @param {mw.rcfilters.dm.FilterItem} item Filter item updated
+ *
+ * Filter item has changed
+ */
+
+ /* Methods */
+
+ /**
+ * Set filters and preserve a group relationship based on
+ * the definition given by an object
+ *
+ * @param {Object} filters Filter group definition
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filters ) {
+ var i, filterItem,
+ model = this,
+ items = [];
+
+ // Reset
+ this.clearItems();
+ this.groups = {};
+
+ $.each( filters, function ( group, data ) {
+ model.groups[ group ] = model.groups[ group ] || {};
+ model.groups[ group ].filters = model.groups[ group ].filters || [];
+
+ model.groups[ group ].title = data.title;
+ model.groups[ group ].type = data.type;
+
+ for ( i = 0; i < data.filters.length; i++ ) {
+ filterItem = new mw.rcfilters.dm.FilterItem( data.filters[ i ].name, {
+ group: group,
+ label: data.filters[ i ].label,
+ description: data.filters[ i ].description,
+ selected: data.filters[ i ].selected
+ } );
+
+ model.groups[ group ].filters.push( filterItem );
+ items.push( filterItem );
+ }
+ } );
+
+ this.addItems( items );
+ this.emit( 'initialize' );
+ };
+
+ /**
+ * Get the names of all available filters
+ *
+ * @return {string[]} An array of filter names
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.getFilterNames = function () {
+ return this.getItems().map( function ( item ) { return item.getName(); } );
+ };
+
+ /**
+ * Get the object that defines groups and their filter items.
+ * The structure of this response:
+ * {
+ * groupName: {
+ * title: {string} Group title
+ * type: {string} Group type
+ * filters: {string[]} Filters in the group
+ * }
+ * }
+ *
+ * @return {Object} Filter groups
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.getFilterGroups = function () {
+ return this.groups;
+ };
+
+ /**
+ * Get the current state of the filters
+ *
+ * @return {Object} Filters current state
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.getState = function () {
+ var i,
+ items = this.getItems(),
+ result = {};
+
+ for ( i = 0; i < items.length; i++ ) {
+ result[ items[ i ].getName() ] = items[ i ].isSelected();
+ }
+
+ return result;
+ };
+
+ /**
+ * Analyze the groups and their filters and output an object representing
+ * the state of the parameters they represent.
+ *
+ * @return {Object} Parameter state object
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.getParametersFromFilters = function () {
+ var i, filterItems, anySelected,
+ result = {},
+ groupItems = this.getFilterGroups();
+
+ $.each( groupItems, function ( group, data ) {
+ if ( data.type === 'send_unselected_if_any' ) {
+ filterItems = data.filters;
+
+ // First, check if any of the items are selected at all.
+ // If none is selected, we're treating it as if they are
+ // all false
+ anySelected = filterItems.some( function ( filterItem ) {
+ return filterItem.isSelected();
+ } );
+
+ // Go over the items and define the correct values
+ for ( i = 0; i < filterItems.length; i++ ) {
+ result[ filterItems[ i ].getName() ] = anySelected ?
+ Number( !filterItems[ i ].isSelected() ) : 0;
+ }
+ }
+ } );
+
+ return result;
+ };
+
+ /**
+ * This is the opposite of the #getParametersFromFilters method; this goes over
+ * the parameters and translates into a selected/unselected value in the filters.
+ *
+ * @param {Object} params Parameters query object
+ * @return {Object} Filter state object
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
+ var i, filterItem, allItemsInGroup,
+ groupMap = {},
+ model = this,
+ base = this.getParametersFromFilters(),
+ // Start with current state
+ result = this.getState();
+
+ params = $.extend( {}, base, params );
+
+ $.each( params, function ( paramName, paramValue ) {
+ // Find the filter item
+ filterItem = model.getItemByName( paramName );
+
+ // Ignore if no filter item exists
+ if ( filterItem ) {
+ groupMap[ filterItem.getGroup() ] = groupMap[ filterItem.getGroup() ] || {};
+
+ // Mark the group if it has any items that are selected
+ groupMap[ filterItem.getGroup() ].hasSelected = (
+ groupMap[ filterItem.getGroup() ].hasSelected ||
+ !!Number( paramValue )
+ );
+
+ // Add the relevant filter into the group map
+ groupMap[ filterItem.getGroup() ].filters = groupMap[ filterItem.getGroup() ].filters || [];
+ groupMap[ filterItem.getGroup() ].filters.push( filterItem );
+ }
+ } );
+
+ // Now that we know the groups' selection states, we need to go over
+ // the filters in the groups and mark their selected states appropriately
+ $.each( groupMap, function ( group, data ) {
+ if ( model.groups[ group ].type === 'send_unselected_if_any' ) {
+ allItemsInGroup = model.groups[ group ].filters;
+
+ for ( i = 0; i < allItemsInGroup.length; i++ ) {
+ filterItem = allItemsInGroup[ i ];
+
+ result[ filterItem.getName() ] = data.hasSelected ?
+ // Flip the definition between the parameter
+ // state and the filter state
+ // This is what the 'toggleSelected' value of the filter is
+ !Number( params[ filterItem.getName() ] ) :
+ // Otherwise, there are no selected items in the
+ // group, which means the state is false
+ false;
+ }
+ }
+ } );
+ return result;
+ };
+
+ /**
+ * Get the item that matches the given name
+ *
+ * @param {string} name Filter name
+ * @return {mw.rcfilters.dm.FilterItem} Filter item
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.getItemByName = function ( name ) {
+ return this.getItems().filter( function ( item ) {
+ return name === item.getName();
+ } )[ 0 ];
+ };
+
+ /**
+ * Toggle selected state of items by their names
+ *
+ * @param {Object} filterDef Filter definitions
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.updateFilters = function ( filterDef ) {
+ var name, filterItem;
+
+ for ( name in filterDef ) {
+ filterItem = this.getItemByName( name );
+ filterItem.toggleSelected( filterDef[ name ] );
+ }
+ };
+
+ /**
+ * Find items whose labels match the given string
+ *
+ * @param {string} str Search string
+ * @return {Object} An object of items to show
+ * arranged by their group names
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.findMatches = function ( str ) {
+ var i,
+ result = {},
+ items = this.getItems();
+
+ // Normalize so we can search strings regardless of case
+ str = str.toLowerCase();
+ for ( i = 0; i < items.length; i++ ) {
+ if ( items[ i ].getLabel().toLowerCase().indexOf( str ) > -1 ) {
+ result[ items[ i ].getGroup() ] = result[ items[ i ].getGroup() ] || [];
+ result[ items[ i ].getGroup() ].push( items[ i ] );
+ }
+ }
+ return result;
+ };
+
+}( mediaWiki, jQuery ) );
--- /dev/null
+( function ( mw ) {
+ /**
+ * Controller for the filters in Recent Changes
+ *
+ * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+ */
+ mw.rcfilters.Controller = function MwRcfiltersController( model ) {
+ this.model = model;
+
+ // TODO: When we are ready, update the URL when a filter is updated
+ // this.model.connect( this, { itemUpdate: 'updateURL' } );
+ };
+
+ /* Initialization */
+ OO.initClass( mw.rcfilters.Controller );
+
+ /**
+ * Initialize the filter and parameter states
+ */
+ mw.rcfilters.Controller.prototype.initialize = function () {
+ var uri = new mw.Uri();
+
+ this.model.updateFilters(
+ // Translate the url params to filter select states
+ this.model.getFiltersFromParameters( uri.query )
+ );
+ };
+
+ /**
+ * Update the state of a filter
+ *
+ * @param {string} filterName Filter name
+ * @param {boolean} isSelected Filter selected state
+ */
+ mw.rcfilters.Controller.prototype.updateFilter = function ( filterName, isSelected ) {
+ var obj = {};
+
+ obj[ filterName ] = isSelected;
+ this.model.updateFilters( obj );
+ };
+
+ /**
+ * Update the URL of the page to reflect current filters
+ */
+ mw.rcfilters.Controller.prototype.updateURL = function () {
+ var uri = new mw.Uri();
+
+ // Add to existing queries in URL
+ // TODO: Clean up the list of filters; perhaps 'falsy' filters
+ // shouldn't appear at all? Or compare to existing query string
+ // and see if current state of a specific filter is needed?
+ uri.extend( this.model.getParametersFromFilters() );
+
+ // Update the URL itself
+ window.history.pushState( { tag: 'rcfilters' }, document.title, uri.toString() );
+ };
+}( mediaWiki ) );
--- /dev/null
+/*!
+ * JavaScript for Special:RecentChanges
+ */
+( function ( mw, $ ) {
+ /**
+ * @class mw.rcfilters
+ * @singleton
+ */
+ var rcfilters = {
+ /** */
+ init: function () {
+ var model = new mw.rcfilters.dm.FiltersViewModel(),
+ controller = new mw.rcfilters.Controller( model ),
+ widget = new mw.rcfilters.ui.FilterWrapperWidget( controller, model );
+
+ model.initializeFilters( {
+ authorship: {
+ title: mw.msg( 'rcfilters-filtergroup-authorship' ),
+ // Type 'send_unselected_if_any' means that the controller will go over
+ // all unselected filters in the group and use their parameters
+ // as truthy in the query string.
+ // This is to handle the "negative" filters. We are showing users
+ // a positive message ("Show xxx") but the filters themselves are
+ // based on "hide YYY". The purpose of this is to correctly map
+ // the functionality to the UI, whether we are dealing with 2
+ // parameters in the group or more.
+ type: 'send_unselected_if_any',
+ filters: [
+ {
+ name: 'hidemyself',
+ label: mw.msg( 'rcfilters-filter-editsbyself-label' ),
+ description: mw.msg( 'rcfilters-filter-editsbyself-description' )
+ },
+ {
+ name: 'hidebyothers',
+ label: mw.msg( 'rcfilters-filter-editsbyother-label' ),
+ description: mw.msg( 'rcfilters-filter-editsbyother-description' )
+ }
+ ]
+ }
+ } );
+
+ $( '.mw-specialpage-summary' ).after( widget.$element );
+
+ // Initialize values
+ controller.initialize();
+
+ $( '.rcoptions form' ).submit( function () {
+ var $form = $( this );
+
+ // Get current filter values
+ $.each( model.getParametersFromFilters(), function ( paramName, paramValue ) {
+ var $existingInput = $form.find( 'input[name=' + paramName + ']' );
+ // Check if the hidden input already exists
+ // This happens if the parameter was already given
+ // on load
+ if ( $existingInput.length ) {
+ // Update the value
+ $existingInput.val( paramValue );
+ } else {
+ // Append hidden fields with filter values
+ $form.append(
+ $( '<input>' )
+ .attr( 'type', 'hidden' )
+ .attr( 'name', paramName )
+ .val( paramValue )
+ );
+ }
+ } );
+
+ // Continue the submission process
+ return true;
+ } );
+ }
+ };
+
+ $( rcfilters.init );
+
+ module.exports = rcfilters;
+
+}( mediaWiki, jQuery ) );
--- /dev/null
+( function ( mw ) {
+ mw.rcfilters = { dm: {}, ui: {} };
+}( mediaWiki ) );
--- /dev/null
+.rcshowhidemine {
+ // HACK: Hide this filter since it already appears in
+ // the new filter drop-down.
+ display: none;
+}
--- /dev/null
+.mw-rcfilters-ui-filterCapsuleMultiselectWidget {
+ &-content-title {
+ font-weight: bold;
+ color: #54595d;
+ }
+
+ .oo-ui-capsuleItemWidget {
+ color: #222;
+ background-color: #fff;
+ }
+}
--- /dev/null
+.mw-rcfilters-ui-filterGroupWidget {
+ padding-bottom: 0.5em;
+
+ &-title {
+ // TODO: Unify colors with official design palette
+ background: #eaecf0;
+ padding: 0.5em 0.75em;
+ color: #555a5d;
+ }
+
+ &-invalid-notice {
+ padding: 0.5em;
+ font-style: italic;
+ display: none;
+
+ .mw-rcfilters-ui-filterGroupWidget-invalid & {
+ display: block;
+ }
+ }
+}
--- /dev/null
+.mw-rcfilters-ui-filterItemWidget {
+ padding-left: 0.5em;
+
+ &-label {
+ &-title {
+ font-weight: bold;
+ font-size: 1.2em;
+ color: #222;
+ }
+ &-desc {
+ color: #464a4f;
+ }
+ }
+
+ .oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline {
+ margin-bottom: 0 !important;
+ }
+}
--- /dev/null
+.mw-rcfilters-ui-filterWrapperWidget {
+ width: 100%;
+
+ .oo-ui-capsuleMultiselectWidget {
+ max-width: none;
+
+ &.oo-ui-widget-enabled .oo-ui-capsuleMultiselectWidget-handle {
+ // TODO: Unify colors with official design palette
+ background-color: #f8f9fa;
+ border: 1px solid #a2a9b1;
+ min-height: 5.5em;
+ padding: 0.75em;
+
+ }
+ }
+
+ &-popup {
+ // We have to override OOUI's definition, which is set
+ // on the inline style of the popup
+ margin-top: 2em !important;
+ max-width: 650px;
+ }
+
+ &-search {
+ max-width: none;
+ margin-top: -0.5em;
+ }
+
+ &-capsule-invalid-filter {
+ // TODO: Unify colors with official design palette
+ background: red;
+ }
+}
--- /dev/null
+.mw-rcfilters-ui-filtersListWidget {
+ &-title {
+ font-size: 1.2em;
+ padding: 0.75em;
+ // TODO: Unify colors with official design palette
+ color: #54595d;
+ border-bottom: 1px solid #c8ccd1;
+ background: #f8f9fa;
+ }
+
+ &-noresults {
+ padding: 0.5em;
+ // TODO: Unify colors with official design palette
+ color: #666;
+ }
+}
--- /dev/null
+( function ( mw, $ ) {
+ /**
+ * Filter-specific CapsuleMultiselectWidget
+ *
+ * @extends OO.ui.CapsuleMultiselectWidget
+ *
+ * @constructor
+ * @param {OO.ui.InputWidget} filterInput A filter input that focuses the capsule widget
+ * @param {Object} config Configuration object
+ */
+ mw.rcfilters.ui.FilterCapsuleMultiselectWidget = function MwRcfiltersUiFilterCapsuleMultiselectWidget( filterInput, config ) {
+ // Parent
+ mw.rcfilters.ui.FilterCapsuleMultiselectWidget.parent.call( this, $.extend( {
+ $autoCloseIgnore: filterInput.$element
+ }, config ) );
+
+ this.filterInput = filterInput;
+
+ this.$content.prepend(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterCapsuleMultiselectWidget-content-title' )
+ .text( mw.msg( 'rcfilters-activefilters' ) )
+ );
+
+ // Events
+ // Add the filterInput as trigger
+ this.filterInput.$input
+ .on( 'focus', this.onFocusForPopup.bind( this ) );
+
+ this.$element
+ .addClass( 'mw-rcfilters-ui-filterCapsuleMultiselectWidget' );
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( mw.rcfilters.ui.FilterCapsuleMultiselectWidget, OO.ui.CapsuleMultiselectWidget );
+
+ /* Events */
+
+ /**
+ * @event remove
+ * @param {string[]} filters Array of names of removed filters
+ *
+ * Filters were removed
+ */
+
+ /* Methods */
+
+ /**
+ * @inheritdoc
+ */
+ mw.rcfilters.ui.FilterCapsuleMultiselectWidget.prototype.onFocusForPopup = function () {
+ // Override this method; we don't want to focus on the popup, and we
+ // don't want to bind the size to the handle.
+ if ( !this.isDisabled() ) {
+ this.popup.toggle( true );
+ }
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.rcfilters.ui.FilterCapsuleMultiselectWidget.prototype.removeItems = function ( items ) {
+ // Parent
+ mw.rcfilters.ui.FilterCapsuleMultiselectWidget.parent.prototype.removeItems.call( this, items );
+
+ this.emit( 'remove', items.map( function ( item ) { return item.getData(); } ) );
+ };
+
+ /**
+ * @inheritdoc
+ */
+ mw.rcfilters.ui.FilterCapsuleMultiselectWidget.prototype.onKeyDown = function () {};
+
+ /**
+ * @inheritdoc
+ */
+ mw.rcfilters.ui.FilterCapsuleMultiselectWidget.prototype.onPopupFocusOut = function () {};
+
+ /**
+ * @inheritdoc
+ */
+ mw.rcfilters.ui.FilterCapsuleMultiselectWidget.prototype.clearInput = function () {
+ if ( this.filterInput ) {
+ this.filterInput.setValue( '' );
+ }
+ this.menu.toggle( false );
+ this.menu.selectItem();
+ this.menu.highlightItem();
+ };
+}( mediaWiki, jQuery ) );
--- /dev/null
+( function ( mw, $ ) {
+ /**
+ * A group of filters
+ *
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.GroupWidget
+ * @mixins OO.ui.mixin.LabelElement
+ *
+ * @constructor
+ * @param {string} name Group name
+ * @param {Object} config Configuration object
+ */
+ mw.rcfilters.ui.FilterGroupWidget = function MwRcfiltersUiFilterGroupWidget( name, config ) {
+ config = config || {};
+
+ // Parent
+ mw.rcfilters.ui.FilterGroupWidget.parent.call( this, config );
+ // Mixin constructors
+ OO.ui.mixin.GroupWidget.call( this, config );
+ OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
+ $label: $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterGroupWidget-title' )
+ } ) );
+
+ this.name = name;
+
+ this.$element
+ .addClass( 'mw-rcfilters-ui-filterGroupWidget' )
+ .append(
+ this.$label,
+ this.$group
+ .addClass( 'mw-rcfilters-ui-filterGroupWidget-group' )
+ );
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( mw.rcfilters.ui.FilterGroupWidget, OO.ui.Widget );
+ OO.mixinClass( mw.rcfilters.ui.FilterGroupWidget, OO.ui.mixin.GroupWidget );
+ OO.mixinClass( mw.rcfilters.ui.FilterGroupWidget, OO.ui.mixin.LabelElement );
+
+ /**
+ * Get the group name
+ *
+ * @return {string} Group name
+ */
+ mw.rcfilters.ui.FilterGroupWidget.prototype.getName = function () {
+ return this.name;
+ };
+
+}( mediaWiki, jQuery ) );
--- /dev/null
+( function ( mw, $ ) {
+ /**
+ * A widget representing a single toggle filter
+ *
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller RCFilters controller
+ * @param {mw.rcfilters.dm.FilterItem} model Filter item model
+ * @param {Object} config Configuration object
+ */
+ mw.rcfilters.ui.FilterItemWidget = function MwRcfiltersUiFilterItemWidget( controller, model, config ) {
+ var layout,
+ $label = $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterItemWidget-label' );
+
+ config = config || {};
+
+ // Parent
+ mw.rcfilters.ui.FilterItemWidget.parent.call( this, config );
+
+ this.controller = controller;
+ this.model = model;
+
+ this.checkboxWidget = new OO.ui.CheckboxInputWidget( {
+ value: this.model.getName(),
+ selected: this.model.isSelected()
+ } );
+
+ $label.append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterItemWidget-label-title' )
+ .text( this.model.getLabel() )
+ );
+ if ( this.model.getDescription() ) {
+ $label.append(
+ $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filterItemWidget-label-desc' )
+ .text( this.model.getDescription() )
+ );
+ }
+
+ layout = new OO.ui.FieldLayout( this.checkboxWidget, {
+ label: $label,
+ align: 'inline'
+ } );
+
+ // Event
+ this.checkboxWidget.connect( this, { change: 'onCheckboxChange' } );
+ this.model.connect( this, { update: 'onModelUpdate' } );
+
+ this.$element
+ .addClass( 'mw-rcfilters-ui-filterItemWidget' )
+ .append(
+ layout.$element
+ );
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( mw.rcfilters.ui.FilterItemWidget, OO.ui.Widget );
+
+ /* Methods */
+
+ /**
+ * Respond to checkbox change.
+ * NOTE: This event is emitted both for deliberate user action and for
+ * a change that the code requests ('setSelected')
+ *
+ * @param {boolean} isSelected The checkbox is selected
+ */
+ mw.rcfilters.ui.FilterItemWidget.prototype.onCheckboxChange = function ( isSelected ) {
+ this.controller.updateFilter( this.model.getName(), isSelected );
+ };
+
+ /**
+ * Respond to item model update event
+ */
+ mw.rcfilters.ui.FilterItemWidget.prototype.onModelUpdate = function () {
+ this.checkboxWidget.setSelected( this.model.isSelected() );
+ };
+
+ /**
+ * Get the name of this filter
+ *
+ * @return {string} Filter name
+ */
+ mw.rcfilters.ui.FilterItemWidget.prototype.getName = function () {
+ return this.model.getName();
+ };
+
+}( mediaWiki, jQuery ) );
--- /dev/null
+( function ( mw ) {
+ /**
+ * List displaying all filter groups
+ *
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.PendingElement
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+ * @param {Object} config Configuration object
+ * @cfg {Object} [filters] A definition of the filter groups in this list
+ */
+ mw.rcfilters.ui.FilterWrapperWidget = function MwRcfiltersUiFilterWrapperWidget( controller, model, config ) {
+ config = config || {};
+
+ // Parent
+ mw.rcfilters.ui.FilterWrapperWidget.parent.call( this, config );
+ // Mixin constructors
+ OO.ui.mixin.PendingElement.call( this, config );
+
+ this.controller = controller;
+ this.model = model;
+ this.filtersInCapsule = [];
+
+ this.filterPopup = new mw.rcfilters.ui.FiltersListWidget(
+ this.controller,
+ this.model,
+ {
+ label: mw.msg( 'rcfilters-filterlist-title' )
+ }
+ );
+
+ this.textInput = new OO.ui.TextInputWidget( {
+ classes: [ 'mw-rcfilters-ui-filterWrapperWidget-search' ],
+ icon: 'search',
+ placeholder: mw.msg( 'rcfilters-search-placeholder' )
+ } );
+
+ this.capsule = new mw.rcfilters.ui.FilterCapsuleMultiselectWidget( this.textInput, {
+ popup: {
+ $content: this.filterPopup.$element,
+ classes: [ 'mw-rcfilters-ui-filterWrapperWidget-popup' ]
+ }
+ } );
+
+ // Events
+ this.model.connect( this, {
+ initialize: 'onModelInitialize',
+ itemUpdate: 'onModelItemUpdate'
+ } );
+ this.textInput.connect( this, {
+ change: 'onTextInputChange'
+ } );
+ this.capsule.connect( this, {
+ remove: 'onCapsuleRemoveItem'
+ } );
+
+ this.$element
+ .addClass( 'mw-rcfilters-ui-filterWrapperWidget' )
+ .append( this.capsule.$element, this.textInput.$element );
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( mw.rcfilters.ui.FilterWrapperWidget, OO.ui.Widget );
+ OO.mixinClass( mw.rcfilters.ui.FilterWrapperWidget, OO.ui.mixin.PendingElement );
+
+ /**
+ * Respond to text input change
+ *
+ * @param {string} newValue Current value
+ */
+ mw.rcfilters.ui.FilterWrapperWidget.prototype.onTextInputChange = function ( newValue ) {
+ // Filter the results
+ this.filterPopup.filter( this.model.findMatches( newValue ) );
+ };
+
+ /**
+ * Respond to an event where an item is removed from the capsule.
+ * This is the case where a user actively removes a filter box from the capsule widget.
+ *
+ * @param {string[]} filterNames An array of filter names that were removed
+ */
+ mw.rcfilters.ui.FilterWrapperWidget.prototype.onCapsuleRemoveItem = function ( filterNames ) {
+ var filterItem,
+ widget = this;
+
+ filterNames.forEach( function ( filterName ) {
+ // Go over filters
+ filterItem = widget.model.getItemByName( filterName );
+ filterItem.toggleSelected( false );
+ } );
+ };
+
+ /**
+ * Respond to model update event and set up the available filters to choose
+ * from.
+ */
+ mw.rcfilters.ui.FilterWrapperWidget.prototype.onModelInitialize = function () {
+ var items,
+ filters = this.model.getItems();
+
+ // Reset
+ this.capsule.getMenu().clearItems();
+
+ // Insert hidden options for the capsule to get its item data from
+ items = filters.map( function ( filterItem ) {
+ return new OO.ui.MenuOptionWidget( {
+ data: filterItem.getName(),
+ label: filterItem.getLabel()
+ } );
+ } );
+
+ this.capsule.getMenu().addItems( items );
+ };
+
+ /**
+ * Respond to model item update
+ *
+ * @param {mw.rcfilters.dm.FilterItem} item Filter item that was updated
+ */
+ mw.rcfilters.ui.FilterWrapperWidget.prototype.onModelItemUpdate = function ( item ) {
+ if ( item.isSelected() ) {
+ this.capsule.addItemsFromData( [ item.getName() ] );
+ } else {
+ this.capsule.removeItemsFromData( [ item.getName() ] );
+ }
+ };
+}( mediaWiki ) );
--- /dev/null
+( function ( mw, $ ) {
+ /**
+ * List displaying all filter groups
+ *
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.GroupWidget
+ * @mixins OO.ui.mixin.LabelElement
+ *
+ * @constructor
+ * @param {mw.rcfilters.Controller} controller Controller
+ * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+ * @param {Object} config Configuration object
+ */
+ mw.rcfilters.ui.FiltersListWidget = function MwRcfiltersUiFiltersListWidget( controller, model, config ) {
+ config = config || {};
+
+ // Parent
+ mw.rcfilters.ui.FiltersListWidget.parent.call( this, config );
+ // Mixin constructors
+ OO.ui.mixin.GroupWidget.call( this, config );
+ OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
+ $label: $( '<div>' )
+ .addClass( 'mw-rcfilters-ui-filtersListWidget-title' )
+ } ) );
+
+ this.controller = controller;
+ this.model = model;
+
+ this.noResultsLabel = new OO.ui.LabelWidget( {
+ label: mw.msg( 'rcfilters-filterlist-noresults' ),
+ classes: [ 'mw-rcfilters-ui-filtersListWidget-noresults' ]
+ } );
+
+ // Events
+ this.model.connect( this, {
+ initialize: 'onModelInitialize'
+ } );
+
+ // Initialize
+ this.showNoResultsMessage( false );
+ this.$element
+ .addClass( 'mw-rcfilters-ui-filtersListWidget' )
+ .append(
+ this.$label,
+ this.$group
+ .addClass( 'mw-rcfilters-ui-filtersListWidget-group' ),
+ this.noResultsLabel.$element
+ );
+ };
+
+ /* Initialization */
+
+ OO.inheritClass( mw.rcfilters.ui.FiltersListWidget, OO.ui.Widget );
+ OO.mixinClass( mw.rcfilters.ui.FiltersListWidget, OO.ui.mixin.GroupWidget );
+ OO.mixinClass( mw.rcfilters.ui.FiltersListWidget, OO.ui.mixin.LabelElement );
+
+ /* Methods */
+
+ /**
+ * Respond to initialize event from the model
+ */
+ mw.rcfilters.ui.FiltersListWidget.prototype.onModelInitialize = function () {
+ var i, group, groupWidget,
+ itemWidgets = [],
+ groupWidgets = [],
+ groups = this.model.getFilterGroups();
+
+ // Reset
+ this.clearItems();
+
+ for ( group in groups ) {
+ groupWidget = new mw.rcfilters.ui.FilterGroupWidget( group, {
+ label: groups[ group ].title
+ } );
+ groupWidgets.push( groupWidget );
+
+ itemWidgets = [];
+ if ( groups[ group ].filters ) {
+ for ( i = 0; i < groups[ group ].filters.length; i++ ) {
+ itemWidgets.push(
+ new mw.rcfilters.ui.FilterItemWidget(
+ this.controller,
+ groups[ group ].filters[ i ],
+ {
+ label: groups[ group ].filters[ i ].getLabel(),
+ description: groups[ group ].filters[ i ].getDescription()
+ }
+ )
+ );
+ }
+
+ groupWidget.addItems( itemWidgets );
+ }
+ }
+
+ this.addItems( groupWidgets );
+ };
+
+ /**
+ * Switch between showing the 'no results' message for filtering results or the result list.
+ *
+ * @param {boolean} showNoResults Show no results message
+ */
+ mw.rcfilters.ui.FiltersListWidget.prototype.showNoResultsMessage = function ( showNoResults ) {
+ this.noResultsLabel.toggle( !!showNoResults );
+ this.$group.toggleClass( 'oo-ui-element-hidden', !!showNoResults );
+ };
+
+ /**
+ * Show only the items matching with the models in the given list
+ *
+ * @param {Object} groupItems An object of items to show
+ * arranged by their group names
+ */
+ mw.rcfilters.ui.FiltersListWidget.prototype.filter = function ( groupItems ) {
+ var i, j, groupName, itemWidgets,
+ groupWidgets = this.getItems(),
+ hasItemWithName = function ( itemArr, name ) {
+ return !!itemArr.filter( function ( item ) {
+ return item.getName() === name;
+ } ).length;
+ };
+
+ if ( $.isEmptyObject( groupItems ) ) {
+ // No results. Hide everything, show only 'no results'
+ // message
+ this.showNoResultsMessage( true );
+ return;
+ }
+
+ this.showNoResultsMessage( false );
+ for ( i = 0; i < groupWidgets.length; i++ ) {
+ groupName = groupWidgets[ i ].getName();
+
+ // If this group widget is in the filtered results,
+ // show it - otherwise, hide it
+ groupWidgets[ i ].toggle( !!groupItems[ groupName ] );
+
+ if ( !groupItems[ groupName ] ) {
+ // Continue to next group
+ continue;
+ }
+
+ // We have items to show
+ itemWidgets = groupWidgets[ i ].getItems();
+ for ( j = 0; j < itemWidgets.length; j++ ) {
+ // Only show items that are in the filtered list
+ itemWidgets[ j ].toggle(
+ hasItemWithName( groupItems[ groupName ], itemWidgets[ j ].getName() )
+ );
+ }
+ }
+ };
+}( mediaWiki, jQuery ) );
'tests/qunit/suites/resources/mediawiki.api/mediawiki.api.watch.test.js',
'tests/qunit/suites/resources/mediawiki.api/mediawiki.ForeignApi.test.js',
'tests/qunit/suites/resources/mediawiki.special/mediawiki.special.recentchanges.test.js',
+ 'tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js',
'tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js',
'tests/qunit/suites/resources/mediawiki/mediawiki.cldr.test.js',
'tests/qunit/suites/resources/mediawiki/mediawiki.cookie.test.js',
'mediawiki.util',
'mediawiki.viewport',
'mediawiki.special.recentchanges',
+ 'mediawiki.rcfilters.filters',
'mediawiki.language',
'mediawiki.cldr',
'mediawiki.cookie',
--- /dev/null
+( function ( mw, $ ) {
+ QUnit.module( 'mediawiki.rcfilters - FiltersViewModel' );
+
+ QUnit.test( 'Setting up filters', function ( assert ) {
+ var definition = {
+ group1: {
+ title: 'Group 1',
+ type: 'send_unselected_if_any',
+ filters: [
+ {
+ name: 'group1filter1',
+ label: 'Group 1: Filter 1',
+ description: 'Description of Filter 1 in Group 1'
+ },
+ {
+ name: 'group1filter2',
+ label: 'Group 1: Filter 2',
+ description: 'Description of Filter 2 in Group 1'
+ }
+ ]
+ },
+ group2: {
+ title: 'Group 2',
+ type: 'send_unselected_if_any',
+ filters: [
+ {
+ name: 'group2filter1',
+ label: 'Group 2: Filter 1',
+ description: 'Description of Filter 1 in Group 2'
+ },
+ {
+ name: 'group2filter2',
+ label: 'Group 2: Filter 2',
+ description: 'Description of Filter 2 in Group 2'
+ }
+ ]
+ }
+ },
+ model = new mw.rcfilters.dm.FiltersViewModel();
+
+ model.initializeFilters( definition );
+
+ assert.ok(
+ model.getItemByName( 'group1filter1' ) instanceof mw.rcfilters.dm.FilterItem &&
+ model.getItemByName( 'group1filter2' ) instanceof mw.rcfilters.dm.FilterItem &&
+ model.getItemByName( 'group2filter1' ) instanceof mw.rcfilters.dm.FilterItem &&
+ model.getItemByName( 'group2filter2' ) instanceof mw.rcfilters.dm.FilterItem,
+ 'Filters instantiated and stored correctly'
+ );
+
+ assert.deepEqual(
+ model.getState(),
+ {
+ group1filter1: false,
+ group1filter2: false,
+ group2filter1: false,
+ group2filter2: false
+ },
+ 'Initial state of filters'
+ );
+
+ model.updateFilters( {
+ group1filter1: true,
+ group2filter2: true
+ } );
+ assert.deepEqual(
+ model.getState(),
+ {
+ group1filter1: true,
+ group1filter2: false,
+ group2filter1: false,
+ group2filter2: true
+ },
+ 'Updating filter states correctly'
+ );
+ } );
+
+ QUnit.test( 'Finding matching filters', function ( assert ) {
+ var matches,
+ definition = {
+ group1: {
+ title: 'Group 1',
+ type: 'send_unselected_if_any',
+ filters: [
+ {
+ name: 'group1filter1',
+ label: 'Group 1: Filter 1',
+ description: 'Description of Filter 1 in Group 1'
+ },
+ {
+ name: 'group1filter2',
+ label: 'Group 1: Filter 2',
+ description: 'Description of Filter 2 in Group 1'
+ }
+ ]
+ },
+ group2: {
+ title: 'Group 2',
+ type: 'send_unselected_if_any',
+ filters: [
+ {
+ name: 'group2filter1',
+ label: 'Group 2: Filter 1',
+ description: 'Description of Filter 1 in Group 2'
+ },
+ {
+ name: 'group2filter2',
+ label: 'Group 2: Filter 2',
+ description: 'Description of Filter 2 in Group 2'
+ }
+ ]
+ }
+ },
+ model = new mw.rcfilters.dm.FiltersViewModel();
+
+ model.initializeFilters( definition );
+
+ matches = model.findMatches( 'group 1' );
+ assert.equal(
+ matches.group1.length,
+ 2,
+ 'findMatches finds correct group with correct number of results'
+ );
+
+ assert.deepEqual(
+ matches.group1.map( function ( item ) { return item.getName(); } ),
+ [ 'group1filter1', 'group1filter2' ],
+ 'findMatches finds the correct items within a single group'
+ );
+
+ matches = model.findMatches( 'filter 1' );
+ assert.ok(
+ matches.group1.length === 1 && matches.group2.length === 1,
+ 'findMatches finds correct number of results in multiple groups'
+ );
+
+ assert.deepEqual(
+ [
+ matches.group1.map( function ( item ) { return item.getName(); } ),
+ matches.group2.map( function ( item ) { return item.getName(); } )
+ ],
+ [
+ [ 'group1filter1' ],
+ [ 'group2filter1' ]
+ ],
+ 'findMatches finds the correct items within multiple groups'
+ );
+
+ matches = model.findMatches( 'foo' );
+ assert.ok(
+ $.isEmptyObject( matches ),
+ 'findMatches returns an empty object when no results found'
+ );
+ } );
+
+ QUnit.test( 'getParametersFromFilters', function ( assert ) {
+ var definition = {
+ group1: {
+ title: 'Group 1',
+ type: 'send_unselected_if_any',
+ filters: [
+ {
+ name: 'hidefilter1',
+ label: 'Group 1: Filter 1',
+ description: 'Description of Filter 1 in Group 1'
+ },
+ {
+ name: 'hidefilter2',
+ label: 'Group 1: Filter 2',
+ description: 'Description of Filter 2 in Group 1'
+ },
+ {
+ name: 'hidefilter3',
+ label: 'Group 1: Filter 3',
+ description: 'Description of Filter 3 in Group 1'
+ }
+ ]
+ },
+ group2: {
+ title: 'Group 2',
+ type: 'send_unselected_if_any',
+ filters: [
+ {
+ name: 'hidefilter4',
+ label: 'Group 2: Filter 1',
+ description: 'Description of Filter 1 in Group 2'
+ },
+ {
+ name: 'hidefilter5',
+ label: 'Group 2: Filter 2',
+ description: 'Description of Filter 2 in Group 2'
+ },
+ {
+ name: 'hidefilter6',
+ label: 'Group 2: Filter 3',
+ description: 'Description of Filter 3 in Group 2'
+ }
+ ]
+ }
+ },
+ model = new mw.rcfilters.dm.FiltersViewModel();
+
+ model.initializeFilters( definition );
+
+ // Starting with all filters unselected
+ assert.deepEqual(
+ model.getParametersFromFilters(),
+ {
+ hidefilter1: 0,
+ hidefilter2: 0,
+ hidefilter3: 0,
+ hidefilter4: 0,
+ hidefilter5: 0,
+ hidefilter6: 0
+ },
+ 'Unselected filters return all parameters falsey.'
+ );
+
+ // Select 1 filter
+ model.updateFilters( {
+ hidefilter1: true,
+ hidefilter2: false,
+ hidefilter3: false,
+ hidefilter4: false,
+ hidefilter5: false,
+ hidefilter6: false
+ } );
+ // Only one filter in one group
+ assert.deepEqual(
+ model.getParametersFromFilters(),
+ {
+ // Group 1 (one selected, the others are true)
+ hidefilter1: 0,
+ hidefilter2: 1,
+ hidefilter3: 1,
+ // Group 2 (nothing is selected, all false)
+ hidefilter4: 0,
+ hidefilter5: 0,
+ hidefilter6: 0
+ },
+ 'One filters in one "send_unselected_if_any" group returns the other parameters truthy.'
+ );
+
+ // Select 2 filters
+ model.updateFilters( {
+ hidefilter1: true,
+ hidefilter2: true,
+ hidefilter3: false,
+ hidefilter4: false,
+ hidefilter5: false,
+ hidefilter6: false
+ } );
+ // Two selected filters in one group
+ assert.deepEqual(
+ model.getParametersFromFilters(),
+ {
+ // Group 1 (two selected, the others are true)
+ hidefilter1: 0,
+ hidefilter2: 0,
+ hidefilter3: 1,
+ // Group 2 (nothing is selected, all false)
+ hidefilter4: 0,
+ hidefilter5: 0,
+ hidefilter6: 0
+ },
+ 'One filters in one "send_unselected_if_any" group returns the other parameters truthy.'
+ );
+
+ // Select 3 filters
+ model.updateFilters( {
+ hidefilter1: true,
+ hidefilter2: true,
+ hidefilter3: true,
+ hidefilter4: false,
+ hidefilter5: false,
+ hidefilter6: false
+ } );
+ // All filters of the group are selected == this is the same as not selecting any
+ assert.deepEqual(
+ model.getParametersFromFilters(),
+ {
+ // Group 1 (all selected, all false)
+ hidefilter1: 0,
+ hidefilter2: 0,
+ hidefilter3: 0,
+ // Group 2 (nothing is selected, all false)
+ hidefilter4: 0,
+ hidefilter5: 0,
+ hidefilter6: 0
+ },
+ 'All filters selected in one "send_unselected_if_any" group returns all parameters falsy.'
+ );
+ } );
+
+ QUnit.test( 'getFiltersFromParameters', function ( assert ) {
+ var definition = {
+ group1: {
+ title: 'Group 1',
+ type: 'send_unselected_if_any',
+ filters: [
+ {
+ name: 'hidefilter1',
+ label: 'Show filter 1',
+ description: 'Description of Filter 1 in Group 1'
+ },
+ {
+ name: 'hidefilter2',
+ label: 'Show filter 2',
+ description: 'Description of Filter 2 in Group 1'
+ },
+ {
+ name: 'hidefilter3',
+ label: 'Show filter 3',
+ description: 'Description of Filter 3 in Group 1'
+ }
+ ]
+ },
+ group2: {
+ title: 'Group 2',
+ type: 'send_unselected_if_any',
+ filters: [
+ {
+ name: 'hidefilter4',
+ label: 'Show filter 4',
+ description: 'Description of Filter 1 in Group 2'
+ },
+ {
+ name: 'hidefilter5',
+ label: 'Show filter 5',
+ description: 'Description of Filter 2 in Group 2'
+ },
+ {
+ name: 'hidefilter6',
+ label: 'Show filter 6',
+ description: 'Description of Filter 3 in Group 2'
+ }
+ ]
+ }
+ },
+ model = new mw.rcfilters.dm.FiltersViewModel();
+
+ model.initializeFilters( definition );
+
+ // Empty query = empty filter definition
+ assert.deepEqual(
+ model.getFiltersFromParameters( {} ),
+ {
+ hidefilter1: false, // The text is "show filter 1"
+ hidefilter2: false, // The text is "show filter 2"
+ hidefilter3: false, // The text is "show filter 3"
+ hidefilter4: false, // The text is "show filter 4"
+ hidefilter5: false, // The text is "show filter 5"
+ hidefilter6: false // The text is "show filter 6"
+ },
+ 'Empty parameter query results in filters in initial state'
+ );
+
+ assert.deepEqual(
+ model.getFiltersFromParameters( {
+ hidefilter1: '1'
+ } ),
+ {
+ hidefilter1: false, // The text is "show filter 1"
+ hidefilter2: true, // The text is "show filter 2"
+ hidefilter3: true, // The text is "show filter 3"
+ hidefilter4: false, // The text is "show filter 4"
+ hidefilter5: false, // The text is "show filter 5"
+ hidefilter6: false // The text is "show filter 6"
+ },
+ 'One falsey parameter in a group makes the rest of the filters in the group truthy (checked) in the interface'
+ );
+
+ assert.deepEqual(
+ model.getFiltersFromParameters( {
+ hidefilter1: '1',
+ hidefilter2: '1'
+ } ),
+ {
+ hidefilter1: false, // The text is "show filter 1"
+ hidefilter2: false, // The text is "show filter 2"
+ hidefilter3: true, // The text is "show filter 3"
+ hidefilter4: false, // The text is "show filter 4"
+ hidefilter5: false, // The text is "show filter 5"
+ hidefilter6: false // The text is "show filter 6"
+ },
+ 'Two falsey parameters in a group makes the rest of the filters in the group truthy (checked) in the interface'
+ );
+
+ assert.deepEqual(
+ model.getFiltersFromParameters( {
+ hidefilter1: '1',
+ hidefilter2: '1',
+ hidefilter3: '1'
+ } ),
+ {
+ // TODO: This will have to be represented as a different state, though.
+ hidefilter1: false, // The text is "show filter 1"
+ hidefilter2: false, // The text is "show filter 2"
+ hidefilter3: false, // The text is "show filter 3"
+ hidefilter4: false, // The text is "show filter 4"
+ hidefilter5: false, // The text is "show filter 5"
+ hidefilter6: false // The text is "show filter 6"
+ },
+ 'All paremeters in the same group false is equivalent to none are truthy (checked) in the interface'
+ );
+
+ // The ones above don't update the model, so we have a clean state.
+
+ model.updateFilters(
+ model.getFiltersFromParameters( {
+ hidefilter1: '1'
+ } )
+ );
+
+ model.updateFilters(
+ model.getFiltersFromParameters( {
+ hidefilter3: '1'
+ } )
+ );
+
+ // 1 and 3 are separately unchecked via hide parameters, 2 should still be
+ // checked.
+ // This can simulate separate filters in the same group being hidden different
+ // ways (e.g. preferences and URL).
+ assert.deepEqual(
+ model.getState(),
+ {
+ hidefilter1: false, // The text is "show filter 1"
+ hidefilter2: true, // The text is "show filter 2"
+ hidefilter3: false, // The text is "show filter 3"
+ hidefilter4: false, // The text is "show filter 4"
+ hidefilter5: false, // The text is "show filter 5"
+ hidefilter6: false // The text is "show filter 6"
+ },
+ 'After unchecking 2 of 3 filters via separate updateFilters calls, only the remaining one is still checked.'
+ );
+
+ // Reset
+ model = new mw.rcfilters.dm.FiltersViewModel();
+ model.initializeFilters( definition );
+
+ model.updateFilters(
+ model.getFiltersFromParameters( {
+ hidefilter1: '1'
+ } )
+ );
+ model.updateFilters(
+ model.getFiltersFromParameters( {
+ hidefilter1: '0'
+ } )
+ );
+
+ // Simulates minor edits being hidden in preferences, then unhidden via URL
+ // override.
+ assert.deepEqual(
+ model.getState(),
+ {
+ hidefilter1: false, // The text is "show filter 1"
+ hidefilter2: false, // The text is "show filter 2"
+ hidefilter3: false, // The text is "show filter 3"
+ hidefilter4: false, // The text is "show filter 4"
+ hidefilter5: false, // The text is "show filter 5"
+ hidefilter6: false // The text is "show filter 6"
+ },
+ 'After unchecking then checking a filter (without touching other filters in that group), all are checked'
+ );
+ } );
+}( mediaWiki, jQuery ) );