OO.EmitterList.call( this );
this.groups = {};
+ this.excludedByMap = {};
// Events
- this.aggregate( { update: 'itemUpdate' } );
+ this.aggregate( { update: 'filterItemUpdate' } );
+ this.connect( this, { filterItemUpdate: 'onFilterItemUpdate' } );
};
/* Initialization */
/* Methods */
+ /**
+ * Respond to filter item change.
+ *
+ * @param {mw.rcfilters.dm.FilterItem} item Updated filter
+ * @fires itemUpdate
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.onFilterItemUpdate = function ( item ) {
+ // Reapply the active state of filters
+ this.reapplyActiveFilters( item );
+
+ this.emit( 'itemUpdate', item );
+ };
+
+ /**
+ * 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.groups[ group ].exclusionType ||
+ this.groups[ group ].exclusionType === '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.groups[ group ].filters.filter( function ( filterItem ) {
+ return filterItem.isSelected();
+ } ).length;
+
+ this.groups[ group ].filters.forEach( function ( filterItem ) {
+ filterItem.toggleActive(
+ selectedItemsCount > 0 ?
+ // If some items are selected
+ (
+ selectedItemsCount === model.groups[ group ].filters.length ?
+ // 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.groups[ group ].exclusionType === '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()
+ );
+ } )
+ ) {
+ // Only change the state for filters that aren't
+ // also affected by other excluding selected filters
+ filterItem.toggleActive( !item.isSelected() );
+ }
+ } );
+ }
+ };
+
/**
* 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,
+ var i, filterItem, excludedFilters,
model = this,
- items = [];
+ items = [],
+ addToMap = function ( excludedFilters ) {
+ excludedFilters.forEach( function ( filterName ) {
+ model.excludedByMap[ filterName ] = model.excludedByMap[ filterName ] || [];
+ model.excludedByMap[ filterName ].push( filterItem.getName() );
+ } );
+ };
// Reset
this.clearItems();
this.groups = {};
+ this.excludedByMap = {};
$.each( filters, function ( group, data ) {
model.groups[ group ] = model.groups[ group ] || {};
model.groups[ group ].title = data.title;
model.groups[ group ].type = data.type;
model.groups[ group ].separator = data.separator || '|';
+ model.groups[ group ].exclusionType = data.exclusionType || 'default';
for ( i = 0; i < data.filters.length; i++ ) {
+ excludedFilters = data.filters[ i ].excludes || [];
+
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
+ selected: data.filters[ i ].selected,
+ excludes: excludedFilters
} );
+ // Map filters and what excludes them
+ addToMap( excludedFilters );
+
model.groups[ group ].filters.push( filterItem );
items.push( filterItem );
}
};
/**
- * Get the current state of the filters
+ * Get the current state of the filters.
+ *
+ * Checks whether the filter group is active. This means at least one
+ * filter is selected, but not all filters are selected.
*
- * @return {Object} Filters current state
+ * @param {string} groupName Group name
+ * @return {boolean} Filter group is active
*/
- mw.rcfilters.dm.FiltersViewModel.prototype.getState = function () {
+ mw.rcfilters.dm.FiltersViewModel.prototype.isFilterGroupActive = function ( groupName ) {
+ var count = 0,
+ filters = this.groups[ groupName ].filters;
+
+ filters.forEach( function ( filterItem ) {
+ count += Number( filterItem.isSelected() );
+ } );
+
+ return (
+ count > 0 &&
+ count < filters.length
+ );
+ };
+
+ /**
+ * 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
+ *
+ * @param {string} name Parameter name
+ * @return {number|string} Parameter value
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.getParamValue = function ( name ) {
+ return this.parameters[ name ];
+ };
+
+ /**
+ * Get the current selected state of the filters
+ *
+ * @return {Object} Filters selected state
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.getSelectedState = function () {
var i,
items = this.getItems(),
result = {};
return result;
};
+ /**
+ * Get the current full state of the filters
+ *
+ * @return {Object} Filters full state
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.getFullState = function () {
+ var i,
+ items = this.getItems(),
+ result = {};
+
+ for ( i = 0; i < items.length; i++ ) {
+ result[ items[ i ].getName() ] = {
+ selected: items[ i ].isSelected(),
+ active: items[ i ].isActive()
+ };
+ }
+
+ return result;
+ };
+
/**
* Analyze the groups and their filters and output an object representing
* the state of the parameters they represent.
model = this,
base = this.getParametersFromFilters(),
// Start with current state
- result = this.getState();
+ result = this.getSelectedState();
params = $.extend( {}, base, params );
);
assert.deepEqual(
- model.getState(),
+ model.getSelectedState(),
{
group1filter1: false,
group1filter2: false,
group3filter1: true
} );
assert.deepEqual(
- model.getState(),
+ model.getSelectedState(),
{
group1filter1: true,
group1filter2: false,
// This can simulate separate filters in the same group being hidden different
// ways (e.g. preferences and URL).
assert.deepEqual(
- model.getState(),
+ model.getSelectedState(),
{
hidefilter1: false, // The text is "show filter 1"
hidefilter2: true, // The text is "show filter 2"
// Simulates minor edits being hidden in preferences, then unhidden via URL
// override.
assert.deepEqual(
- model.getState(),
+ model.getSelectedState(),
{
hidefilter1: false, // The text is "show filter 1"
hidefilter2: false, // The text is "show filter 2"
} )
);
assert.deepEqual(
- model.getState(),
+ model.getSelectedState(),
{
hidefilter1: false, // The text is "show filter 1"
hidefilter2: false, // The text is "show filter 2"
} )
);
assert.deepEqual(
- model.getState(),
+ model.getSelectedState(),
{
hidefilter1: false, // The text is "show filter 1"
hidefilter2: false, // The text is "show filter 2"
} )
);
assert.deepEqual(
- model.getState(),
+ model.getSelectedState(),
{
hidefilter1: false, // The text is "show filter 1"
hidefilter2: false, // The text is "show filter 2"
} )
);
assert.deepEqual(
- model.getState(),
+ model.getSelectedState(),
{
hidefilter1: false, // The text is "show filter 1"
hidefilter2: false, // The text is "show filter 2"
} )
);
assert.deepEqual(
- model.getState(),
+ model.getSelectedState(),
{
hidefilter1: false, // The text is "show filter 1"
hidefilter2: false, // The text is "show filter 2"
'If any value is "all", the only value is "all".'
);
} );
+
+ QUnit.test( 'reapplyActiveFilters - "default" exclusion rules', function ( assert ) {
+ var definition = {
+ group1: {
+ title: 'Group 1',
+ type: 'send_unselected_if_any',
+ exclusionType: 'default',
+ 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 );
+
+ assert.deepEqual(
+ model.getFullState(),
+ {
+ // Group 1
+ hidefilter1: { selected: false, active: true },
+ hidefilter2: { selected: false, active: true },
+ hidefilter3: { selected: false, active: true },
+ // Group 2
+ hidefilter4: { selected: false, active: true },
+ hidefilter5: { selected: false, active: true },
+ hidefilter6: { selected: false, active: true },
+ },
+ 'Initial state: all filters are active.'
+ );
+
+ // 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
+ } );
+ 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.'
+ );
+
+ // 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.'
+ );
+ } );
+
+ QUnit.test( 'reapplyActiveFilters - "explicit" exclusion rules', function ( assert ) {
+ var definition = {
+ group1: {
+ title: 'Group 1',
+ type: 'send_unselected_if_any',
+ exclusionType: 'explicit',
+ filters: [
+ {
+ name: 'filter1',
+ excludes: [ 'filter2', 'filter3' ],
+ label: 'Show filter 1',
+ description: 'Description of Filter 1 in Group 1'
+ },
+ {
+ 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'
+ },
+ {
+ name: 'filter4',
+ label: 'Show filter 4',
+ description: 'Description of Filter 4 in Group 1'
+ }
+ ]
+ }
+ },
+ model = new mw.rcfilters.dm.FiltersViewModel();
+
+ model.initializeFilters( definition );
+
+ 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.'
+ );
+
+ // "Explicit" behavior for 'exclusion' with one item checked:
+ // - Items in the 'excluded' list of the selected filter are inactive
+ 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
+ } );
+ 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.'
+ );
+
+ // "Explicit" behavior for 'exclusion' with two item checked:
+ // - Items in the 'excluded' list of each of the selected filter are inactive
+ 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
+ } );
+ 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.'
+ );
+
+ // "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
+ } );
+ 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 }
+ },
+ '"Explicit" exclusion behavior with two selected items that both exclude another item.'
+ );
+
+ // Unselect filter2: filter3 should still be excluded, because filter1 excludes it and is selected
+ model.updateFilters( {
+ filter2: false, // Excludes 'hidefilter3'
+ } );
+ 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.'
+ );
+
+ // Unselect filter1: filter3 should now be active, since both filters that exclude it are unselected
+ model.updateFilters( {
+ filter1: false, // Excludes 'hidefilter3' and 'hidefilter2'
+ } );
+ 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.'
+ );
+
+ } );
}( mediaWiki, jQuery ) );