Page MenuHomeWickedGov Phorge

FiltersViewModel.js
No OneTemporary

Size
37 KB
Referenced Files
None
Subscribers
None

FiltersViewModel.js

const FilterGroup = require( './FilterGroup.js' ),
FilterItem = require( './FilterItem.js' ),
utils = require( '../utils.js' );
/**
* View model for the filters selection and display.
*
* @class mw.rcfilters.dm.FiltersViewModel
* @ignore
* @mixes OO.EventEmitter
* @mixes OO.EmitterList
*/
const FiltersViewModel = function MwRcfiltersDmFiltersViewModel() {
// Mixin constructor
OO.EventEmitter.call( this );
OO.EmitterList.call( this );
this.groups = {};
this.defaultParams = {};
this.highlightEnabled = false;
this.parameterMap = {};
this.emptyParameterState = null;
this.views = {};
this.currentView = 'default';
this.searchQuery = null;
// Events
this.aggregate( { update: 'filterItemUpdate' } );
this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
};
/* Initialization */
OO.initClass( FiltersViewModel );
OO.mixinClass( FiltersViewModel, OO.EventEmitter );
OO.mixinClass( FiltersViewModel, OO.EmitterList );
/* Events */
/**
* Filter list is initialized.
*
* @event initialize
* @ignore
*/
/**
* Model has been updated.
*
* @event update
* @ignore
*/
/**
* Filter item has changed.
*
* @event itemUpdate
* @param {mw.rcfilters.dm.FilterItem} item Filter item updated
* @ignore
*/
/**
* Highlight feature has been toggled enabled or disabled.
*
* @event highlightChange
* @param {boolean} Highlight feature is enabled
* @ignore
*/
/* 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
*/
FiltersViewModel.prototype.reassessFilterInteractions = function ( item ) {
const model = this,
iterationItems = item !== undefined ? [ item ] : this.getItems();
iterationItems.forEach( ( checkedItem ) => {
const allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ),
groupModel = checkedItem.getGroupModel();
// Check for subsets (included filters) plus the item itself:
allCheckedItems.forEach( ( filterItemName ) => {
const itemInSubset = model.getItemByName( filterItemName );
itemInSubset.toggleIncluded(
// If any of itemInSubset's supersets are selected, this item
// is included
itemInSubset.getSuperset().some( ( supersetName ) => ( model.getItemByName( supersetName ).isSelected() ) )
);
} );
// Update coverage for the changed group
if ( groupModel.isFullCoverage() ) {
const allSelected = groupModel.areAllSelected();
groupModel.getItems().forEach( ( 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( ( filterItem ) => {
let inConflict = false;
const filterItemGroup = filterItem.getGroupModel();
// For each item, see if that item is still conflicting
// eslint-disable-next-line no-jquery/no-each-util
$.each( model.groups, ( 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.findSelectedItems().every( ( otherGroupItem ) => 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 );
} );
};
/**
* Get whether the model has any conflict in its items
*
* @return {boolean} There is a conflict
*/
FiltersViewModel.prototype.hasConflict = function () {
return this.getItems().some( ( filterItem ) => filterItem.isSelected() && filterItem.isConflicted() );
};
/**
* Get the first item with a current conflict
*
* @ignore
* @return {mw.rcfilters.dm.FilterItem|undefined} Conflicted item or undefined when not found
*/
FiltersViewModel.prototype.getFirstConflictedItem = function () {
const items = this.getItems();
for ( let i = 0; i < items.length; i++ ) {
const filterItem = items[ i ];
if ( filterItem.isSelected() && filterItem.isConflicted() ) {
return filterItem;
}
}
};
/**
* Set filters and preserve a group relationship based on
* the definition given by an object
*
* @param {Array} filterGroups Filters definition
* @param {Object} [views] Extra views definition
* Expected in the following format:
* {
* namespaces: {
* label: 'namespaces', // Message key
* trigger: ':',
* groups: [
* {
* // Group info
* name: 'namespaces' // Parameter name
* title: 'namespaces' // Message key
* type: 'string_options',
* separator: ';',
* labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
* fullCoverage: true
* items: []
* }
* ]
* }
* }
*/
FiltersViewModel.prototype.initializeFilters = function ( filterGroups, views ) {
const model = this,
items = [],
groupConflictMap = {},
filterConflictMap = {},
/*!
* Expand a conflict definition from group name to
* the list of all included filters in that group.
* We do this so that the direct relationship in the
* models are consistently item->items rather than
* mixing item->group with item->item.
*
* @param {Object} obj Conflict definition
* @return {Object} Expanded conflict definition
*/
expandConflictDefinitions = function ( obj ) {
const result = {};
// eslint-disable-next-line no-jquery/no-each-util
$.each( obj, ( key, conflicts ) => {
const adjustedConflicts = {};
conflicts.forEach( ( conflict ) => {
let filter;
if ( conflict.filter ) {
const filterName = model.groups[ conflict.group ].getPrefixedName( conflict.filter );
filter = model.getItemByName( filterName );
// Rename
adjustedConflicts[ filterName ] = Object.assign(
{},
conflict,
{
filter: filterName,
item: filter
}
);
} 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( ( groupItem ) => {
// Rebuild the conflict
adjustedConflicts[ groupItem.getName() ] = Object.assign(
{},
conflict,
{
filter: groupItem.getName(),
item: groupItem
}
);
} );
}
} );
result[ key ] = adjustedConflicts;
} );
return result;
};
// Reset
this.clearItems();
this.groups = {};
this.views = {};
// Clone
filterGroups = OO.copy( filterGroups );
// Normalize definition from the server
filterGroups.forEach( ( data ) => {
let i;
// What's this information needs to be normalized
data.whatsThis = {
body: data.whatsThisBody,
header: data.whatsThisHeader,
linkText: data.whatsThisLinkText,
url: data.whatsThisUrl
};
// Title is a msg-key
// eslint-disable-next-line mediawiki/msg-doc
data.title = data.title ? mw.msg( data.title ) : data.name;
// Filters are given to us with msg-keys, we need
// to translate those before we hand them off
for ( i = 0; i < data.filters.length; i++ ) {
// eslint-disable-next-line mediawiki/msg-doc
data.filters[ i ].label = data.filters[ i ].label ? mw.msg( data.filters[ i ].label ) : data.filters[ i ].name;
// eslint-disable-next-line mediawiki/msg-doc
data.filters[ i ].description = data.filters[ i ].description ? mw.msg( data.filters[ i ].description ) : '';
}
} );
// Collect views
const allViews = $.extend( true, {
default: {
title: mw.msg( 'rcfilters-filterlist-title' ),
groups: filterGroups
}
}, views );
// Go over all views
// eslint-disable-next-line no-jquery/no-each-util
$.each( allViews, ( viewName, viewData ) => {
// Define the view
model.views[ viewName ] = {
name: viewData.name,
title: viewData.title,
trigger: viewData.trigger
};
// Go over groups
viewData.groups.forEach( ( groupData ) => {
const group = groupData.name;
if ( !model.groups[ group ] ) {
model.groups[ group ] = new FilterGroup(
group,
$.extend( true, {}, groupData, { view: viewName } )
);
}
model.groups[ group ].initializeFilters( groupData.filters, groupData.default );
items.push( ...model.groups[ group ].getItems() );
// Prepare conflicts
if ( groupData.conflicts ) {
// Group conflicts
groupConflictMap[ group ] = groupData.conflicts;
}
groupData.filters.forEach( ( itemData ) => {
const filterItem = model.groups[ group ].getItemByParamName( itemData.name );
// Filter conflicts
if ( itemData.conflicts ) {
filterConflictMap[ filterItem.getName() ] = itemData.conflicts;
}
} );
} );
} );
// Add item references to the model, for lookup
this.addItems( items );
// Expand conflicts
const groupConflictResult = expandConflictDefinitions( groupConflictMap );
const filterConflictResult = expandConflictDefinitions( filterConflictMap );
// Set conflicts for groups
// eslint-disable-next-line no-jquery/no-each-util
$.each( groupConflictResult, ( group, conflicts ) => {
model.groups[ group ].setConflicts( conflicts );
} );
// Set conflicts for items
// eslint-disable-next-line no-jquery/no-each-util
$.each( filterConflictResult, ( filterName, conflicts ) => {
const filterItem = model.getItemByName( filterName );
// set conflicts for items in the group
filterItem.setConflicts( conflicts );
} );
// Create a map between known parameters and their models
// eslint-disable-next-line no-jquery/no-each-util
$.each( this.groups, ( group, groupModel ) => {
if (
groupModel.getType() === 'send_unselected_if_any' ||
groupModel.getType() === 'boolean' ||
groupModel.getType() === 'any_value'
) {
// Individual filters
groupModel.getItems().forEach( ( filterItem ) => {
model.parameterMap[ filterItem.getParamName() ] = filterItem;
} );
} else if (
groupModel.getType() === 'string_options' ||
groupModel.getType() === 'single_option'
) {
// Group
model.parameterMap[ groupModel.getName() ] = groupModel;
}
} );
this.setSearch( '' );
this.updateHighlightedState();
// Finish initialization
this.emit( 'initialize' );
};
/**
* Update filter view model state based on a parameter object
*
* @param {Object} params Parameters object
*/
FiltersViewModel.prototype.updateStateFromParams = function ( params ) {
// For arbitrary numeric single_option values make sure the values
// are normalized to fit within the limits
// eslint-disable-next-line no-jquery/no-each-util
$.each( this.getFilterGroups(), ( groupName, groupModel ) => {
params[ groupName ] = groupModel.normalizeArbitraryValue( params[ groupName ] );
} );
// Update filter values
const filtersValue = this.getFiltersFromParameters( params );
Object.keys( filtersValue ).forEach( ( filterName ) => {
this.getItemByName( filterName ).setValue( filtersValue[ filterName ] );
} );
// Update highlight state
this.getItemsSupportingHighlights().forEach( ( filterItem ) => {
const color = params[ filterItem.getName() + '_color' ];
if ( color ) {
filterItem.setHighlightColor( color );
} else {
filterItem.clearHighlightColor();
}
} );
this.updateHighlightedState();
// Check all filter interactions
this.reassessFilterInteractions();
};
/**
* Get a representation of an empty (falsey) parameter state
*
* @return {Object} Empty parameter state
*/
FiltersViewModel.prototype.getEmptyParameterState = function () {
if ( !this.emptyParameterState ) {
this.emptyParameterState = $.extend(
true,
{},
this.getParametersFromFilters( {} ),
this.getEmptyHighlightParameters()
);
}
return this.emptyParameterState;
};
/**
* Get a representation of only the non-falsey parameters
*
* @param {Object} [parameters] A given parameter state to minimize. If not given the current
* state of the system will be used.
* @return {Object} Empty parameter state
*/
FiltersViewModel.prototype.getMinimizedParamRepresentation = function ( parameters ) {
const result = {};
parameters = parameters ? $.extend( true, {}, parameters ) : this.getCurrentParameterState();
// Params
// eslint-disable-next-line no-jquery/no-each-util
$.each( this.getEmptyParameterState(), ( param, value ) => {
if ( parameters[ param ] !== undefined && parameters[ param ] !== value ) {
result[ param ] = parameters[ param ];
}
} );
// Highlights
Object.keys( this.getEmptyHighlightParameters() ).forEach( ( param ) => {
if ( parameters[ param ] ) {
// If a highlight parameter is not undefined and not null
// add it to the result
result[ param ] = parameters[ param ];
}
} );
return result;
};
/**
* Get a representation of the full parameter list, including all base values
*
* @return {Object} Full parameter representation
*/
FiltersViewModel.prototype.getExpandedParamRepresentation = function () {
return $.extend(
true,
{},
this.getEmptyParameterState(),
this.getCurrentParameterState()
);
};
/**
* Get a parameter representation of the current state of the model
*
* @param {boolean} [removeStickyParams] Remove sticky filters from final result
* @return {Object} Parameter representation of the current state of the model
*/
FiltersViewModel.prototype.getCurrentParameterState = function ( removeStickyParams ) {
let state = this.getMinimizedParamRepresentation( $.extend(
true,
{},
this.getParametersFromFilters( this.getSelectedState() ),
this.getHighlightParameters()
) );
if ( removeStickyParams ) {
state = this.removeStickyParams( state );
}
return state;
};
/**
* Delete sticky parameters from given object.
*
* @param {Object} paramState Parameter state
* @return {Object} Parameter state without sticky parameters
*/
FiltersViewModel.prototype.removeStickyParams = function ( paramState ) {
this.getStickyParams().forEach( ( paramName ) => {
delete paramState[ paramName ];
} );
return paramState;
};
/**
* Turn the highlight feature on or off
*/
FiltersViewModel.prototype.updateHighlightedState = function () {
this.toggleHighlight( this.getHighlightedItems().length > 0 );
};
/**
* Get the object that defines groups by their name.
*
* @return {Object} Filter groups
*/
FiltersViewModel.prototype.getFilterGroups = function () {
return this.groups;
};
/**
* Get the object that defines groups that match a certain view by their name.
*
* @param {string} [view] Requested view. If not given, uses current view
* @return {Object} Filter groups matching a display group
*/
FiltersViewModel.prototype.getFilterGroupsByView = function ( view ) {
const result = {};
view = view || this.getCurrentView();
// eslint-disable-next-line no-jquery/no-each-util
$.each( this.groups, ( groupName, groupModel ) => {
if ( groupModel.getView() === view ) {
result[ groupName ] = groupModel;
}
} );
return result;
};
/**
* Get an array of filters matching the given display group.
*
* @ignore
* @param {string} [view] Requested view. If not given, uses current view
* @return {mw.rcfilters.dm.FilterItem} Filter items matching the group
*/
FiltersViewModel.prototype.getFiltersByView = function ( view ) {
const result = [];
view = view || this.getCurrentView();
const groups = this.getFilterGroupsByView( view );
// eslint-disable-next-line no-jquery/no-each-util
$.each( groups, ( groupName, groupModel ) => {
result.push( ...groupModel.getItems() );
} );
return result;
};
/**
* Get the trigger for the requested view.
*
* @param {string} view View name
* @return {string} View trigger, if exists
*/
FiltersViewModel.prototype.getViewTrigger = function ( view ) {
return ( this.views[ view ] && this.views[ view ].trigger ) || '';
};
/**
* Get the value of a specific parameter
*
* @param {string} name Parameter name
* @return {number|string} Parameter value
*/
FiltersViewModel.prototype.getParamValue = function ( name ) {
return this.parameters[ name ];
};
/**
* Get the current selected state of the filters
*
* @param {boolean} [onlySelected] return an object containing only the filters with a value
* @return {Object} Filters selected state
*/
FiltersViewModel.prototype.getSelectedState = function ( onlySelected ) {
const items = this.getItems(),
result = {};
for ( let i = 0; i < items.length; i++ ) {
if ( !onlySelected || items[ i ].getValue() ) {
result[ items[ i ].getName() ] = items[ i ].getValue();
}
}
return result;
};
/**
* Get the current full state of the filters
*
* @return {Object} Filters full state
*/
FiltersViewModel.prototype.getFullState = function () {
const items = this.getItems(),
result = {};
for ( let 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 an object representing default parameters state
*
* @return {Object} Default parameter values
*/
FiltersViewModel.prototype.getDefaultParams = function () {
const result = {};
// Get default filter state
// eslint-disable-next-line no-jquery/no-each-util
$.each( this.groups, ( name, model ) => {
if ( !model.isSticky() ) {
$.extend( true, result, model.getDefaultParams() );
}
} );
return result;
};
/**
* Get a parameter representation of all sticky parameters
*
* @return {Object} Sticky parameter values
*/
FiltersViewModel.prototype.getStickyParams = function () {
let result = [];
// eslint-disable-next-line no-jquery/no-each-util
$.each( this.groups, ( name, model ) => {
if ( model.isSticky() ) {
if ( model.isPerGroupRequestParameter() ) {
result.push( name );
} else {
// Each filter is its own param
result = result.concat( model.getItems().map( ( filterItem ) => filterItem.getParamName() ) );
}
}
} );
return result;
};
/**
* Get a parameter representation of all sticky parameters
*
* @return {Object} Sticky parameter values
*/
FiltersViewModel.prototype.getStickyParamsValues = function () {
const result = {};
// eslint-disable-next-line no-jquery/no-each-util
$.each( this.groups, ( name, model ) => {
if ( model.isSticky() ) {
$.extend( true, result, model.getParamRepresentation() );
}
} );
return result;
};
/**
* Analyze the groups and their filters and output an object representing
* the state of the parameters they represent.
*
* @param {Object} [filterDefinition] An object defining the filter values,
* keyed by filter names.
* @return {Object} Parameter state object
*/
FiltersViewModel.prototype.getParametersFromFilters = function ( filterDefinition ) {
let groupItemDefinition;
const result = {},
groupItems = this.getFilterGroups();
if ( filterDefinition ) {
groupItemDefinition = {};
// Filter definition is "flat", but in effect
// each group needs to tell us its result based
// on the values in it. We need to split this list
// back into groupings so we can "feed" it to the
// loop below, and we need to expand it so it includes
// all filters (set to false)
this.getItems().forEach( ( filterItem ) => {
groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {};
groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = filterItem.coerceValue( filterDefinition[ filterItem.getName() ] );
} );
}
// eslint-disable-next-line no-jquery/no-each-util
$.each( groupItems, ( group, model ) => {
Object.assign(
result,
model.getParamRepresentation(
groupItemDefinition ?
groupItemDefinition[ group ] : null
)
);
} );
return result;
};
/**
* This is the opposite of the #getParametersFromFilters method; this goes over
* the given parameters and translates into a selected/unselected value in the filters.
*
* @param {Object} params Parameters query object
* @return {Object} Filter state object
*/
FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
const groupMap = {},
model = this;
let result = {};
// Go over the given parameters, break apart to groupings
// The resulting object represents the group with its parameter
// values. For example:
// {
// group1: {
// param1: "1",
// param2: "0",
// param3: "1"
// },
// group2: "param4|param5"
// }
// eslint-disable-next-line no-jquery/no-each-util
$.each( params, ( paramName, paramValue ) => {
const itemOrGroup = model.parameterMap[ paramName ];
if ( itemOrGroup ) {
const groupName = itemOrGroup instanceof FilterItem ?
itemOrGroup.getGroupName() : itemOrGroup.getName();
groupMap[ groupName ] = groupMap[ groupName ] || {};
groupMap[ groupName ][ paramName ] = paramValue;
}
} );
// Go over all groups, so we make sure we get the complete output
// even if the parameters don't include a certain group
// eslint-disable-next-line no-jquery/no-each-util
$.each( this.groups, ( groupName, groupModel ) => {
result = $.extend( true, {}, result, groupModel.getFilterRepresentation( groupMap[ groupName ] ) );
} );
return result;
};
/**
* Get the highlight parameters based on current filter configuration
*
* @return {Object} Object where keys are `<filter name>_color` and values
* are the selected highlight colors.
*/
FiltersViewModel.prototype.getHighlightParameters = function () {
const highlightEnabled = this.isHighlightEnabled(),
result = {};
this.getItems().forEach( ( filterItem ) => {
if ( filterItem.isHighlightSupported() ) {
result[ filterItem.getName() + '_color' ] = highlightEnabled && filterItem.isHighlighted() ?
filterItem.getHighlightColor() :
null;
}
} );
return result;
};
/**
* Get an object representing the complete empty state of highlights
*
* @return {Object} Object containing all the highlight parameters set to their negative value
*/
FiltersViewModel.prototype.getEmptyHighlightParameters = function () {
const result = {};
this.getItems().forEach( ( filterItem ) => {
if ( filterItem.isHighlightSupported() ) {
result[ filterItem.getName() + '_color' ] = null;
}
} );
return result;
};
/**
* Get an array of currently applied highlight colors
*
* @return {string[]} Currently applied highlight colors
*/
FiltersViewModel.prototype.getCurrentlyUsedHighlightColors = function () {
const result = [];
if ( this.isHighlightEnabled() ) {
this.getHighlightedItems().forEach( ( filterItem ) => {
const color = filterItem.getHighlightColor();
if ( result.indexOf( color ) === -1 ) {
result.push( color );
}
} );
}
return result;
};
/**
* Sanitize value group of a string_option groups type
* 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
*/
FiltersViewModel.prototype.sanitizeStringOptionGroup = function ( groupName, valueArray ) {
const validNames = this.getGroupFilters( groupName ).map( ( filterItem ) => filterItem.getParamName() );
return utils.normalizeParamOptions( valueArray, validNames );
};
/**
* Check whether no visible filter is selected.
*
* Filter groups that are hidden or sticky are not shown in the
* active filters area and therefore not included in this check.
*
* @return {boolean} No visible filter is selected
*/
FiltersViewModel.prototype.areVisibleFiltersEmpty = function () {
// Check if there are either any selected items or any items
// that have highlight enabled
return !this.getItems().some( ( filterItem ) => {
const visible = !filterItem.getGroupModel().isSticky() && !filterItem.getGroupModel().isHidden(),
active = ( filterItem.isSelected() || filterItem.isHighlighted() );
return visible && active;
} );
};
/**
* Check whether the namespace invert state is a valid one. A valid invert state is one
* where there are actual namespaces selected.
*
* This is done to compare states to previous ones that may have had the invert model
* selected but effectively had no namespaces, so are not effectively different than
* ones where invert is not selected.
*
* @return {boolean} Invert is effectively selected
*/
FiltersViewModel.prototype.areNamespacesEffectivelyInverted = function () {
return this.getNamespacesInvertModel().isSelected() &&
this.findSelectedItems().some( ( itemModel ) => itemModel.getGroupModel().getName() === 'namespace' );
};
/**
* Check whether the tag invert state is a valid one. A valid invert state is one
* where there are actual tags selected.
*
* This is done to compare states to previous ones that may have had the invert model
* selected but effectively had no tags, so are not effectively different than
* ones where invert is not selected.
*
* @return {boolean} Invert is effectively selected
*/
FiltersViewModel.prototype.areTagsEffectivelyInverted = function () {
return this.getTagsInvertModel().isSelected() &&
this.findSelectedItems().some( ( itemModel ) => itemModel.getGroupModel().getName() === 'tagfilter' );
};
/**
* Get the item that matches the given name
*
* @ignore
* @param {string} name Filter name
* @return {mw.rcfilters.dm.FilterItem} Filter item
*/
FiltersViewModel.prototype.getItemByName = function ( name ) {
return this.getItems().filter( ( item ) => name === item.getName() )[ 0 ];
};
/**
* Set all filters to false or empty/all
* This is equivalent to display all.
*/
FiltersViewModel.prototype.emptyAllFilters = function () {
this.getItems().forEach( ( filterItem ) => {
if ( !filterItem.getGroupModel().isSticky() ) {
this.toggleFilterSelected( filterItem.getName(), false );
}
} );
};
/**
* Toggle selected state of one item
*
* @param {string} name Name of the filter item
* @param {boolean} [isSelected] Filter selected state
*/
FiltersViewModel.prototype.toggleFilterSelected = function ( name, isSelected ) {
const item = this.getItemByName( name );
if ( item ) {
item.toggleSelected( isSelected );
}
};
/**
* Toggle selected state of items by their names
*
* @param {Object} filterDef Filter definitions
*/
FiltersViewModel.prototype.toggleFiltersSelected = function ( filterDef ) {
Object.keys( filterDef ).forEach( ( name ) => {
this.toggleFilterSelected( name, filterDef[ name ] );
} );
};
/**
* Get a group model from its name
*
* @ignore
* @param {string} groupName Group name
* @return {mw.rcfilters.dm.FilterGroup} Group model
*/
FiltersViewModel.prototype.getGroup = function ( groupName ) {
return this.groups[ groupName ];
};
/**
* Get all filters within a specified group by its name
*
* @ignore
* @param {string} groupName Group name
* @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
*/
FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
};
/**
* Find items whose labels match the given string
*
* @param {string} query Search string
* @param {boolean} [returnFlat] Return a flat array. If false, the result
* is an object whose keys are the group names and values are an array of
* filters per group. If set to true, returns an array of filters regardless
* of their groups.
* @return {Object} An object of items to show
* arranged by their group names
*/
FiltersViewModel.prototype.findMatches = function ( query, returnFlat ) {
const result = {},
flatResult = [],
view = this.getViewByTrigger( query.slice( 0, 1 ) ),
items = this.getFiltersByView( view );
// Normalize so we can search strings regardless of case and view
query = query.trim().toLowerCase();
if ( view !== 'default' ) {
query = query.slice( 1 );
}
// Trim again to also intercept cases where the spaces were after the trigger
// eg: '# str'
query = query.trim();
// Check if the search if actually empty; this can be a problem when
// we use prefixes to denote different views
const searchIsEmpty = query.length === 0;
// item label starting with the query string
for ( let i = 0; i < items.length; i++ ) {
if (
searchIsEmpty ||
items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ||
(
// For tags, we want the parameter name to be included in the search
view === 'tags' &&
items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
)
) {
result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
result[ items[ i ].getGroupName() ].push( items[ i ] );
flatResult.push( items[ i ] );
}
}
if ( $.isEmptyObject( result ) ) {
// item containing the query string in their label, description, or group title
for ( let i = 0; i < items.length; i++ ) {
const groupTitle = items[ i ].getGroupModel().getTitle();
if (
searchIsEmpty ||
items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
groupTitle.toLowerCase().indexOf( query ) > -1 ||
(
// For tags, we want the parameter name to be included in the search
view === 'tags' &&
items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
)
) {
result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
result[ items[ i ].getGroupName() ].push( items[ i ] );
flatResult.push( items[ i ] );
}
}
}
return returnFlat ? flatResult : result;
};
/**
* Get items that are highlighted
*
* @ignore
* @return {mw.rcfilters.dm.FilterItem[]} Highlighted items
*/
FiltersViewModel.prototype.getHighlightedItems = function () {
return this.getItems().filter( ( filterItem ) => filterItem.isHighlightSupported() &&
filterItem.getHighlightColor() );
};
/**
* Get items that allow highlights even if they're not currently highlighted
*
* @ignore
* @return {mw.rcfilters.dm.FilterItem[]} Items supporting highlights
*/
FiltersViewModel.prototype.getItemsSupportingHighlights = function () {
return this.getItems().filter( ( filterItem ) => filterItem.isHighlightSupported() );
};
/**
* Get all selected items
*
* @ignore
* @return {mw.rcfilters.dm.FilterItem[]} Selected items
*/
FiltersViewModel.prototype.findSelectedItems = function () {
let allSelected = [];
// eslint-disable-next-line no-jquery/no-each-util
$.each( this.getFilterGroups(), ( groupName, groupModel ) => {
allSelected = allSelected.concat( groupModel.findSelectedItems() );
} );
return allSelected;
};
/**
* Get the current view
*
* @return {string} Current view
*/
FiltersViewModel.prototype.getCurrentView = function () {
return this.currentView;
};
/**
* Get the label for the current view
*
* @param {string} viewName View name
* @return {string} Label for the current view
*/
FiltersViewModel.prototype.getViewTitle = function ( viewName ) {
viewName = viewName || this.getCurrentView();
return this.views[ viewName ] && this.views[ viewName ].title;
};
/**
* Get the view that fits the given trigger
*
* @param {string} trigger Trigger
* @return {string} Name of view
*/
FiltersViewModel.prototype.getViewByTrigger = function ( trigger ) {
let result = 'default';
// eslint-disable-next-line no-jquery/no-each-util
$.each( this.views, ( name, data ) => {
if ( data.trigger === trigger ) {
result = name;
}
} );
return result;
};
/**
* Return a version of the given string that is without any
* view triggers.
*
* @param {string} str Given string
* @return {string} Result
*/
FiltersViewModel.prototype.removeViewTriggers = function ( str ) {
if ( this.getViewFromString( str ) !== 'default' ) {
str = str.slice( 1 );
}
return str;
};
/**
* Get the view from the given string by a trigger, if it exists
*
* @param {string} str Given string
* @return {string} View name
*/
FiltersViewModel.prototype.getViewFromString = function ( str ) {
return this.getViewByTrigger( str.slice( 0, 1 ) );
};
/**
* Set the current search for the system.
* This also dictates what items and groups are visible according
* to the search in #findMatches
*
* @param {string} searchQuery Search query, including triggers
* @fires searchChange
*/
FiltersViewModel.prototype.setSearch = function ( searchQuery ) {
let visibleGroups, visibleGroupNames;
if ( this.searchQuery !== searchQuery ) {
// Check if the view changed
this.switchView( this.getViewFromString( searchQuery ) );
visibleGroups = this.findMatches( searchQuery );
visibleGroupNames = Object.keys( visibleGroups );
// Update visibility of items and groups
// eslint-disable-next-line no-jquery/no-each-util
$.each( this.getFilterGroups(), ( groupName, groupModel ) => {
// Check if the group is visible at all
groupModel.toggleVisible( visibleGroupNames.indexOf( groupName ) !== -1 );
groupModel.setVisibleItems( visibleGroups[ groupName ] || [] );
} );
this.searchQuery = searchQuery;
this.emit( 'searchChange', this.searchQuery );
}
};
/**
* Get the current search
*
* @return {string} Current search query
*/
FiltersViewModel.prototype.getSearch = function () {
return this.searchQuery;
};
/**
* Switch the current view
*
* @private
* @param {string} view View name
*/
FiltersViewModel.prototype.switchView = function ( view ) {
if ( this.views[ view ] && this.currentView !== view ) {
this.currentView = view;
}
};
/**
* Toggle the highlight feature on and off.
* Propagate the change to filter items.
*
* @param {boolean} enable Highlight should be enabled
* @fires highlightChange
*/
FiltersViewModel.prototype.toggleHighlight = function ( enable ) {
enable = enable === undefined ? !this.highlightEnabled : enable;
if ( this.highlightEnabled !== enable ) {
this.highlightEnabled = enable;
this.emit( 'highlightChange', this.highlightEnabled );
}
};
/**
* Check if the highlight feature is enabled
*
* @return {boolean}
*/
FiltersViewModel.prototype.isHighlightEnabled = function () {
return !!this.highlightEnabled;
};
/**
* Toggle the inverted tags property on and off.
* Propagate the change to tag filter items.
*
* @param {boolean} enable Inverted property is enabled
*/
FiltersViewModel.prototype.toggleInvertedTags = function ( enable ) {
this.toggleFilterSelected( this.getTagsInvertModel().getName(), enable );
};
/**
* Toggle the inverted namespaces property on and off.
* Propagate the change to namespace filter items.
*
* @param {boolean} enable Inverted property is enabled
*/
FiltersViewModel.prototype.toggleInvertedNamespaces = function ( enable ) {
this.toggleFilterSelected( this.getNamespacesInvertModel().getName(), enable );
};
/**
* Get the model object that represents the 'invert' filter
*
* @ignore
* @param {string} view
* @return {mw.rcfilters.dm.FilterItem|null}
*/
FiltersViewModel.prototype.getInvertModel = function ( view ) {
if ( view === 'namespaces' ) {
return this.getNamespacesInvertModel();
}
if ( view === 'tags' ) {
return this.getTagsInvertModel();
}
return null;
};
/**
* Get the model object that represents the 'invert' filter
*
* @ignore
* @return {mw.rcfilters.dm.FilterItem}
*/
FiltersViewModel.prototype.getNamespacesInvertModel = function () {
return this.getGroup( 'invertGroup' ).getItemByParamName( 'invert' );
};
/**
* Get the model object that represents the 'invert' filter
*
* @ignore
* @return {mw.rcfilters.dm.FilterItem}
*/
FiltersViewModel.prototype.getTagsInvertModel = function () {
return this.getGroup( 'invertTagsGroup' ).getItemByParamName( 'inverttags' );
};
/**
* Set highlight color for a specific filter item
*
* @param {string} filterName Name of the filter item
* @param {string} color Selected color
*/
FiltersViewModel.prototype.setHighlightColor = function ( filterName, color ) {
this.getItemByName( filterName ).setHighlightColor( color );
};
/**
* Clear highlight for a specific filter item
*
* @param {string} filterName Name of the filter item
*/
FiltersViewModel.prototype.clearHighlightColor = function ( filterName ) {
this.getItemByName( filterName ).clearHighlightColor();
};
module.exports = FiltersViewModel;

File Metadata

Mime Type
text/plain
Expires
Sun, Oct 4, 00:47 (3 w, 1 d ago)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
3a/57/1a4d5ffcf82f33a2446c29b13982
Default Alt Text
FiltersViewModel.js (37 KB)

Event Timeline