* @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 */
/* 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;
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
*
};
/**
- * 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 ) );
*
* @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
* @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 */
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();
};
/**
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
*
};
/**
- * 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' );
}
};
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 ) );
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 */
/* 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 );
+ } );
+ }
+ } );
};
/**
* @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
}
} );
+ 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' );
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
*
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()
};
}
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'
// 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 ] );
}
}
}
/**
* 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();
};
/**
var obj = {};
obj[ filterName ] = isSelected;
+
this.filtersModel.updateFilters( obj );
this.updateURL();
this.updateChangesList();
+
+ // Check filter interactions
+ this.filtersModel.reassessFilterInteractions( this.filtersModel.getItemByName( filterName ) );
};
/**
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',
// ** 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' ]
}
]
},
// 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',
automated: {
title: mw.msg( 'rcfilters-filtergroup-automated' ),
type: 'send_unselected_if_any',
+ fullCoverage: true,
filters: [
{
name: 'hidebots',
significance: {
title: mw.msg( 'rcfilters-filtergroup-significance' ),
type: 'send_unselected_if_any',
+ fullCoverage: true,
filters: [
{
name: 'hideminor',
changetype: {
title: mw.msg( 'rcfilters-filtergroup-changetype' ),
type: 'send_unselected_if_any',
+ fullCoverage: true,
filters: [
{
name: 'hidepageedits',
$( '.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 () {
// Fix the positioning of the popup itself
margin-top: 1em;
}
+
+ &-muted {
+ opacity: 0.5;
+ }
}
color: #54595d;
}
- &-item-inactive {
- opacity: 0.5;
- }
-
&-emptyFilters {
color: #72777d;
}
margin: 0 !important;
}
- &-inactive {
+ &-muted {
opacity: 0.5;
}
}
.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 );
* 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()
);
};
// 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' )
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
*
group1: {
title: 'Group 1',
type: 'send_unselected_if_any',
- exclusionType: 'default',
filters: [
{
name: 'hidefilter1',
]
}
},
+ 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 ) );