OO.EmitterList.call( this );
this.groups = {};
+ this.defaultParams = {};
+ this.defaultFiltersEmpty = null;
// Events
- this.aggregate( { update: 'itemUpdate' } );
+ this.aggregate( { update: 'filterItemUpdate' } );
+ this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
};
/* Initialization */
/* Methods */
+ /**
+ * Re-assess the states of filter items based on the interactions between them
+ *
+ * @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.reassessFilterInteractions = function ( item ) {
+ var allSelected,
+ model = this,
+ iterationItems = item !== undefined ? [ item ] : this.getItems();
+
+ iterationItems.forEach( function ( checkedItem ) {
+ var allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ),
+ groupModel = checkedItem.getGroupModel();
+
+ // Check for subsets (included filters) plus the item itself:
+ allCheckedItems.forEach( function ( filterItemName ) {
+ var itemInSubset = model.getItemByName( filterItemName );
+
+ itemInSubset.toggleIncluded(
+ // If any of itemInSubset's supersets are selected, this item
+ // is included
+ itemInSubset.getSuperset().some( function ( supersetName ) {
+ return ( model.getItemByName( supersetName ).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 );
+ } );
+ };
+
/**
* 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, selectedFilterNames,
model = this,
- items = [];
+ items = [],
+ 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 = {};
$.each( filters, function ( group, data ) {
- model.groups[ group ] = model.groups[ group ] || {};
- model.groups[ group ].filters = model.groups[ group ].filters || [];
-
- model.groups[ group ].title = data.title;
- model.groups[ group ].type = data.type;
- model.groups[ group ].separator = data.separator || '|';
+ if ( !model.groups[ group ] ) {
+ model.groups[ group ] = new mw.rcfilters.dm.FilterGroup( group, {
+ type: data.type,
+ title: data.title,
+ separator: data.separator,
+ fullCoverage: !!data.fullCoverage
+ } );
+ }
+ selectedFilterNames = [];
for ( i = 0; i < data.filters.length; i++ ) {
- 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
+ subset: data.filters[ i ].subset
} );
- model.groups[ group ].filters.push( filterItem );
+ // 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
+ // For this group type, parameter values are direct
+ model.defaultParams[ data.filters[ i ].name ] = Number( !!data.filters[ i ].default );
+ } else if (
+ data.type === 'string_options' &&
+ data.filters[ i ].default
+ ) {
+ selectedFilterNames.push( data.filters[ i ].name );
+ }
+
+ model.groups[ group ].addItems( filterItem );
items.push( filterItem );
}
+
+ if ( data.type === 'string_options' ) {
+ // Store the default parameter group state
+ // For this group, the parameter is group name and value is the names
+ // of selected items
+ model.defaultParams[ group ] = model.sanitizeStringOptionGroup( group, selectedFilterNames ).join( model.groups[ group ].getSeparator() );
+ }
+ } );
+
+ 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' );
};
};
/**
- * Get the object that defines groups and their filter items.
- * The structure of this response:
- * {
- * groupName: {
- * title: {string} Group title
- * type: {string} Group type
- * filters: {string[]} Filters in the group
- * }
- * }
+ * Get the object that defines groups by their name.
*
* @return {Object} Filter groups
*/
};
/**
- * Get the current state of the filters
+ * Get the value of a specific parameter
*
- * @return {Object} Filters current state
+ * @param {string} name Parameter name
+ * @return {number|string} Parameter value
*/
- mw.rcfilters.dm.FiltersViewModel.prototype.getState = function () {
+ 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(),
+ conflicted: items[ i ].isConflicted(),
+ included: items[ i ].isIncluded()
+ };
+ }
+
+ return result;
+ };
+
+ /**
+ * Get the default parameters object
+ *
+ * @return {Object} Default parameter values
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.getDefaultParams = function () {
+ return this.defaultParams;
+ };
+
+ /**
+ * Set all filter states to default values
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.setFiltersToDefaults = function () {
+ var defaultFilterStates = this.getFiltersFromParameters( this.getDefaultParams() );
+
+ this.updateFilters( defaultFilterStates );
+ };
+
/**
* Analyze the groups and their filters and output an object representing
* the state of the parameters they represent.
*
+ * @param {Object} [filterGroups] An object defining the filter groups to
+ * translate to parameters. Its structure must follow that of this.groups
+ * see #getFilterGroups
* @return {Object} Parameter state object
*/
- mw.rcfilters.dm.FiltersViewModel.prototype.getParametersFromFilters = function () {
+ mw.rcfilters.dm.FiltersViewModel.prototype.getParametersFromFilters = function ( filterGroups ) {
var i, filterItems, anySelected, values,
result = {},
- groupItems = this.getFilterGroups();
+ groupItems = filterGroups || this.getFilterGroups();
- $.each( groupItems, function ( group, data ) {
- filterItems = data.filters;
+ $.each( groupItems, function ( group, model ) {
+ filterItems = model.getItems();
- if ( data.type === 'send_unselected_if_any' ) {
+ if ( model.getType() === 'send_unselected_if_any' ) {
// First, check if any of the items are selected at all.
// If none is selected, we're treating it as if they are
// all false
result[ filterItems[ i ].getName() ] = anySelected ?
Number( !filterItems[ i ].isSelected() ) : 0;
}
- } else if ( data.type === 'string_options' ) {
+ } else if ( model.getType() === 'string_options' ) {
values = [];
for ( i = 0; i < filterItems.length; i++ ) {
if ( filterItems[ i ].isSelected() ) {
if ( values.length === 0 || values.length === filterItems.length ) {
result[ group ] = 'all';
} else {
- result[ group ] = values.join( data.separator );
+ result[ group ] = values.join( model.getSeparator() );
}
}
} );
* Remove duplicates and make sure to only use valid
* values.
*
+ * @private
* @param {string} groupName Group name
* @param {string[]} valueArray Array of values
* @return {string[]} Array of valid values
*/
mw.rcfilters.dm.FiltersViewModel.prototype.sanitizeStringOptionGroup = function( groupName, valueArray ) {
var result = [],
- validNames = this.groups[ groupName ].filters.map( function ( filterItem ) {
+ validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
return filterItem.getName();
} );
return result;
};
+ /**
+ * Check whether the current filter state is set to all false.
+ *
+ * @return {boolean} Current filters are all empty
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.areCurrentFiltersEmpty = function () {
+ var currFilters = this.getSelectedState();
+
+ return Object.keys( currFilters ).every( function ( filterName ) {
+ return !currFilters[ filterName ];
+ } );
+ };
+
+ /**
+ * Check whether the default values of the filters are all false.
+ *
+ * @return {boolean} Default filters are all false
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.areDefaultFiltersEmpty = function () {
+ var defaultFilters;
+
+ if ( this.defaultFiltersEmpty !== null ) {
+ // We only need to do this test once,
+ // because defaults are set once per session
+ defaultFilters = this.getFiltersFromParameters();
+ this.defaultFiltersEmpty = Object.keys( defaultFilters ).every( function ( filterName ) {
+ return !defaultFilters[ filterName ];
+ } );
+ }
+
+ return this.defaultFiltersEmpty;
+ };
+
/**
* This is the opposite of the #getParametersFromFilters method; this goes over
- * the parameters and translates into a selected/unselected value in the filters.
+ * the given parameters and translates into a selected/unselected value in the filters.
*
* @param {Object} params Parameters query object
* @return {Object} Filter state object
var i, filterItem,
groupMap = {},
model = this,
- base = this.getParametersFromFilters(),
- // Start with current state
- result = this.getState();
+ base = this.getDefaultParams(),
+ result = {};
params = $.extend( {}, base, params );
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'
- groupMap[ paramName ] = { filters: model.groups[ paramName ].filters };
+ groupMap[ paramName ] = { filters: model.groups[ paramName ].getItems() };
}
} );
var paramValues, filterItem,
allItemsInGroup = data.filters;
- if ( model.groups[ group ].type === 'send_unselected_if_any' ) {
+ if ( model.groups[ group ].getType() === 'send_unselected_if_any' ) {
for ( i = 0; i < allItemsInGroup.length; i++ ) {
filterItem = allItemsInGroup[ i ];
// group, which means the state is false
false;
}
- } else if ( model.groups[ group ].type === 'string_options' ) {
- paramValues = model.sanitizeStringOptionGroup( group, params[ group ].split( model.groups[ group ].separator ) );
+ } else if ( model.groups[ group ].getType() === 'string_options' ) {
+ paramValues = model.sanitizeStringOptionGroup( group, params[ group ].split( model.groups[ group ].getSeparator() ) );
for ( i = 0; i < allItemsInGroup.length; i++ ) {
filterItem = allItemsInGroup[ i ];
// If it is the word 'all'
paramValues.length === 1 && paramValues[ 0 ] === 'all' ||
// All values are written
- paramValues.length === model.groups[ group ].filters.length
+ paramValues.length === model.groups[ group ].getItemCount()
) ?
// All true (either because all values are written or the term 'all' is written)
// is the same as all filters set to false
} )[ 0 ];
};
+ /**
+ * Set all filters to false or empty/all
+ * This is equivalent to display all.
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.emptyAllFilters = function () {
+ var filters = {};
+
+ this.getItems().forEach( function ( filterItem ) {
+ filters[ filterItem.getName() ] = false;
+ } );
+
+ // Update filters
+ this.updateFilters( filters );
+ };
+
/**
* Toggle selected state of items by their names
*
}
};
+ /**
+ * Get a group model from its name
+ *
+ * @param {string} groupName Group name
+ * @return {mw.rcfilters.dm.FilterGroup} Group model
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.getGroup = function ( groupName ) {
+ return this.groups[ groupName ];
+ };
+
+ /**
+ * Get all filters within a specified group by its name
+ *
+ * @param {string} groupName Group name
+ * @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
+ return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
+ };
+
/**
* 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;
};