this.defaultParams = {};
this.defaultFiltersEmpty = null;
this.highlightEnabled = false;
+ this.invertedNamespaces = false;
this.parameterMap = {};
+ this.views = {};
+ this.currentView = null;
+
// Events
this.aggregate( { update: 'filterItemUpdate' } );
this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
* Filter list is initialized
*/
+ /**
+ * @event update
+ *
+ * Model has been updated
+ */
+
/**
* @event itemUpdate
* @param {mw.rcfilters.dm.FilterItem} item Filter item updated
* Highlight feature has been toggled enabled or disabled
*/
+ /**
+ * @event invertChange
+ * @param {boolean} isInverted Namespace selected is inverted
+ *
+ * Namespace selection is inverted or straight forward
+ */
+
/* Methods */
/**
* Set filters and preserve a group relationship based on
* the definition given by an object
*
- * @param {Array} filters Filter group definition
+ * @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: []
+ * }
+ * ]
+ * }
+ * }
*/
- mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filters ) {
- var i, filterItem, filterConflictResult, groupConflictResult, subsetNames,
+ mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filterGroups, views ) {
+ var filterConflictResult, groupConflictResult,
+ allViews = {},
model = this,
items = [],
- supersetMap = {},
groupConflictMap = {},
filterConflictMap = {},
- addArrayElementsUnique = function ( arr, elements ) {
- elements = Array.isArray( elements ) ? elements : [ elements ];
-
- elements.forEach( function ( element ) {
- if ( arr.indexOf( element ) === -1 ) {
- arr.push( element );
- }
- } );
-
- return arr;
- },
+ /*!
+ * 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 ) {
var result = {};
var filter;
if ( conflict.filter ) {
- filterName = model.groups[ conflict.group ].getNamePrefix() + conflict.filter;
+ filterName = model.groups[ conflict.group ].getPrefixedName( conflict.filter );
filter = model.getItemByName( filterName );
// Rename
// Reset
this.clearItems();
this.groups = {};
+ this.views = {};
+
+ // Clone
+ filterGroups = OO.copy( filterGroups );
+
+ // Normalize definition from the server
+ filterGroups.forEach( function ( data ) {
+ var i;
+ // What's this information needs to be normalized
+ data.whatsThis = {
+ body: data.whatsThisBody,
+ header: data.whatsThisHeader,
+ linkText: data.whatsThisLinkText,
+ url: data.whatsThisUrl
+ };
- filters.forEach( function ( data ) {
- var group = data.name;
-
- if ( !model.groups[ group ] ) {
- model.groups[ group ] = new mw.rcfilters.dm.FilterGroup( group, {
- type: data.type,
- title: mw.msg( data.title ),
- separator: data.separator,
- fullCoverage: !!data.fullCoverage,
- whatsThis: {
- body: data.whatsThisBody,
- header: data.whatsThisHeader,
- linkText: data.whatsThisLinkText,
- url: data.whatsThisUrl
- }
- } );
+ // Title is a msg-key
+ 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++ ) {
+ data.filters[ i ].label = data.filters[ i ].label ? mw.msg( data.filters[ i ].label ) : data.filters[ i ].name;
+ data.filters[ i ].description = data.filters[ i ].description ? mw.msg( data.filters[ i ].description ) : '';
}
+ } );
- if ( data.conflicts ) {
- groupConflictMap[ group ] = data.conflicts;
+ // Collect views
+ allViews = {
+ 'default': {
+ label: mw.msg( 'rcfilters-filterlist-title' ),
+ groups: filterGroups
}
+ };
- for ( i = 0; i < data.filters.length; i++ ) {
- data.filters[ i ].subset = data.filters[ i ].subset || [];
- data.filters[ i ].subset = data.filters[ i ].subset.map( function ( el ) {
- return el.filter;
- } );
+ if ( views && mw.config.get( 'wgStructuredChangeFiltersEnableExperimentalViews' ) ) {
+ // If we have extended views, add them in
+ $.extend( true, allViews, views );
+ }
- filterItem = new mw.rcfilters.dm.FilterItem( data.filters[ i ].name, model.groups[ group ], {
- group: group,
- label: mw.msg( data.filters[ i ].label ),
- description: mw.msg( data.filters[ i ].description ),
- cssClass: data.filters[ i ].cssClass
- } );
+ // Go over all views
+ $.each( allViews, function ( viewName, viewData ) {
+ // Define the view
+ model.views[ viewName ] = {
+ name: viewData.name,
+ title: viewData.title,
+ trigger: viewData.trigger
+ };
- if ( data.filters[ i ].subset ) {
- subsetNames = [];
- data.filters[ i ].subset.forEach( function ( subsetFilterName ) { // eslint-disable-line no-loop-func
- var subsetName = model.groups[ group ].getNamePrefix() + subsetFilterName;
- // 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
- supersetMap[ subsetName ] = supersetMap[ subsetName ] || [];
- addArrayElementsUnique(
- supersetMap[ subsetName ],
- filterItem.getName()
- );
-
- // Translate subset param name to add the group name, so we
- // get consistent naming. We know that subsets are only within
- // the same group
- subsetNames.push( subsetName );
- } );
+ // Go over groups
+ viewData.groups.forEach( function ( groupData ) {
+ var group = groupData.name;
- // Set translated subset
- filterItem.setSubset( subsetNames );
- }
+ model.groups[ group ] = new mw.rcfilters.dm.FilterGroup(
+ group,
+ $.extend( true, {}, groupData, { view: viewName } )
+ );
- // Store conflicts
- if ( data.filters[ i ].conflicts ) {
- filterConflictMap[ filterItem.getName() ] = data.filters[ i ].conflicts;
- }
+ model.groups[ group ].initializeFilters( groupData.filters, groupData.default );
+ items = items.concat( model.groups[ group ].getItems() );
- 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 );
+ // Prepare conflicts
+ if ( groupData.conflicts ) {
+ // Group conflicts
+ groupConflictMap[ group ] = groupData.conflicts;
}
- 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,
- data.default ?
- data.default.split( model.groups[ group ].getSeparator() ) :
- []
- ).join( model.groups[ group ].getSeparator() );
- }
+ groupData.filters.forEach( function ( itemData ) {
+ var filterItem = model.groups[ group ].getItemByParamName( itemData.name );
+ // Filter conflicts
+ if ( itemData.conflicts ) {
+ filterConflictMap[ filterItem.getName() ] = itemData.conflicts;
+ }
+ } );
+ } );
} );
- // Add items to the model
+ // Add item references to the model, for lookup
this.addItems( items );
// Expand 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() ] );
- }
+ // Set conflicts for items
+ $.each( filterConflictResult, function ( filterName, conflicts ) {
+ var filterItem = model.getItemByName( filterName );
+ // set conflicts for items in the group
+ filterItem.setConflicts( conflicts );
} );
// Create a map between known parameters and their models
}
} );
+ this.currentView = 'default';
+
+ // Finish initialization
this.emit( 'initialize' );
};
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
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.getFilterGroupsByView = function ( view ) {
+ var result = {};
+
+ view = view || this.getCurrentView();
+
+ $.each( this.groups, function ( groupName, groupModel ) {
+ if ( groupModel.getView() === view ) {
+ result[ groupName ] = groupModel;
+ }
+ } );
+
+ return result;
+ };
+
+ /**
+ * Get an array of filters matching the given display group.
+ *
+ * @param {string} [view] Requested view. If not given, uses current view
+ * @return {mw.rcfilters.dm.FilterItem} Filter items matching the group
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersByView = function ( view ) {
+ var groups,
+ result = [];
+
+ view = view || this.getCurrentView();
+
+ groups = this.getFilterGroupsByView( view );
+
+ $.each( groups, function ( groupName, groupModel ) {
+ result = result.concat( groupModel.getItems() );
+ } );
+
+ return result;
+ };
+
+ /**
+ * Get the trigger for the requested view.
+ *
+ * @param {string} view View name
+ * @return {string} View trigger, if exists
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.getViewTrigger = function ( view ) {
+ return this.views[ view ] && this.views[ view ].trigger;
+ };
/**
* Get the value of a specific parameter
*
};
/**
- * Get the default parameters object
+ * Get an object representing default parameters state
*
* @return {Object} Default parameter values
*/
mw.rcfilters.dm.FiltersViewModel.prototype.getDefaultParams = function () {
- return this.defaultParams;
+ var result = {};
+
+ // Get default filter state
+ $.each( this.groups, function ( name, model ) {
+ $.extend( true, result, model.getDefaultParams() );
+ } );
+
+ return result;
};
/**
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
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
+ var groupMap = {},
+ model = this,
+ 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"
+ // }
+ $.each( params, function ( paramName, paramValue ) {
+ var itemOrGroup = model.parameterMap[ paramName ];
+
+ if ( itemOrGroup instanceof mw.rcfilters.dm.FilterItem ) {
+ groupMap[ itemOrGroup.getGroupName() ] = groupMap[ itemOrGroup.getGroupName() ] || {};
+ groupMap[ itemOrGroup.getGroupName() ][ itemOrGroup.getParamName() ] = paramValue;
+ } else if ( itemOrGroup instanceof mw.rcfilters.dm.FilterGroup ) {
+ // This parameter represents a group (values are the filters)
+ // this is equivalent to checking if the group is 'string_options'
+ groupMap[ itemOrGroup.getName() ] = groupMap[ itemOrGroup.getName() ] || {};
+ groupMap[ itemOrGroup.getName() ] = paramValue;
+ }
+ } );
+
+ // Go over all groups, so we make sure we get the complete output
+ // even if the parameters don't include a certain group
+ $.each( this.groups, function ( 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
+ * @return {Object} Object where keys are "<filter name>_color" and values
* are the selected highlight colors.
*/
mw.rcfilters.dm.FiltersViewModel.prototype.getHighlightParameters = function () {
- var result = { highlight: Number( this.isHighlightEnabled() ) };
+ var result = {};
+
+ this.getItems().forEach( function ( filterItem ) {
+ result[ filterItem.getName() + '_color' ] = filterItem.getHighlightColor() || null;
+ } );
+ result.highlight = String( Number( this.isHighlightEnabled() ) );
+
+ return result;
+ };
+
+ /**
+ * Extract the highlight values from given object. Since highlights are
+ * the same for filter and parameters, it doesn't matter which one is
+ * given; values will be returned with a full list of the highlights
+ * with colors or null values.
+ *
+ * @param {Object} representation Object containing representation of
+ * some or all highlight values
+ * @return {Object} Object where keys are "<filter name>_color" and values
+ * are the selected highlight colors. The returned object
+ * contains all available filters either with a color value
+ * or with null.
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.extractHighlightValues = function ( representation ) {
+ var result = {};
this.getItems().forEach( function ( filterItem ) {
- result[ filterItem.getName() + '_color' ] = filterItem.getHighlightColor();
+ var highlightName = filterItem.getName() + '_color';
+ result[ highlightName ] = representation[ highlightName ] || null;
} );
+
return result;
};
* @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.getGroupFilters( groupName ).map( function ( filterItem ) {
- return filterItem.getParamName();
- } );
-
- if ( valueArray.indexOf( 'all' ) > -1 ) {
- // If anywhere in the values there's 'all', we
- // treat it as if only 'all' was selected.
- // Example: param=valid1,valid2,all
- // Result: param=all
- return [ 'all' ];
- }
-
- // Get rid of any dupe and invalid parameter, only output
- // valid ones
- // Example: param=valid1,valid2,invalid1,valid1
- // Result: param=valid1,valid2
- valueArray.forEach( function ( value ) {
- if (
- validNames.indexOf( value ) > -1 &&
- result.indexOf( value ) === -1
- ) {
- result.push( value );
- }
+ mw.rcfilters.dm.FiltersViewModel.prototype.sanitizeStringOptionGroup = function ( groupName, valueArray ) {
+ var validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
+ return filterItem.getParamName();
} );
- return result;
+ return mw.rcfilters.utils.normalizeParamOptions( valueArray, validNames );
};
/**
if ( this.defaultFiltersEmpty !== null ) {
// We only need to do this test once,
// because defaults are set once per session
- defaultFilters = this.getFiltersFromParameters();
+ defaultFilters = this.getFiltersFromParameters( this.getDefaultParams() );
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 given parameters and translates into a selected/unselected value in the filters.
- *
- * @param {Object} params Parameters query object
- * @return {Object} Filter state object
- */
- mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
- var i,
- groupMap = {},
- model = this,
- base = this.getDefaultParams(),
- result = {};
-
- params = $.extend( {}, base, params );
-
- // Go over the given parameters
- $.each( params, function ( paramName, paramValue ) {
- var itemOrGroup = model.parameterMap[ paramName ];
-
- if ( itemOrGroup instanceof mw.rcfilters.dm.FilterItem ) {
- // Mark the group if it has any items that are selected
- groupMap[ itemOrGroup.getGroupName() ] = groupMap[ itemOrGroup.getGroupName() ] || {};
- groupMap[ itemOrGroup.getGroupName() ].hasSelected = (
- groupMap[ itemOrGroup.getGroupName() ].hasSelected ||
- !!Number( paramValue )
- );
-
- // Add filters
- groupMap[ itemOrGroup.getGroupName() ].filters = groupMap[ itemOrGroup.getGroupName() ].filters || [];
- groupMap[ itemOrGroup.getGroupName() ].filters.push( itemOrGroup );
- } else if ( itemOrGroup instanceof mw.rcfilters.dm.FilterGroup ) {
- groupMap[ itemOrGroup.getName() ] = groupMap[ itemOrGroup.getName() ] || {};
- // This parameter represents a group (values are the filters)
- // this is equivalent to checking if the group is 'string_options'
- groupMap[ itemOrGroup.getName() ].filters = itemOrGroup.getItems();
- }
- } );
-
- // Now that we know the groups' selection states, we need to go over
- // the filters in the groups and mark their selected states appropriately
- $.each( groupMap, function ( group, data ) {
- var paramValues, filterItem,
- allItemsInGroup = data.filters;
-
- if ( model.groups[ group ].getType() === 'send_unselected_if_any' ) {
- for ( i = 0; i < allItemsInGroup.length; i++ ) {
- filterItem = allItemsInGroup[ i ];
-
- result[ filterItem.getName() ] = groupMap[ filterItem.getGroupName() ].hasSelected ?
- // Flip the definition between the parameter
- // state and the filter state
- // This is what the 'toggleSelected' value of the filter is
- !Number( params[ filterItem.getParamName() ] ) :
- // Otherwise, there are no selected items in the
- // group, which means the state is false
- false;
- }
- } 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 ];
-
- result[ filterItem.getName() ] = (
- // If it is the word 'all'
- paramValues.length === 1 && paramValues[ 0 ] === 'all' ||
- // All values are written
- 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 true
- true :
- // Otherwise, the filter is selected only if it appears in the parameter values
- paramValues.indexOf( filterItem.getParamName() ) > -1;
- }
- }
- } );
-
- return result;
- };
-
/**
* Get the item that matches the given name
*
* arranged by their group names
*/
mw.rcfilters.dm.FiltersViewModel.prototype.findMatches = function ( query, returnFlat ) {
- var i,
+ var i, searchIsEmpty,
groupTitle,
result = {},
flatResult = [],
- items = this.getItems();
+ view = this.getViewByTrigger( query.substr( 0, 1 ) ),
+ items = this.getFiltersByView( view );
- // Normalize so we can search strings regardless of case
+ // Normalize so we can search strings regardless of case and view
query = query.toLowerCase();
+ if ( view !== 'default' ) {
+ query = query.substr( 1 );
+ }
+
+ // Check if the search if actually empty; this can be a problem when
+ // we use prefixes to denote different views
+ searchIsEmpty = query.length === 0;
// item label starting with the query string
for ( i = 0; i < items.length; i++ ) {
- if ( items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ) {
+ 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 ] );
for ( i = 0; i < items.length; i++ ) {
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
+ 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 ] );
} );
};
+ /**
+ * Switch the current view
+ *
+ * @param {string} view View name
+ * @fires update
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.switchView = function ( view ) {
+ if ( this.views[ view ] && this.currentView !== view ) {
+ this.currentView = view;
+ this.emit( 'update' );
+ }
+ };
+
+ /**
+ * Get the current view
+ *
+ * @return {string} Current view
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentView = function () {
+ return this.currentView;
+ };
+
+ /**
+ * Get the label for the current view
+ *
+ * @return {string} Label for the current view
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentViewLabel = function () {
+ return this.views[ this.getCurrentView() ].title;
+ };
+
+ /**
+ * Get an array of all available view names
+ *
+ * @return {string} Available view names
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.getAvailableViews = function () {
+ return Object.keys( this.views );
+ };
+
+ /**
+ * Get the view that fits the given trigger
+ *
+ * @param {string} trigger Trigger
+ * @return {string} Name of view
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.getViewByTrigger = function ( trigger ) {
+ var result = 'default';
+
+ $.each( this.views, function ( name, data ) {
+ if ( data.trigger === trigger ) {
+ result = name;
+ }
+ } );
+
+ return result;
+ };
+
/**
* Toggle the highlight feature on and off.
* Propagate the change to filter items.
return !!this.highlightEnabled;
};
+ /**
+ * Toggle the inverted namespaces property on and off.
+ * Propagate the change to namespace filter items.
+ *
+ * @param {boolean} enable Inverted property is enabled
+ * @fires invertChange
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.toggleInvertedNamespaces = function ( enable ) {
+ enable = enable === undefined ? !this.invertedNamespaces : enable;
+
+ if ( this.invertedNamespaces !== enable ) {
+ this.invertedNamespaces = enable;
+
+ this.getFiltersByView( 'namespaces' ).forEach( function ( filterItem ) {
+ filterItem.toggleInverted( this.invertedNamespaces );
+ }.bind( this ) );
+
+ this.emit( 'invertChange', this.invertedNamespaces );
+ }
+ };
+
+ /**
+ * Check if the namespaces selection is set to be inverted
+ * @return {boolean}
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.areNamespacesInverted = function () {
+ return !!this.invertedNamespaces;
+ };
+
/**
* Set highlight color for a specific filter item
*