* @cfg {string} [separator='|'] Value separator for 'string_options' groups
* @cfg {boolean} [active] Group is active
* @cfg {boolean} [fullCoverage] This filters in this group collectively cover all results
+ * @cfg {Object} [conflicts] Defines the conflicts for this filter group
*/
mw.rcfilters.dm.FilterGroup = function MwRcfiltersDmFilterGroup( name, config ) {
config = config || {};
this.active = !!config.active;
this.fullCoverage = !!config.fullCoverage;
+ this.conflicts = config.conflicts || {};
+
this.aggregate( { update: 'filterItemUpdate' } );
this.connect( this, { filterItemUpdate: 'onFilterItemUpdate' } );
};
return this.name;
};
+ /**
+ * Get the conflicts associated with the entire group.
+ * Conflict object is set up by filter name keys and conflict
+ * definition. For example:
+ * [
+ * {
+ * filterName: {
+ * filter: filterName,
+ * group: group1
+ * }
+ * },
+ * {
+ * filterName2: {
+ * filter: filterName2,
+ * group: group2
+ * }
+ * }
+ * ]
+ * @return {Object} Conflict definition
+ */
+ mw.rcfilters.dm.FilterGroup.prototype.getConflicts = function () {
+ return this.conflicts;
+ };
+
+ /**
+ * Set conflicts for this group. See #getConflicts for the expected
+ * structure of the definition.
+ *
+ * @param {Object} conflicts Conflicts for this group
+ */
+ mw.rcfilters.dm.FilterGroup.prototype.setConflicts = function ( conflicts ) {
+ this.conflicts = conflicts;
+ };
+
+ /**
+ * 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.FilterGroup.prototype.existsInConflicts = function ( filterItem ) {
+ return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
+ };
+
/**
* Check whether there are any items selected
*
mw.rcfilters.dm.FilterGroup.prototype.areAllSelectedInConflictWith = function ( filterItem ) {
var selectedItems = this.getSelectedItems( filterItem );
- return selectedItems.length > 0 && selectedItems.every( function ( selectedFilter ) {
- return selectedFilter.existsInConflicts( filterItem );
- } );
+ return selectedItems.length > 0 &&
+ (
+ // The group as a whole is in conflict with this item
+ this.existsInConflicts( filterItem ) ||
+ // All selected items are in conflict individually
+ selectedItems.every( function ( selectedFilter ) {
+ return selectedFilter.existsInConflicts( filterItem );
+ } )
+ );
};
/**
mw.rcfilters.dm.FilterGroup.prototype.areAnySelectedInConflictWith = function ( filterItem ) {
var selectedItems = this.getSelectedItems( filterItem );
- return selectedItems.length > 0 && selectedItems.some( function ( selectedFilter ) {
- return selectedFilter.existsInConflicts( filterItem );
- } );
+ return selectedItems.length > 0 && (
+ // The group as a whole is in conflict with this item
+ this.existsInConflicts( filterItem ) ||
+ // Any selected items are in conflict individually
+ selectedItems.some( function ( selectedFilter ) {
+ return selectedFilter.existsInConflicts( filterItem );
+ } )
+ );
};
/**
* selected, makes inactive.
* @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
+ * @cfg {Object} [conflicts] Defines the conflicts for this filter
* @cfg {string} [cssClass] The class identifying the results that match this filter
*/
mw.rcfilters.dm.FilterItem = function MwRcfiltersDmFilterItem( name, groupModel, config ) {
// Interaction definitions
this.subset = config.subset || [];
- this.conflicts = config.conflicts || [];
+ this.conflicts = config.conflicts || {};
this.superset = [];
// Interaction states
/**
* Get filter conflicts
*
- * @return {string[]} Filter conflicts
+ * Conflict object is set up by filter name keys and conflict
+ * definition. For example:
+ * {
+ * filterName: {
+ * filter: filterName,
+ * group: group1
+ * }
+ * filterName2: {
+ * filter: filterName2,
+ * group: group2
+ * }
+ * }
+ *
+ * @return {Object} Filter conflicts
*/
mw.rcfilters.dm.FilterItem.prototype.getConflicts = function () {
- return this.conflicts;
+ return $.extend( {}, this.conflicts, this.getGroupModel().getConflicts() );
};
/**
- * Set filter conflicts
+ * Set conflicts for this filter. See #getConflicts for the expected
+ * structure of the definition.
*
- * @param {string[]} conflicts Filter conflicts
+ * @param {Object} conflicts Conflicts for this filter
*/
mw.rcfilters.dm.FilterItem.prototype.setConflicts = function ( conflicts ) {
- this.conflicts = conflicts || [];
+ this.conflicts = conflicts || {};
};
/**
* @return {boolean} This item has a conflict with the given item
*/
mw.rcfilters.dm.FilterItem.prototype.existsInConflicts = function ( filterItem ) {
- return this.conflicts.indexOf( filterItem.getName() ) > -1;
+ return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
};
/**
* @param {Array} filters Filter group definition
*/
mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filters ) {
- var i, filterItem, selectedFilterNames,
+ var i, filterItem, selectedFilterNames, filterConflictResult, groupConflictResult,
model = this,
items = [],
+ supersetMap = {},
+ groupConflictMap = {},
+ filterConflictMap = {},
addArrayElementsUnique = function ( arr, elements ) {
elements = Array.isArray( elements ) ? elements : [ elements ];
return arr;
},
- conflictMap = {},
- supersetMap = {};
+ expandConflictDefinitions = function ( obj ) {
+ var result = {};
+
+ $.each( obj, function ( group, conflicts ) {
+ var adjustedConflicts = {};
+ conflicts.forEach( function ( conflict ) {
+ if ( conflict.filter ) {
+ adjustedConflicts[ conflict.filter ] = conflict;
+ } else {
+ // This conflict is for an entire group. Split it up to
+ // represent each filter
+
+ // Get the relevant group items
+ model.groups[ conflict.group ].getItems().forEach( function ( groupItem ) {
+ // Rebuild the conflict
+ adjustedConflicts[ groupItem.getName() ] = $.extend( {}, conflict, { filter: groupItem.getName() } );
+ } );
+ }
+ } );
+
+ result[ group ] = adjustedConflicts;
+ } );
+
+ return result;
+ };
// Reset
this.clearItems();
} );
}
+ if ( data.conflicts ) {
+ groupConflictMap[ group ] = data.conflicts;
+ }
+
selectedFilterNames = [];
for ( i = 0; i < data.filters.length; i++ ) {
data.filters[ i ].subset = data.filters[ i ].subset || [];
} );
}
- // 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.
+ // Store conflicts
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()
- );
- } );
+ filterConflictMap[ data.filters[ i ].name ] = data.filters[ i ].conflicts;
}
if ( data.type === 'send_unselected_if_any' ) {
}
} );
- 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() ] );
+ // Expand conflicts
+ groupConflictResult = expandConflictDefinitions( groupConflictMap );
+ filterConflictResult = expandConflictDefinitions( filterConflictMap );
+ // Set conflicts for groups
+ $.each( groupConflictResult, function ( group, conflicts ) {
+ model.groups[ group ].setConflicts( conflicts );
+ } );
+
+ items.forEach( function ( filterItem ) {
// Apply the superset map
filterItem.setSuperset( supersetMap[ filterItem.getName() ] );
+
+ // set conflicts for item
+ if ( filterConflictResult[ filterItem.getName() ] ) {
+ filterItem.setConflicts( filterConflictResult[ filterItem.getName() ] );
+ }
} );
// Add items to the model
QUnit.test( 'Initializing filter item', function ( assert ) {
var item,
- group1 = new mw.rcfilters.dm.FilterGroup( 'group1' );
+ group1 = new mw.rcfilters.dm.FilterGroup( 'group1' ),
+ group2 = new mw.rcfilters.dm.FilterGroup( 'group2' );
item = new mw.rcfilters.dm.FilterItem( 'filter1', group1 );
assert.equal(
'filter1',
group1,
{
- conflicts: [ 'conflict1', 'conflict2', 'conflict3' ]
+ conflicts: {
+ conflict1: { group: 'group2', filter: 'conflict1' },
+ conflict2: { group: 'group2', filter: 'conflict2' },
+ conflict3: { group: 'group2', filter: 'conflict3' }
+ }
}
);
assert.deepEqual(
item.getConflicts(),
- [ 'conflict1', 'conflict2', 'conflict3' ],
+ {
+ conflict1: { group: 'group2', filter: 'conflict1' },
+ conflict2: { group: 'group2', filter: 'conflict2' },
+ conflict3: { group: 'group2', filter: 'conflict3' }
+ },
'Conflict information is retained.'
);
assert.equal(
// TODO: Consider allowing for either a FilterItem or a filter name
// in this method, so it is consistent with the subset one
- item.existsInConflicts( new mw.rcfilters.dm.FilterItem( 'conflict1', group1 ) ),
+ item.existsInConflicts( new mw.rcfilters.dm.FilterItem( 'conflict1', group2 ) ),
true,
'Specific item exists in conflicts.'
);
name: 'filter1',
label: '1',
description: '1',
- conflicts: [ 'filter2', 'filter4' ]
+ conflicts: [ { group: 'group2' } ]
},
{
name: 'filter2',
label: '2',
description: '2',
- conflicts: [ 'filter6' ]
+ conflicts: [ { group: 'group2', filter: 'filter6' } ]
},
{
name: 'filter3',
name: 'group2',
title: 'Group 2',
type: 'send_unselected_if_any',
+ conflicts: [ { group: 'group1', filter: 'filter1' } ],
filters: [
{
name: 'filter4',
{
name: 'filter5',
label: '5',
- description: '5',
- conflicts: [ 'filter3' ]
+ description: '5'
},
{
name: 'filter6',
label: '6',
- description: '6'
+ description: '6',
+ conflicts: [ { group: 'group1', filter: 'filter2' } ]
}
]
} ],
'Initial state: no conflicts because no selections.'
);
- // Select a filter that has a conflict with another
+ // Select a filter that has a conflict with an entire group
model.toggleFiltersSelected( {
- filter1: true // conflicts: filter2, filter4
+ filter1: true // conflicts: entire of group 2 ( filter4, filter5, filter6)
} );
model.reassessFilterInteractions( model.getItemByName( 'filter1' ) );
model.getFullState(),
$.extend( true, {}, baseFullState, {
filter1: { selected: true },
- filter2: { conflicted: true },
- filter4: { conflicted: true }
+ filter4: { conflicted: true },
+ filter5: { conflicted: true },
+ filter6: { conflicted: true }
} ),
- 'Selecting a filter set its conflicts list as "conflicted".'
+ 'Selecting a filter that conflicts with a group sets all the conflicted group items as "conflicted".'
);
// Select one of the conflicts (both filters are now conflicted and selected)
model.getFullState(),
$.extend( true, {}, baseFullState, {
filter1: { selected: true, conflicted: true },
- filter2: { conflicted: true },
- filter4: { selected: true, conflicted: true }
+ filter4: { selected: true, conflicted: true },
+ filter5: { conflicted: true },
+ filter6: { conflicted: true }
} ),
- 'Selecting a conflicting filter sets both sides to conflicted and selected.'
+ 'Selecting a conflicting filter inside a group, sets both sides to conflicted and selected.'
);
- // Select another filter from filter4 group, meaning:
- // now filter1 no longer conflicts with filter4
+ // Reset
+ model = new mw.rcfilters.dm.FiltersViewModel();
+ model.initializeFilters( definition );
+
+ // Select a filter that has a conflict with a specific filter
+ model.toggleFiltersSelected( {
+ filter2: true // conflicts: filter6
+ } );
+
+ model.reassessFilterInteractions( model.getItemByName( 'filter2' ) );
+
+ assert.deepEqual(
+ model.getFullState(),
+ $.extend( true, {}, baseFullState, {
+ filter2: { selected: true },
+ filter6: { conflicted: true }
+ } ),
+ 'Selecting a filter that conflicts with another filter sets the other as "conflicted".'
+ );
+
+ // Select the conflicting filter
model.toggleFiltersSelected( {
filter6: true // conflicts: filter2
} );
+
model.reassessFilterInteractions( model.getItemByName( 'filter6' ) );
assert.deepEqual(
model.getFullState(),
$.extend( true, {}, baseFullState, {
- filter1: { selected: true, conflicted: false }, // No longer conflicts (filter4 is not the only in the group)
- filter2: { conflicted: true }, // While not selected, still in conflict with filter1, which is selected
- filter4: { selected: true, conflicted: false }, // No longer conflicts with filter1
- filter6: { selected: true, conflicted: false }
+ filter2: { selected: true, conflicted: true },
+ filter6: { selected: true, conflicted: true },
+ // This is added to the conflicts because filter6 is part of group2,
+ // who is in conflict with filter1; note that filter2 also conflicts
+ // with filter6 which means that filter1 conflicts with filter6 (because it's in group2)
+ // and also because its **own sibling** (filter2) is **also** in conflict with the
+ // selected items in group2 (filter6)
+ filter1: { conflicted: true }
+ } ),
+ 'Selecting a conflicting filter with an individual filter, sets both sides to conflicted and selected.'
+ );
+
+ // Now choose a non-conflicting filter from the group
+ model.toggleFiltersSelected( {
+ filter5: true
+ } );
+
+ model.reassessFilterInteractions( model.getItemByName( 'filter5' ) );
+
+ assert.deepEqual(
+ model.getFullState(),
+ $.extend( true, {}, baseFullState, {
+ filter2: { selected: true },
+ filter6: { selected: true },
+ filter5: { selected: true }
+ // Filter6 and filter1 are no longer in conflict because
+ // filter5, while it is in conflict with filter1, it is
+ // not in conflict with filter2 - and since filter2 is
+ // selected, it removes the conflict bidirectionally
+ } ),
+ 'Selecting a non-conflicting filter within the group of a conflicting filter removes the conflicts.'
+ );
+
+ // Followup on the previous test, unselect filter2 so filter1
+ // is now the only one selected in its own group, and since
+ // it is in conflict with the entire of group2, it means
+ // filter1 is once again conflicted
+ model.toggleFiltersSelected( {
+ filter2: false
+ } );
+
+ model.reassessFilterInteractions( model.getItemByName( 'filter2' ) );
+
+ assert.deepEqual(
+ model.getFullState(),
+ $.extend( true, {}, baseFullState, {
+ filter1: { conflicted: true },
+ filter6: { selected: true },
+ filter5: { selected: true }
} ),
- 'Selecting a non-conflicting filter from a conflicting group removes the conflict'
+ 'Unselecting an item that did not conflict returns the conflict state.'
);
+
+ // Followup #2: Now actually select filter1, and make everything conflicted
+ model.toggleFiltersSelected( {
+ filter1: true
+ } );
+
+ model.reassessFilterInteractions( model.getItemByName( 'filter1' ) );
+
+ assert.deepEqual(
+ model.getFullState(),
+ $.extend( true, {}, baseFullState, {
+ filter1: { selected: true, conflicted: true },
+ filter6: { selected: true, conflicted: true },
+ filter5: { selected: true, conflicted: true },
+ filter4: { conflicted: true } // Not selected but conflicted because it's in group2
+ } ),
+ 'Selecting an item that conflicts with a whole group makes all selections in that group conflicted.'
+ );
+
+ /* Simple case */
+ // Reset
+ model = new mw.rcfilters.dm.FiltersViewModel();
+ model.initializeFilters( definition );
+
+ // Select a filter that has a conflict with a specific filter
+ model.toggleFiltersSelected( {
+ filter2: true // conflicts: filter6
+ } );
+
+ model.reassessFilterInteractions( model.getItemByName( 'filter2' ) );
+
+ assert.deepEqual(
+ model.getFullState(),
+ $.extend( true, {}, baseFullState, {
+ filter2: { selected: true },
+ filter6: { conflicted: true }
+ } ),
+ 'Simple case: Selecting a filter that conflicts with another filter sets the other as "conflicted".'
+ );
+
+ model.toggleFiltersSelected( {
+ filter3: true // conflicts: filter6
+ } );
+
+ model.reassessFilterInteractions( model.getItemByName( 'filter3' ) );
+
+ assert.deepEqual(
+ model.getFullState(),
+ $.extend( true, {}, baseFullState, {
+ filter2: { selected: true },
+ filter3: { selected: true }
+ } ),
+ 'Simple case: Selecting a filter that is not in conflict removes the conflict.'
+ );
+
} );
QUnit.test( 'Filter highlights', function ( assert ) {