From: Moriel Schottlender Date: Thu, 2 Feb 2017 20:13:00 +0000 (-0800) Subject: RCFilters UI: Define interaction states for filters X-Git-Tag: 1.31.0-rc.0~4109 X-Git-Url: http://git.cyclocoop.org/%22.%24image2.%22?a=commitdiff_plain;h=d730073fdfb576ac3bb18fe3fb1c51be86194a64;p=lhc%2Fweb%2Fwiklou.git RCFilters UI: Define interaction states for filters This patch sets up the ground for all three interaction types: 'subset', 'conflict' and 'coverage' as toggle-able properties of the item and group models, and sets up the widgets' initial logic abou their own "mute" state. The patch includes the basic logic for two interactions: - Subsets (and 'supersets' that are derived from them) - Coverage Direct conflict states will be defined in an upcoming commit. Bug: T156864 Bug: T156861 Bug: T156860 Change-Id: If20bbe9f1442cfcfce046e56f6150b38dd3a4efc --- 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 da9e59e7c5..6d9611e874 100644 --- a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js @@ -6,27 +6,31 @@ * @mixins OO.EmitterList * * @constructor + * @param {string} name Group name * @param {Object} [config] Configuration options - * @cfg {string} [name] Group name * @cfg {string} [type='send_unselected_if_any'] Group type * @cfg {string} [title] Group title * @cfg {string} [separator='|'] Value separator for 'string_options' groups - * @cfg {string} [exclusionType='default'] Group exclusion type * @cfg {boolean} [active] Group is active + * @cfg {boolean} [fullCoverage] This filters in this group collectively cover all results */ - mw.rcfilters.dm.FilterGroup = function MwRcfiltersDmFilterGroup( config ) { + mw.rcfilters.dm.FilterGroup = function MwRcfiltersDmFilterGroup( name, config ) { config = config || {}; // Mixin constructor OO.EventEmitter.call( this ); OO.EmitterList.call( this ); - this.name = config.name; + this.name = name; this.type = config.type || 'send_unselected_if_any'; this.title = config.title; this.separator = config.separator || '|'; - this.exclusionType = config.exclusionType || 'default'; + this.active = !!config.active; + this.fullCoverage = !!config.fullCoverage; + + this.aggregate( { update: 'filterItemUpdate' } ); + this.connect( this, { filterItemUpdate: 'onFilterItemUpdate' } ); }; /* Initialization */ @@ -45,23 +49,13 @@ /* Methods */ /** - * Check the active status of the group and set it accordingly. + * Respond to filterItem update event * * @fires update */ - mw.rcfilters.dm.FilterGroup.prototype.checkActive = function () { - var active, - count = 0; - - // Recheck group activity - this.getItems().forEach( function ( filterItem ) { - count += Number( filterItem.isSelected() ); - } ); - - active = ( - count > 0 && - count < this.getItemCount() - ); + mw.rcfilters.dm.FilterGroup.prototype.onFilterItemUpdate = function () { + // Update state + var active = this.areAnySelected(); if ( this.active !== active ) { this.active = active; @@ -87,6 +81,42 @@ return this.name; }; + /** + * Check whether there are any items selected + * + * @return {boolean} Any items in the group are selected + */ + mw.rcfilters.dm.FilterGroup.prototype.areAnySelected = function () { + return this.getItems().some( function ( filterItem ) { + return filterItem.isSelected(); + } ); + }; + + /** + * Check whether all items selected + * + * @return {boolean} All items are selected + */ + mw.rcfilters.dm.FilterGroup.prototype.areAllSelected = function () { + return this.getItems().every( function ( filterItem ) { + return filterItem.isSelected(); + } ); + }; + + /** + * Get all selected items in this group + * + * @param {mw.rcfilters.dm.FilterItem} [excludeItem] Item to exclude from the list + * @return {mw.rcfilters.dm.FilterItem[]} Selected items + */ + mw.rcfilters.dm.FilterGroup.prototype.getSelectedItems = function ( excludeItem ) { + var excludeName = ( excludeItem && excludeItem.getName() ) || ''; + + return this.getItems().filter( function ( item ) { + return item.getName() !== excludeName && item.isSelected(); + } ); + }; + /** * Get group type * @@ -115,11 +145,11 @@ }; /** - * Get group exclusion type + * Check whether the group is defined as full coverage * - * @return {string} Exclusion type + * @return {boolean} Group is full coverage */ - mw.rcfilters.dm.FilterGroup.prototype.getExclusionType = function () { - return this.exclusionType; + mw.rcfilters.dm.FilterGroup.prototype.isFullCoverage = function () { + return this.fullCoverage; }; }( mediaWiki ) ); diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js index 5dfb68df99..acf53c1cfc 100644 --- a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js @@ -6,6 +6,7 @@ * * @constructor * @param {string} name Filter name + * @param {mw.rcfilters.dm.FilterGroup} groupModel Filter group model * @param {Object} config Configuration object * @cfg {string} [group] The group this item belongs to * @cfg {string} [label] The label for the filter @@ -13,23 +14,32 @@ * @cfg {boolean} [active=true] The filter is active and affecting the result * @cfg {string[]} [excludes=[]] A list of filter names this filter, if * selected, makes inactive. - * @cfg {boolean} [default] The default state of this filter + * @cfg {boolean} [selected] The item is selected + * @cfg {string[]} [subset] Defining the names of filters that are a subset of this filter + * @cfg {string[]} [conflictsWith] Defining the names of filters that conflict with this item */ - mw.rcfilters.dm.FilterItem = function MwRcfiltersDmFilterItem( name, config ) { + mw.rcfilters.dm.FilterItem = function MwRcfiltersDmFilterItem( name, groupModel, config ) { config = config || {}; // Mixin constructor OO.EventEmitter.call( this ); this.name = name; - this.group = config.group || ''; + this.groupModel = groupModel; + this.label = config.label || this.name; this.description = config.description; - this.default = !!config.default; + this.selected = !!config.selected; + + // Interaction definitions + this.subset = config.subset || []; + this.conflicts = config.conflicts || []; + this.superset = []; - this.active = config.active === undefined ? true : !!config.active; - this.excludes = config.excludes || []; - this.selected = this.default; + // Interaction states + this.included = false; + this.conflicted = false; + this.fullyCovered = false; }; /* Initialization */ @@ -56,13 +66,22 @@ return this.name; }; + /** + * Get the model of the group this filter belongs to + * + * @return {mw.rcfilters.dm.FilterGroup} Filter group model + */ + mw.rcfilters.dm.FilterItem.prototype.getGroupModel = function () { + return this.groupModel; + }; + /** * Get the group name this filter belongs to * * @return {string} Filter group name */ - mw.rcfilters.dm.FilterItem.prototype.getGroup = function () { - return this.group; + mw.rcfilters.dm.FilterItem.prototype.getGroupName = function () { + return this.groupModel.getName(); }; /** @@ -92,6 +111,28 @@ return this.default; }; + /** + * Get filter subset + * This is a list of filter names that are defined to be included + * when this filter is selected. + * + * @return {string[]} Filter subset + */ + mw.rcfilters.dm.FilterItem.prototype.getSubset = function () { + return this.subset; + }; + + /** + * Get filter superset + * This is a generated list of filters that define this filter + * to be included when either of them is selected. + * + * @return {string[]} Filter superset + */ + mw.rcfilters.dm.FilterItem.prototype.getSuperset = function () { + return this.superset; + }; + /** * Get the selected state of this filter * @@ -102,43 +143,111 @@ }; /** - * Check if this filter is active + * Check whether the filter is currently in a conflict state + * + * @return {boolean} Filter is in conflict state + */ + mw.rcfilters.dm.FilterItem.prototype.isConflicted = function () { + return this.conflicted; + }; + + /** + * Check whether the filter is currently in an already included subset * - * @return {boolean} Filter is active + * @return {boolean} Filter is in an already-included subset */ - mw.rcfilters.dm.FilterItem.prototype.isActive = function () { - return this.active; + mw.rcfilters.dm.FilterItem.prototype.isIncluded = function () { + return this.included; }; /** - * Check if this filter has a list of excluded filters + * Check whether the filter is currently fully covered * - * @return {boolean} Filter has a list of excluded filters + * @return {boolean} Filter is in fully-covered state */ - mw.rcfilters.dm.FilterItem.prototype.hasExcludedFilters = function () { - return !!this.excludes.length; + mw.rcfilters.dm.FilterItem.prototype.isFullyCovered = function () { + return this.fullyCovered; }; /** - * Get this filter's list of excluded filters + * Get filter conflicts * - * @return {string[]} Array of excluded filter names + * @return {string[]} Filter conflicts */ - mw.rcfilters.dm.FilterItem.prototype.getExcludedFilters = function () { - return this.excludes; + mw.rcfilters.dm.FilterItem.prototype.getConflicts = function () { + return this.conflicts; }; /** - * Toggle the active state of the item + * Set filter conflicts * - * @param {boolean} [isActive] Filter is active + * @param {string[]} conflicts Filter conflicts + */ + mw.rcfilters.dm.FilterItem.prototype.setConflicts = function ( conflicts ) { + this.conflicts = conflicts || []; + }; + + /** + * Set filter superset + * + * @param {string[]} superset Filter superset + */ + mw.rcfilters.dm.FilterItem.prototype.setSuperset = function ( superset ) { + this.superset = superset || []; + }; + + /** + * Check whether a filter exists in the subset list for this filter + * + * @param {string} filterName Filter name + * @return {boolean} Filter name is in the subset list + */ + mw.rcfilters.dm.FilterItem.prototype.existsInSubset = function ( filterName ) { + return this.subset.indexOf( filterName ) > -1; + }; + + /** + * Check whether this item has a potential conflict with the given item + * + * This checks whether the given item is in the list of conflicts of + * the current item, but makes no judgment about whether the conflict + * is currently at play (either one of the items may not be selected) + * + * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item + * @return {boolean} This item has a conflict with the given item + */ + mw.rcfilters.dm.FilterItem.prototype.hasConflictWith = function ( filterItem ) { + return this.conflicts.indexOf( filterItem.getName() ) > -1; + }; + + /** + * Set the state of this filter as being conflicted + * (This means any filters in its conflicts are selected) + * + * @param {boolean} [conflicted] Filter is in conflict state + * @fires update + */ + mw.rcfilters.dm.FilterItem.prototype.toggleConflicted = function ( conflicted ) { + conflicted = conflicted === undefined ? !this.conflicted : conflicted; + + if ( this.conflicted !== conflicted ) { + this.conflicted = conflicted; + this.emit( 'update' ); + } + }; + + /** + * Set the state of this filter as being already included + * (This means any filters in its superset are selected) + * + * @param {boolean} [included] Filter is included as part of a subset * @fires update */ - mw.rcfilters.dm.FilterItem.prototype.toggleActive = function ( isActive ) { - isActive = isActive === undefined ? !this.active : isActive; + mw.rcfilters.dm.FilterItem.prototype.toggleIncluded = function ( included ) { + included = included === undefined ? !this.included : included; - if ( this.active !== isActive ) { - this.active = isActive; + if ( this.included !== included ) { + this.included = included; this.emit( 'update' ); } }; @@ -157,4 +266,19 @@ this.emit( 'update' ); } }; + + /** + * Toggle the fully covered state of the item + * + * @param {boolean} [isFullyCovered] Filter is fully covered + * @fires update + */ + mw.rcfilters.dm.FilterItem.prototype.toggleFullyCovered = function ( isFullyCovered ) { + isFullyCovered = isFullyCovered === undefined ? !this.fullycovered : isFullyCovered; + + if ( this.fullyCovered !== isFullyCovered ) { + this.fullyCovered = isFullyCovered; + this.emit( 'update' ); + } + }; }( mediaWiki ) ); 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 5bbeabf3cc..14306f1270 100644 --- a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js @@ -13,13 +13,12 @@ OO.EmitterList.call( this ); this.groups = {}; - this.excludedByMap = {}; this.defaultParams = {}; this.defaultFiltersEmpty = null; // Events this.aggregate( { update: 'filterItemUpdate' } ); - this.connect( this, { filterItemUpdate: 'onFilterItemUpdate' } ); + this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } ); }; /* Initialization */ @@ -45,96 +44,41 @@ /* Methods */ /** - * Respond to filter item change. + * Re-assess the states of filter items based on the interactions between them * - * @param {mw.rcfilters.dm.FilterItem} item Updated filter - * @fires itemUpdate + * @param {mw.rcfilters.dm.FilterItem} [item] Changed item. If not given, the + * method will go over the state of all items */ - mw.rcfilters.dm.FiltersViewModel.prototype.onFilterItemUpdate = function ( item ) { - // Reapply the active state of filters - this.reapplyActiveFilters( item ); + mw.rcfilters.dm.FiltersViewModel.prototype.reassessFilterInteractions = function ( item ) { + var allSelected, + model = this, + iterationItems = item !== undefined ? [ item ] : this.getItems(); - // Recheck group activity state - this.getGroup( item.getGroup() ).checkActive(); + iterationItems.forEach( function ( checkedItem ) { + var allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ), + groupModel = checkedItem.getGroupModel(); - this.emit( 'itemUpdate', item ); - }; + // Check for subsets (included filters) plus the item itself: + allCheckedItems.forEach( function ( filterItemName ) { + var itemInSubset = model.getItemByName( filterItemName ); - /** - * Calculate the active state of the filters, based on selected filters in the group. - * - * @param {mw.rcfilters.dm.FilterItem} item Changed item - */ - mw.rcfilters.dm.FiltersViewModel.prototype.reapplyActiveFilters = function ( item ) { - var selectedItemsCount, - group = item.getGroup(), - model = this; - if ( - !this.getGroup( group ).getExclusionType() || - this.getGroup( group ).getExclusionType() === 'default' - ) { - // Default behavior - // If any parameter is selected, but: - // - If there are unselected items in the group, they are inactive - // - If the entire group is selected, all are inactive - - // Check what's selected in the group - selectedItemsCount = this.getGroupFilters( group ).filter( function ( filterItem ) { - return filterItem.isSelected(); - } ).length; - - this.getGroupFilters( group ).forEach( function ( filterItem ) { - filterItem.toggleActive( - selectedItemsCount > 0 ? - // If some items are selected - ( - selectedItemsCount === model.groups[ group ].getItemCount() ? - // If **all** items are selected, they're all inactive - false : - // If not all are selected, then the selected are active - // and the unselected are inactive - filterItem.isSelected() - ) : - // No item is selected, everything is active - true - ); - } ); - } else if ( this.getGroup( group ).getExclusionType() === 'explicit' ) { - // Explicit behavior - // - Go over the list of excluded filters to change their - // active states accordingly - - // For each item in the list, see if there are other selected - // filters that also exclude it. If it does, it will still be - // inactive. - - item.getExcludedFilters().forEach( function ( filterName ) { - var filterItem = model.getItemByName( filterName ); - - // Note to reduce confusion: - // - item is the filter whose state changed and should exclude the other filters - // in its list of exclusions - // - filterItem is the filter that is potentially being excluded by the current item - // - anotherExcludingFilter is any other filter that excludes filterItem; we must check - // if that filter is selected, because if it is, we should not touch the excluded item - if ( - // Check if there are any filters (other than the current one) - // that also exclude the filterName - !model.excludedByMap[ filterName ].some( function ( anotherExcludingFilterName ) { - var anotherExcludingFilter = model.getItemByName( anotherExcludingFilterName ); - - return ( - anotherExcludingFilterName !== item.getName() && - anotherExcludingFilter.isSelected() - ); + itemInSubset.toggleIncluded( + // If any of itemInSubset's supersets are selected, this item + // is included + itemInSubset.getSuperset().some( function ( supersetName ) { + return ( model.getItemByName( supersetName ).isSelected() ); } ) - ) { - // Only change the state for filters that aren't - // also affected by other excluding selected filters - filterItem.toggleActive( !item.isSelected() ); - } + ); } ); - } + + // Update coverage for the changed group + if ( groupModel.isFullCoverage() ) { + allSelected = groupModel.areAllSelected(); + groupModel.getItems().forEach( function ( filterItem ) { + filterItem.toggleFullyCovered( allSelected ); + } ); + } + } ); }; /** @@ -144,47 +88,81 @@ * @param {Object} filters Filter group definition */ mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filters ) { - var i, filterItem, selectedFilterNames, excludedFilters, + var i, filterItem, selectedFilterNames, model = this, items = [], - addToMap = function ( excludedFilters ) { - excludedFilters.forEach( function ( filterName ) { - model.excludedByMap[ filterName ] = model.excludedByMap[ filterName ] || []; - model.excludedByMap[ filterName ].push( filterItem.getName() ); + addArrayElementsUnique = function ( arr, elements ) { + elements = Array.isArray( elements ) ? elements : [ elements ]; + + elements.forEach( function ( element ) { + if ( arr.indexOf( element ) === -1 ) { + arr.push( element ); + } } ); - }; + + return arr; + }, + conflictMap = {}, + supersetMap = {}; // Reset this.clearItems(); this.groups = {}; - this.excludedByMap = {}; $.each( filters, function ( group, data ) { if ( !model.groups[ group ] ) { - model.groups[ group ] = new mw.rcfilters.dm.FilterGroup( { - name: group, + model.groups[ group ] = new mw.rcfilters.dm.FilterGroup( group, { type: data.type, title: data.title, separator: data.separator, - exclusionType: data.exclusionType + fullCoverage: !!data.fullCoverage } ); } selectedFilterNames = []; for ( i = 0; i < data.filters.length; i++ ) { - excludedFilters = data.filters[ i ].excludes || []; - - filterItem = new mw.rcfilters.dm.FilterItem( data.filters[ i ].name, { + filterItem = new mw.rcfilters.dm.FilterItem( data.filters[ i ].name, model.groups[ group ], { group: group, label: data.filters[ i ].label, description: data.filters[ i ].description, - selected: data.filters[ i ].selected, - excludes: excludedFilters, - 'default': data.filters[ i ].default + subset: data.filters[ i ].subset } ); - // Map filters and what excludes them - addToMap( excludedFilters ); + // For convenience, we should store each filter's "supersets" -- these are + // the filters that have that item in their subset list. This will just + // make it easier to go through whether the item has any other items + // that affect it (and are selected) at any given time + if ( data.filters[ i ].subset ) { + data.filters[ i ].subset.forEach( function ( subsetFilterName ) { // eslint-disable-line no-loop-func + supersetMap[ subsetFilterName ] = supersetMap[ subsetFilterName ] || []; + addArrayElementsUnique( + supersetMap[ subsetFilterName ], + filterItem.getName() + ); + } ); + } + + // Conflicts are bi-directional, which means FilterA can define having + // a conflict with FilterB, and this conflict should appear in **both** + // filter definitions. + // We need to remap all the 'conflicts' so they reflect the entire state + // in either direction regardless of which filter defined the other as conflicting. + if ( data.filters[ i ].conflicts ) { + conflictMap[ filterItem.getName() ] = conflictMap[ filterItem.getName() ] || []; + addArrayElementsUnique( + conflictMap[ filterItem.getName() ], + data.filters[ i ].conflicts + ); + + data.filters[ i ].conflicts.forEach( function ( conflictingFilterName ) { // eslint-disable-line no-loop-func + // Add this filter to the conflicts of each of the filters in its list + conflictMap[ conflictingFilterName ] = conflictMap[ conflictingFilterName ] || []; + addArrayElementsUnique( + conflictMap[ conflictingFilterName ], + filterItem.getName() + ); + } ); + } if ( data.type === 'send_unselected_if_any' ) { // Store the default parameter state @@ -209,6 +187,17 @@ } } ); + items.forEach( function ( filterItem ) { + // Apply conflict map to the items + // Now that we mapped all items and conflicts bi-directionally + // we need to apply the definition to each filter again + filterItem.setConflicts( conflictMap[ filterItem.getName() ] ); + + // Apply the superset map + filterItem.setSuperset( supersetMap[ filterItem.getName() ] ); + } ); + + // Add items to the model this.addItems( items ); this.emit( 'initialize' ); @@ -232,26 +221,6 @@ return this.groups; }; - /** - * Update the representation of the parameters. These are the back-end - * parameters representing the filters, but they represent the given - * current state regardless of validity. - * - * This should only run after filters are already set. - * - * @param {Object} params Parameter state - */ - mw.rcfilters.dm.FiltersViewModel.prototype.updateParameters = function ( params ) { - var model = this; - - $.each( params, function ( name, value ) { - // Only store the parameters that exist in the system - if ( model.getItemByName( name ) ) { - model.parameters[ name ] = value; - } - } ); - }; - /** * Get the value of a specific parameter * @@ -292,7 +261,8 @@ for ( i = 0; i < items.length; i++ ) { result[ items[ i ].getName() ] = { selected: items[ i ].isSelected(), - active: items[ i ].isActive() + conflicted: items[ i ].isConflicted(), + included: items[ i ].isIncluded() }; } @@ -460,17 +430,17 @@ filterItem = model.getItemByName( paramName ); // Ignore if no filter item exists if ( filterItem ) { - groupMap[ filterItem.getGroup() ] = groupMap[ filterItem.getGroup() ] || {}; + groupMap[ filterItem.getGroupName() ] = groupMap[ filterItem.getGroupName() ] || {}; // Mark the group if it has any items that are selected - groupMap[ filterItem.getGroup() ].hasSelected = ( - groupMap[ filterItem.getGroup() ].hasSelected || + groupMap[ filterItem.getGroupName() ].hasSelected = ( + groupMap[ filterItem.getGroupName() ].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 ); + groupMap[ filterItem.getGroupName() ].filters = groupMap[ filterItem.getGroupName() ].filters || []; + groupMap[ filterItem.getGroupName() ].filters.push( filterItem ); } else if ( model.groups.hasOwnProperty( paramName ) ) { // This parameter represents a group (values are the filters) // this is equivalent to checking if the group is 'string_options' @@ -600,22 +570,22 @@ // item label starting with the query string for ( i = 0; i < items.length; i++ ) { if ( items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ) { - result[ items[ i ].getGroup() ] = result[ items[ i ].getGroup() ] || []; - result[ items[ i ].getGroup() ].push( items[ i ] ); + result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || []; + result[ items[ i ].getGroupName() ].push( items[ i ] ); } } if ( $.isEmptyObject( result ) ) { // item containing the query string in their label, description, or group title for ( i = 0; i < items.length; i++ ) { - groupTitle = this.getGroup( items[ i ].getGroup() ).getTitle(); + groupTitle = items[ i ].getGroupModel().getTitle(); if ( items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 || items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 || groupTitle.toLowerCase().indexOf( query ) > -1 ) { - result[ items[ i ].getGroup() ] = result[ items[ i ].getGroup() ] || []; - result[ items[ i ].getGroup() ].push( items[ i ] ); + result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || []; + result[ items[ i ].getGroupName() ].push( items[ i ] ); } } } diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js index 88f32b4d9d..ff34bb8c5d 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js @@ -16,21 +16,31 @@ /** * Initialize the filter and parameter states + * + * @param {Object} filterStructure Filter definition and structure for the model */ - mw.rcfilters.Controller.prototype.initialize = function () { - this.updateFromURL(); - }; - - /** - * Update the model state based on the URL parameters. - */ - mw.rcfilters.Controller.prototype.updateFromURL = function () { + mw.rcfilters.Controller.prototype.initialize = function ( filterStructure ) { var uri = new mw.Uri(); + // Initialize the model + this.filtersModel.initializeFilters( filterStructure ); + + // Set filter states based on defaults and URL params this.filtersModel.updateFilters( - // Translate the url params to filter select states - this.filtersModel.getFiltersFromParameters( uri.query ) + this.filtersModel.getFiltersFromParameters( + // Merge defaults with URL params for initialization + $.extend( + true, + {}, + this.filtersModel.getDefaultParams(), + // URI query overrides defaults + uri.query + ) + ) ); + + // Check all filter interactions + this.filtersModel.reassessFilterInteractions(); }; /** @@ -61,9 +71,13 @@ var obj = {}; obj[ filterName ] = isSelected; + this.filtersModel.updateFilters( obj ); this.updateURL(); this.updateChangesList(); + + // Check filter interactions + this.filtersModel.reassessFilterInteractions( this.filtersModel.getItemByName( filterName ) ); }; /** diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js index ef0489c382..61df2e80ec 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js @@ -25,10 +25,11 @@ new mw.rcfilters.ui.FormWrapperWidget( changesListModel, $( '.rcoptions form' ) ); - filtersModel.initializeFilters( { + controller.initialize( { registration: { title: mw.msg( 'rcfilters-filtergroup-registration' ), type: 'send_unselected_if_any', + fullCoverage: true, filters: [ { name: 'hideliu', @@ -51,21 +52,25 @@ // ** In this case, the parameter name is the group name. ** type: 'string_options', separator: ',', + fullCoverage: false, filters: [ { name: 'newcomer', label: mw.msg( 'rcfilters-filter-userExpLevel-newcomer-label' ), - description: mw.msg( 'rcfilters-filter-userExpLevel-newcomer-description' ) + description: mw.msg( 'rcfilters-filter-userExpLevel-newcomer-description' ), + conflicts: [ 'hideanons' ] }, { name: 'learner', label: mw.msg( 'rcfilters-filter-userExpLevel-learner-label' ), - description: mw.msg( 'rcfilters-filter-userExpLevel-learner-description' ) + description: mw.msg( 'rcfilters-filter-userExpLevel-learner-description' ), + conflicts: [ 'hideanons' ] }, { name: 'experienced', label: mw.msg( 'rcfilters-filter-userExpLevel-experienced-label' ), - description: mw.msg( 'rcfilters-filter-userExpLevel-experienced-description' ) + description: mw.msg( 'rcfilters-filter-userExpLevel-experienced-description' ), + conflicts: [ 'hideanons' ] } ] }, @@ -80,6 +85,7 @@ // the functionality to the UI, whether we are dealing with 2 // parameters in the group or more. type: 'send_unselected_if_any', + fullCoverage: true, filters: [ { name: 'hidemyself', @@ -96,6 +102,7 @@ automated: { title: mw.msg( 'rcfilters-filtergroup-automated' ), type: 'send_unselected_if_any', + fullCoverage: true, filters: [ { name: 'hidebots', @@ -114,6 +121,7 @@ significance: { title: mw.msg( 'rcfilters-filtergroup-significance' ), type: 'send_unselected_if_any', + fullCoverage: true, filters: [ { name: 'hideminor', @@ -130,6 +138,7 @@ changetype: { title: mw.msg( 'rcfilters-filtergroup-changetype' ), type: 'send_unselected_if_any', + fullCoverage: true, filters: [ { name: 'hidepageedits', @@ -162,9 +171,6 @@ $( '.rcoptions' ).before( filtersWidget.$element ); $( 'body' ).append( $overlay ); - // Initialize values - controller.initialize(); - // HACK: Remove old-style filter links for filters handled by the widget // Ideally the widget would handle all filters and we'd just remove .rcshowhide entirely $( '.rcshowhide' ).children().each( function () { diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.CapsuleItemWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.CapsuleItemWidget.less index 4ea88b5799..8a9ad54eff 100644 --- a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.CapsuleItemWidget.less +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.CapsuleItemWidget.less @@ -7,4 +7,8 @@ // Fix the positioning of the popup itself margin-top: 1em; } + + &-muted { + opacity: 0.5; + } } diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.less index c409d58857..6c11cdb90a 100644 --- a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.less +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterCapsuleMultiselectWidget.less @@ -14,10 +14,6 @@ color: #54595d; } - &-item-inactive { - opacity: 0.5; - } - &-emptyFilters { color: #72777d; } diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemWidget.less index 9f9e6fca3a..a8744164e0 100644 --- a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemWidget.less +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemWidget.less @@ -20,7 +20,7 @@ margin: 0 !important; } - &-inactive { + &-muted { opacity: 0.5; } } diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CapsuleItemWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CapsuleItemWidget.js index ca47f16b5d..525f7186ac 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CapsuleItemWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CapsuleItemWidget.js @@ -57,6 +57,8 @@ .addClass( 'mw-rcfilters-ui-capsuleItemWidget' ) .on( 'mouseover', this.onHover.bind( this, true ) ) .on( 'mouseout', this.onHover.bind( this, false ) ); + + this.setCurrentMuteState(); }; OO.inheritClass( mw.rcfilters.ui.CapsuleItemWidget, OO.ui.CapsuleItemWidget ); @@ -66,11 +68,19 @@ * Respond to model update event */ mw.rcfilters.ui.CapsuleItemWidget.prototype.onModelUpdate = function () { - // Deal with active/inactive capsule filter items + this.setCurrentMuteState(); + }; + + /** + * Set the current mute state for this item + */ + mw.rcfilters.ui.CapsuleItemWidget.prototype.setCurrentMuteState = function () { this.$element .toggleClass( - 'mw-rcfilters-ui-filterCapsuleMultiselectWidget-item-inactive', - !this.model.isActive() + 'mw-rcfilters-ui-capsuleItemWidget-muted', + this.model.isIncluded() || + this.model.isConflicted() || + this.model.isFullyCovered() ); }; diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemWidget.js index f9829d4c2e..9bf26d1a5e 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemWidget.js @@ -48,6 +48,7 @@ // Event this.checkboxWidget.connect( this, { userChange: 'onCheckboxChange' } ); this.model.connect( this, { update: 'onModelUpdate' } ); + this.model.getGroupModel().connect( this, { update: 'onGroupModelUpdate' } ); this.$element .addClass( 'mw-rcfilters-ui-filterItemWidget' ) @@ -79,12 +80,33 @@ mw.rcfilters.ui.FilterItemWidget.prototype.onModelUpdate = function () { this.checkboxWidget.setSelected( this.model.isSelected() ); + this.setCurrentMuteState(); + }; + + /** + * Respond to item group model update event + */ + mw.rcfilters.ui.FilterItemWidget.prototype.onGroupModelUpdate = function () { + this.setCurrentMuteState(); + }; + + /** + * Set the current mute state for this item + */ + mw.rcfilters.ui.FilterItemWidget.prototype.setCurrentMuteState = function () { this.$element.toggleClass( - 'mw-rcfilters-ui-filterItemWidget-inactive', - !this.model.isActive() + 'mw-rcfilters-ui-filterItemWidget-muted', + this.model.isConflicted() || + this.model.isIncluded() || + this.model.isFullyCovered() || + ( + // Item is also muted when any of the items in its group is active + this.model.getGroupModel().isActive() && + // But it isn't selected + !this.model.isSelected() + ) ); }; - /** * Get the name of this filter * 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 998817d792..884be8cd07 100644 --- a/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js +++ b/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js @@ -728,7 +728,6 @@ group1: { title: 'Group 1', type: 'send_unselected_if_any', - exclusionType: 'default', filters: [ { name: 'hidefilter1', @@ -772,257 +771,284 @@ ] } }, + defaultFilterRepresentation = { + // Group 1 and 2, "send_unselected_if_any", the values of the filters are "flipped" from the values of the parameters + hidefilter1: false, + hidefilter2: true, + hidefilter3: false, + hidefilter4: true, + hidefilter5: false, + hidefilter6: true + }, model = new mw.rcfilters.dm.FiltersViewModel(); model.initializeFilters( definition ); assert.deepEqual( - model.getFullState(), + model.getSelectedState(), { - // Group 1 - hidefilter1: { selected: true, active: true }, - hidefilter2: { selected: false, active: true }, - hidefilter3: { selected: true, active: true }, - // Group 2 - hidefilter4: { selected: false, active: true }, - hidefilter5: { selected: true, active: true }, - hidefilter6: { selected: false, active: true }, + hidefilter1: false, + hidefilter2: false, + hidefilter3: false, + hidefilter4: false, + hidefilter5: false, + hidefilter6: false }, - 'Initial state: all filters are active, and select states are default.' + 'Initial state: default filters are not selected (controller selects defaults explicitly).' ); - // Default behavior for 'exclusion' type with only 1 item selected, means that: - // - The items in the same group that are *not* selected are *not* active - // - Items in other groups are unaffected (all active) model.updateFilters( { hidefilter1: false, - hidefilter2: false, - hidefilter3: false, - hidefilter4: false, - hidefilter5: false, - hidefilter6: true + hidefilter3: false } ); - assert.deepEqual( - model.getFullState(), - { - // Group 1: not affected - hidefilter1: { selected: false, active: true }, - hidefilter2: { selected: false, active: true }, - hidefilter3: { selected: false, active: true }, - // Group 2: affected - hidefilter4: { selected: false, active: false }, - hidefilter5: { selected: false, active: false }, - hidefilter6: { selected: true, active: true }, - }, - 'Default exclusion behavior with 1 item selected in the group.' - ); - // Default behavior for 'exclusion' type with multiple items selected, but not all, means that: - // - The items in the same group that are *not* selected are *not* active - // - Items in other groups are unaffected (all active) - model.updateFilters( { - // Literally updating filters to create a clean state - hidefilter1: false, - hidefilter2: false, - hidefilter3: false, - hidefilter4: false, - hidefilter5: true, - hidefilter6: true - } ); - assert.deepEqual( - model.getFullState(), - { - // Group 1: not affected - hidefilter1: { selected: false, active: true }, - hidefilter2: { selected: false, active: true }, - hidefilter3: { selected: false, active: true }, - // Group 2: affected - hidefilter4: { selected: false, active: false }, - hidefilter5: { selected: true, active: true }, - hidefilter6: { selected: true, active: true }, - }, - 'Default exclusion behavior with multiple items (but not all) selected in the group.' - ); + model.setFiltersToDefaults(); - // Default behavior for 'exclusion' type with all items in the group selected, means that: - // - All items in the group are NOT active - // - Items in other groups are unaffected (all active) - model.updateFilters( { - // Literally updating filters to create a clean state - hidefilter1: false, - hidefilter2: false, - hidefilter3: false, - hidefilter4: true, - hidefilter5: true, - hidefilter6: true - } ); assert.deepEqual( - model.getFullState(), - { - // Group 1: not affected - hidefilter1: { selected: false, active: true }, - hidefilter2: { selected: false, active: true }, - hidefilter3: { selected: false, active: true }, - // Group 2: affected - hidefilter4: { selected: true, active: false }, - hidefilter5: { selected: true, active: false }, - hidefilter6: { selected: true, active: false }, - }, - 'Default exclusion behavior with all items in the group.' + model.getSelectedState(), + defaultFilterRepresentation, + 'Changing values of filters and then returning to defaults still results in default filters being selected.' ); } ); - QUnit.test( 'reapplyActiveFilters - "explicit" exclusion rules', function ( assert ) { + QUnit.test( 'Filter interaction: subsets', function ( assert ) { var definition = { group1: { title: 'Group 1', - type: 'send_unselected_if_any', - exclusionType: 'explicit', + type: 'string_options', filters: [ { name: 'filter1', - excludes: [ 'filter2', 'filter3' ], label: 'Show filter 1', - description: 'Description of Filter 1 in Group 1' + description: 'Description of Filter 1 in Group 1', + subset: [ 'filter2', 'filter5' ] }, { name: 'filter2', - excludes: [ 'filter3' ], label: 'Show filter 2', description: 'Description of Filter 2 in Group 1' }, { name: 'filter3', label: 'Show filter 3', - excludes: [ 'filter1' ], description: 'Description of Filter 3 in Group 1' - }, + } + ] + }, + group2: { + title: 'Group 2', + type: 'send_unselected_if_any', + filters: [ { name: 'filter4', label: 'Show filter 4', - description: 'Description of Filter 4 in Group 1' + description: 'Description of Filter 1 in Group 2', + subset: [ 'filter3', 'filter5' ] + }, + { + name: 'filter5', + label: 'Show filter 5', + description: 'Description of Filter 2 in Group 2' + }, + { + name: 'filter6', + label: 'Show filter 6', + description: 'Description of Filter 3 in Group 2' } ] } }, - defaultFilterRepresentation = { - // Group 1 and 2, "send_unselected_if_any", the values of the filters are "flipped" from the values of the parameters - hidefilter1: false, - hidefilter2: true, - hidefilter3: false, - hidefilter4: true, - hidefilter5: false, - hidefilter6: true, - // Group 3, "string_options", default values correspond to parameters and filters - filter7: false, - filter8: true, - filter9: false + baseFullState = { + filter1: { selected: false, conflicted: false, included: false }, + filter2: { selected: false, conflicted: false, included: false }, + filter3: { selected: false, conflicted: false, included: false }, + filter4: { selected: false, conflicted: false, included: false }, + filter5: { selected: false, conflicted: false, included: false }, + filter6: { selected: false, conflicted: false, included: false } }, model = new mw.rcfilters.dm.FiltersViewModel(); model.initializeFilters( definition ); + // Select a filter that has subset with another filter + model.updateFilters( { + filter1: true + } ); + model.reassessFilterInteractions( model.getItemByName( 'filter1' ) ); assert.deepEqual( model.getFullState(), - { - filter1: { selected: false, active: true }, - filter2: { selected: false, active: true }, - filter3: { selected: false, active: true }, - filter4: { selected: false, active: true } - }, - 'Initial state: all filters are active.' + $.extend( true, {}, baseFullState, { + filter1: { selected: true }, + filter2: { included: true }, + filter5: { included: true } + } ), + 'Filters with subsets are represented in the model.' ); - // "Explicit" behavior for 'exclusion' with one item checked: - // - Items in the 'excluded' list of the selected filter are inactive + // Select another filter that has a subset with the same previous filter model.updateFilters( { - // Literally updating filters to create a clean state - filter1: true, // Excludes 'hidefilter2', 'hidefilter3' - filter2: false, // Excludes 'hidefilter3' - filter3: false, // Excludes 'hidefilter1' - filter4: false // No exclusion list + filter4: true } ); + model.reassessFilterInteractions( model.getItemByName( 'filter4' ) ); assert.deepEqual( model.getFullState(), - { - filter1: { selected: true, active: true }, - filter2: { selected: false, active: false }, - filter3: { selected: false, active: false }, - filter4: { selected: false, active: true } - }, - '"Explicit" exclusion behavior with one item selected that has an exclusion list.' + $.extend( true, {}, baseFullState, { + filter1: { selected: true }, + filter2: { included: true }, + filter3: { included: true }, + filter4: { selected: true }, + filter5: { included: true } + } ), + 'Filters that have multiple subsets are represented.' ); - // "Explicit" behavior for 'exclusion' with two item checked: - // - Items in the 'excluded' list of each of the selected filter are inactive + // Remove one filter (but leave the other) that affects filter2 model.updateFilters( { - // Literally updating filters to create a clean state - filter1: true, // Excludes 'hidefilter2', 'hidefilter3' - filter2: false, // Excludes 'hidefilter3' - filter3: true, // Excludes 'hidefilter1' - filter4: false // No exclusion list + filter1: false } ); + model.reassessFilterInteractions( model.getItemByName( 'filter1' ) ); assert.deepEqual( model.getFullState(), - { - filter1: { selected: true, active: false }, - filter2: { selected: false, active: false }, - filter3: { selected: true, active: false }, - filter4: { selected: false, active: true } - }, - '"Explicit" exclusion behavior with two selected items that both have an exclusion list.' + $.extend( true, {}, baseFullState, { + filter2: { included: false }, + filter3: { included: true }, + filter4: { selected: true }, + filter5: { included: true } + } ), + 'Removing a filter only un-includes its subset if there is no other filter affecting.' ); - // "Explicit behavior" with two filters that exclude the same item - - // Two filters selected, both exclude 'hidefilter3' model.updateFilters( { - // Literally updating filters to create a clean state - filter1: true, // Excludes 'hidefilter2', 'hidefilter3' - filter2: true, // Excludes 'hidefilter3' - filter3: false, // Excludes 'hidefilter1' - filter4: false // No exclusion list + filter4: false } ); + model.reassessFilterInteractions( model.getItemByName( 'filter4' ) ); assert.deepEqual( model.getFullState(), - { - filter1: { selected: true, active: true }, - filter2: { selected: true, active: false }, // Excluded by filter1 - filter3: { selected: false, active: false }, // Excluded by both filter1 and filter2 - filter4: { selected: false, active: true } + baseFullState, + 'Removing all supersets also un-includes the subsets.' + ); + } ); + + QUnit.test( 'Filter interaction: full coverage', function ( assert ) { + var definition = { + group1: { + title: 'Group 1', + type: 'string_options', + fullCoverage: false, + filters: [ + { name: 'filter1' }, + { name: 'filter2' }, + { name: 'filter3' }, + ] + }, + group2: { + title: 'Group 2', + type: 'send_unselected_if_any', + fullCoverage: true, + filters: [ + { name: 'filter4' }, + { name: 'filter5' }, + { name: 'filter6' }, + ] + } }, - '"Explicit" exclusion behavior with two selected items that both exclude another item.' + isCapsuleItemMuted = function ( filterName ) { + var itemModel = model.getItemByName( filterName ), + groupModel = itemModel.getGroupModel(); + + // This is the logic inside the capsule widget + return ( + // The capsule item widget only appears if the item is selected + itemModel.isSelected() && + // Muted state is only valid if group is full coverage and all items are selected + groupModel.isFullCoverage() && groupModel.areAllSelected() + ); + }, + getCurrentItemsMutedState = function () { + return { + filter1: isCapsuleItemMuted( 'filter1' ), + filter2: isCapsuleItemMuted( 'filter2' ), + filter3: isCapsuleItemMuted( 'filter3' ), + filter4: isCapsuleItemMuted( 'filter4' ), + filter5: isCapsuleItemMuted( 'filter5' ), + filter6: isCapsuleItemMuted( 'filter6' ) + }; + }, + baseMuteState = { + filter1: false, + filter2: false, + filter3: false, + filter4: false, + filter5: false, + filter6: false + }, + model = new mw.rcfilters.dm.FiltersViewModel(); + + model.initializeFilters( definition ); + + // Starting state, no selection, all items are non-muted + assert.deepEqual( + getCurrentItemsMutedState(), + baseMuteState, + 'No selection - all items are non-muted' ); - // Unselect filter2: filter3 should still be excluded, because filter1 excludes it and is selected + // Select most (but not all) items in each group model.updateFilters( { - filter2: false, // Excludes 'hidefilter3' + filter1: true, + filter2: true, + filter4: true, + filter5: true } ); + + // Both groups have multiple (but not all) items selected, all items are non-muted assert.deepEqual( - model.getFullState(), - { - filter1: { selected: true, active: true }, - filter2: { selected: false, active: false }, // Excluded by filter1 - filter3: { selected: false, active: false }, // Still excluded by filter1 - filter4: { selected: false, active: true } - }, - '"Explicit" exclusion behavior unselecting one item that excludes another item, that is being excluded by a third active item.' + getCurrentItemsMutedState(), + baseMuteState, + 'Not all items in the group selected - all items are non-muted' ); - // Unselect filter1: filter3 should now be active, since both filters that exclude it are unselected + // Select all items in 'fullCoverage' group (group2) model.updateFilters( { - filter1: false, // Excludes 'hidefilter3' and 'hidefilter2' + filter6: true } ); + + // Group2 (full coverage) has all items selected, all its items are muted assert.deepEqual( - model.getFullState(), - { - filter1: { selected: false, active: true }, - filter2: { selected: false, active: true }, // No longer excluded by filter1 - filter3: { selected: false, active: true }, // No longer excluded by either filter1 nor filter2 - filter4: { selected: false, active: true } - }, - '"Explicit" exclusion behavior unselecting both items that excluded the same third item.' + getCurrentItemsMutedState(), + $.extend( {}, baseMuteState, { + filter4: true, + filter5: true, + filter6: true + } ), + 'All items in \'full coverage\' group are selected - all items in the group are muted' + ); + + // Select all items in non 'fullCoverage' group (group1) + model.updateFilters( { + filter3: true + } ); + + // Group1 (full coverage) has all items selected, no items in it are muted (non full coverage) + assert.deepEqual( + getCurrentItemsMutedState(), + $.extend( {}, baseMuteState, { + filter4: true, + filter5: true, + filter6: true + } ), + 'All items in a non \'full coverage\' group are selected - none of the items in the group are muted' ); + // Uncheck an item from each group + model.updateFilters( { + filter3: false, + filter5: false + } ); + assert.deepEqual( + getCurrentItemsMutedState(), + baseMuteState, + 'Not all items in the group are checked - all items are non-muted regardless of group coverage' + ); } ); }( mediaWiki, jQuery ) );