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
+ itemInSubset.toggleIncluded(
+ // If any of itemInSubset's supersets are selected, this item
+ // is included
+ itemInSubset.getSuperset().some( function ( supersetName ) {
+ return ( model.getItemByName( supersetName ).isSelected() );
+ } )
);
} );
- } 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()
- );
- } )
- ) {
- // 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 );
+ } );
+ }
+ } );
+
+ // Check for conflicts
+ // In this case, we must go over all items, since
+ // conflicts are bidirectional and depend not only on
+ // individual items, but also on the selected states of
+ // the groups they're in.
+ this.getItems().forEach( function ( filterItem ) {
+ var inConflict = false,
+ filterItemGroup = filterItem.getGroupModel();
+
+ // For each item, see if that item is still conflicting
+ $.each( model.groups, function ( groupName, groupModel ) {
+ if ( filterItem.getGroupName() === groupName ) {
+ // Check inside the group
+ inConflict = groupModel.areAnySelectedInConflictWith( filterItem );
+ } else {
+ // According to the spec, if two items conflict from two different
+ // groups, the conflict only lasts if the groups **only have selected
+ // items that are conflicting**. If a group has selected items that
+ // are conflicting and non-conflicting, the scope of the result has
+ // expanded enough to completely remove the conflict.
+
+ // For example, see two groups with conflicts:
+ // userExpLevel: [
+ // {
+ // name: 'experienced',
+ // conflicts: [ 'unregistered' ]
+ // }
+ // ],
+ // registration: [
+ // {
+ // name: 'registered',
+ // },
+ // {
+ // name: 'unregistered',
+ // }
+ // ]
+ // If we select 'experienced', then 'unregistered' is in conflict (and vice versa),
+ // because, inherently, 'experienced' filter only includes registered users, and so
+ // both filters are in conflict with one another.
+ // However, the minute we select 'registered', the scope of our results
+ // has expanded to no longer have a conflict with 'experienced' filter, and
+ // so the conflict is removed.
+
+ // In our case, we need to check if the entire group conflicts with
+ // the entire item's group, so we follow the above spec
+ inConflict = (
+ // The foreign group is in conflict with this item
+ groupModel.areAllSelectedInConflictWith( filterItem ) &&
+ // Every selected member of the item's own group is also
+ // in conflict with the other group
+ filterItemGroup.getSelectedItems().every( function ( otherGroupItem ) {
+ return groupModel.areAllSelectedInConflictWith( otherGroupItem );
+ } )
+ );
}
+
+ // If we're in conflict, this will return 'false' which
+ // will break the loop. Otherwise, we're not in conflict
+ // and the loop continues
+ return !inConflict;
} );
- }
+
+ // Toggle the item state
+ filterItem.toggleConflicted( inConflict );
+ } );
};
/**
* @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( {
+ 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'
/**
* Find items whose labels match the given string
*
- * @param {string} str Search string
+ * @param {string} query Search string
* @return {Object} An object of items to show
* arranged by their group names
*/
- mw.rcfilters.dm.FiltersViewModel.prototype.findMatches = function ( str ) {
+ mw.rcfilters.dm.FiltersViewModel.prototype.findMatches = function ( query ) {
var i,
+ groupTitle,
result = {},
items = this.getItems();
// Normalize so we can search strings regardless of case
- str = str.toLowerCase();
+ query = query.toLowerCase();
+
+ // item label starting with the query string
for ( i = 0; i < items.length; i++ ) {
- if ( items[ i ].getLabel().toLowerCase().indexOf( str ) > -1 ) {
- result[ items[ i ].getGroup() ] = result[ items[ i ].getGroup() ] || [];
- result[ items[ i ].getGroup() ].push( items[ i ] );
+ if ( items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ) {
+ 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 = 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 ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
+ result[ items[ i ].getGroupName() ].push( items[ i ] );
+ }
}
}
+
return result;
};