diff options
Diffstat (limited to 'www/wiki/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js')
-rw-r--r-- | www/wiki/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js | 1276 |
1 files changed, 1276 insertions, 0 deletions
diff --git a/www/wiki/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js b/www/wiki/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js new file mode 100644 index 00000000..cdf1f635 --- /dev/null +++ b/www/wiki/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js @@ -0,0 +1,1276 @@ +( function ( mw, $ ) { + /** + * View model for the filters selection and display + * + * @mixins OO.EventEmitter + * @mixins OO.EmitterList + * + * @constructor + */ + mw.rcfilters.dm.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( mw.rcfilters.dm.FiltersViewModel ); + OO.mixinClass( mw.rcfilters.dm.FiltersViewModel, OO.EventEmitter ); + OO.mixinClass( mw.rcfilters.dm.FiltersViewModel, OO.EmitterList ); + + /* Events */ + + /** + * @event initialize + * + * Filter list is initialized + */ + + /** + * @event update + * + * Model has been updated + */ + + /** + * @event itemUpdate + * @param {mw.rcfilters.dm.FilterItem} item Filter item updated + * + * Filter item has changed + */ + + /** + * @event highlightChange + * @param {boolean} Highlight feature is enabled + * + * Highlight feature has been toggled enabled or disabled + */ + + /* 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.findSelectedItems().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 ); + } ); + }; + + /** + * Get whether the model has any conflict in its items + * + * @return {boolean} There is a conflict + */ + mw.rcfilters.dm.FiltersViewModel.prototype.hasConflict = function () { + return this.getItems().some( function ( filterItem ) { + return filterItem.isSelected() && filterItem.isConflicted(); + } ); + }; + + /** + * Get the first item with a current conflict + * + * @return {mw.rcfilters.dm.FilterItem} Conflicted item + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getFirstConflictedItem = function () { + var conflictedItem; + + this.getItems().forEach( function ( filterItem ) { + if ( filterItem.isSelected() && filterItem.isConflicted() ) { + conflictedItem = filterItem; + return false; + } + } ); + + return conflictedItem; + }; + + /** + * 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: [] + * } + * ] + * } + * } + */ + mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filterGroups, views ) { + var filterConflictResult, groupConflictResult, + allViews = {}, + 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 ) { + var result = {}; + + $.each( obj, function ( key, conflicts ) { + var filterName, + adjustedConflicts = {}; + + conflicts.forEach( function ( conflict ) { + var filter; + + if ( conflict.filter ) { + filterName = model.groups[ conflict.group ].getPrefixedName( conflict.filter ); + filter = model.getItemByName( filterName ); + + // Rename + adjustedConflicts[ filterName ] = $.extend( + {}, + 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( function ( groupItem ) { + // Rebuild the conflict + adjustedConflicts[ groupItem.getName() ] = $.extend( + {}, + 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( 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 + }; + + // 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 ) : ''; + } + } ); + + // Collect views + allViews = $.extend( true, { + 'default': { + title: mw.msg( 'rcfilters-filterlist-title' ), + groups: filterGroups + } + }, views ); + + // Go over all views + $.each( allViews, function ( viewName, viewData ) { + // Define the view + model.views[ viewName ] = { + name: viewData.name, + title: viewData.title, + trigger: viewData.trigger + }; + + // Go over groups + viewData.groups.forEach( function ( groupData ) { + var group = groupData.name; + + if ( !model.groups[ group ] ) { + model.groups[ group ] = new mw.rcfilters.dm.FilterGroup( + group, + $.extend( true, {}, groupData, { view: viewName } ) + ); + } + + model.groups[ group ].initializeFilters( groupData.filters, groupData.default ); + items = items.concat( model.groups[ group ].getItems() ); + + // Prepare conflicts + if ( groupData.conflicts ) { + // Group conflicts + groupConflictMap[ group ] = groupData.conflicts; + } + + groupData.filters.forEach( function ( itemData ) { + var 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 + groupConflictResult = expandConflictDefinitions( groupConflictMap ); + filterConflictResult = expandConflictDefinitions( filterConflictMap ); + + // Set conflicts for groups + $.each( groupConflictResult, function ( group, conflicts ) { + model.groups[ group ].setConflicts( conflicts ); + } ); + + // 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 + $.each( this.groups, function ( group, groupModel ) { + if ( + groupModel.getType() === 'send_unselected_if_any' || + groupModel.getType() === 'boolean' || + groupModel.getType() === 'any_value' + ) { + // Individual filters + groupModel.getItems().forEach( function ( 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 + */ + mw.rcfilters.dm.FiltersViewModel.prototype.updateStateFromParams = function ( params ) { + var filtersValue; + // For arbitrary numeric single_option values make sure the values + // are normalized to fit within the limits + $.each( this.getFilterGroups(), function ( groupName, groupModel ) { + params[ groupName ] = groupModel.normalizeArbitraryValue( params[ groupName ] ); + } ); + + // Update filter values + filtersValue = this.getFiltersFromParameters( params ); + Object.keys( filtersValue ).forEach( function ( filterName ) { + this.getItemByName( filterName ).setValue( filtersValue[ filterName ] ); + }.bind( this ) ); + + // Update highlight state + this.getItemsSupportingHighlights().forEach( function ( filterItem ) { + var 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 + */ + mw.rcfilters.dm.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 + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getMinimizedParamRepresentation = function ( parameters ) { + var result = {}; + + parameters = parameters ? $.extend( true, {}, parameters ) : this.getCurrentParameterState(); + + // Params + $.each( this.getEmptyParameterState(), function ( param, value ) { + if ( parameters[ param ] !== undefined && parameters[ param ] !== value ) { + result[ param ] = parameters[ param ]; + } + } ); + + // Highlights + Object.keys( this.getEmptyHighlightParameters() ).forEach( function ( 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 + */ + mw.rcfilters.dm.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 + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentParameterState = function ( removeStickyParams ) { + var 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 + */ + mw.rcfilters.dm.FiltersViewModel.prototype.removeStickyParams = function ( paramState ) { + this.getStickyParams().forEach( function ( paramName ) { + delete paramState[ paramName ]; + } ); + + return paramState; + }; + + /** + * Turn the highlight feature on or off + */ + mw.rcfilters.dm.FiltersViewModel.prototype.updateHighlightedState = function () { + this.toggleHighlight( this.getHighlightedItems().length > 0 ); + }; + + /** + * Get the object that defines groups by their name. + * + * @return {Object} Filter groups + */ + mw.rcfilters.dm.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 + */ + 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 + * + * @param {string} name Parameter name + * @return {number|string} Parameter value + */ + mw.rcfilters.dm.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 + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getSelectedState = function ( onlySelected ) { + var i, + items = this.getItems(), + result = {}; + + for ( 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 + */ + 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 an object representing default parameters state + * + * @return {Object} Default parameter values + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getDefaultParams = function () { + var result = {}; + + // Get default filter state + $.each( this.groups, function ( 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 + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getStickyParams = function () { + var result = []; + + $.each( this.groups, function ( name, model ) { + if ( model.isSticky() ) { + if ( model.isPerGroupRequestParameter() ) { + result.push( name ); + } else { + // Each filter is its own param + result = result.concat( model.getItems().map( function ( filterItem ) { + return filterItem.getParamName(); + } ) ); + } + } + } ); + + return result; + }; + + /** + * Get a parameter representation of all sticky parameters + * + * @return {Object} Sticky parameter values + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getStickyParamsValues = function () { + var result = {}; + + $.each( this.groups, function ( 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 + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getParametersFromFilters = function ( filterDefinition ) { + var groupItemDefinition, + 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( function ( filterItem ) { + groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {}; + groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = filterItem.coerceValue( filterDefinition[ filterItem.getName() ] ); + } ); + } + + $.each( groupItems, function ( group, model ) { + $.extend( + 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 + */ + 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 groupName, + itemOrGroup = model.parameterMap[ paramName ]; + + if ( itemOrGroup ) { + groupName = itemOrGroup instanceof mw.rcfilters.dm.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 + $.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 + * are the selected highlight colors. + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getHighlightParameters = function () { + var highlightEnabled = this.isHighlightEnabled(), + result = {}; + + this.getItems().forEach( function ( 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 + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getEmptyHighlightParameters = function () { + var result = {}; + + this.getItems().forEach( function ( 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 + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentlyUsedHighlightColors = function () { + var result = []; + + if ( this.isHighlightEnabled() ) { + this.getHighlightedItems().forEach( function ( filterItem ) { + var 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 + */ + mw.rcfilters.dm.FiltersViewModel.prototype.sanitizeStringOptionGroup = function ( groupName, valueArray ) { + var validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) { + return filterItem.getParamName(); + } ); + + return mw.rcfilters.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 + */ + mw.rcfilters.dm.FiltersViewModel.prototype.areVisibleFiltersEmpty = function () { + // Check if there are either any selected items or any items + // that have highlight enabled + return !this.getItems().some( function ( filterItem ) { + var visible = !filterItem.getGroupModel().isSticky() && !filterItem.getGroupModel().isHidden(), + active = ( filterItem.isSelected() || filterItem.isHighlighted() ); + return visible && active; + } ); + }; + + /** + * Check whether the 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 + */ + mw.rcfilters.dm.FiltersViewModel.prototype.areNamespacesEffectivelyInverted = function () { + return this.getInvertModel().isSelected() && + this.findSelectedItems().some( function ( itemModel ) { + return itemModel.getGroupModel().getName() === 'namespace'; + } ); + }; + + /** + * Get the item that matches the given name + * + * @param {string} name Filter name + * @return {mw.rcfilters.dm.FilterItem} Filter item + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getItemByName = function ( name ) { + return this.getItems().filter( function ( item ) { + return name === item.getName(); + } )[ 0 ]; + }; + + /** + * Set all filters to false or empty/all + * This is equivalent to display all. + */ + mw.rcfilters.dm.FiltersViewModel.prototype.emptyAllFilters = function () { + this.getItems().forEach( function ( filterItem ) { + if ( !filterItem.getGroupModel().isSticky() ) { + this.toggleFilterSelected( filterItem.getName(), false ); + } + }.bind( this ) ); + }; + + /** + * Toggle selected state of one item + * + * @param {string} name Name of the filter item + * @param {boolean} [isSelected] Filter selected state + */ + mw.rcfilters.dm.FiltersViewModel.prototype.toggleFilterSelected = function ( name, isSelected ) { + var item = this.getItemByName( name ); + + if ( item ) { + item.toggleSelected( isSelected ); + } + }; + + /** + * Toggle selected state of items by their names + * + * @param {Object} filterDef Filter definitions + */ + mw.rcfilters.dm.FiltersViewModel.prototype.toggleFiltersSelected = function ( filterDef ) { + Object.keys( filterDef ).forEach( function ( name ) { + this.toggleFilterSelected( name, filterDef[ name ] ); + }.bind( this ) ); + }; + + /** + * 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} 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 + */ + mw.rcfilters.dm.FiltersViewModel.prototype.findMatches = function ( query, returnFlat ) { + var i, searchIsEmpty, + groupTitle, + result = {}, + flatResult = [], + view = this.getViewByTrigger( query.substr( 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.substr( 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 + searchIsEmpty = query.length === 0; + + // item label starting with the query string + for ( 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 ( 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 || + ( + // 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 + * + * @return {mw.rcfilters.dm.FilterItem[]} Highlighted items + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getHighlightedItems = function () { + return this.getItems().filter( function ( filterItem ) { + return filterItem.isHighlightSupported() && + filterItem.getHighlightColor(); + } ); + }; + + /** + * Get items that allow highlights even if they're not currently highlighted + * + * @return {mw.rcfilters.dm.FilterItem[]} Items supporting highlights + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getItemsSupportingHighlights = function () { + return this.getItems().filter( function ( filterItem ) { + return filterItem.isHighlightSupported(); + } ); + }; + + /** + * Get all selected items + * + * @return {mw.rcfilters.dm.FilterItem[]} Selected items + */ + mw.rcfilters.dm.FiltersViewModel.prototype.findSelectedItems = function () { + var allSelected = []; + + $.each( this.getFilterGroups(), function ( groupName, groupModel ) { + allSelected = allSelected.concat( groupModel.findSelectedItems() ); + } ); + + return allSelected; + }; + + /** + * 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 + * + * @param {string} viewName View name + * @return {string} Label for the current view + */ + mw.rcfilters.dm.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 + */ + 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; + }; + + /** + * Return a version of the given string that is without any + * view triggers. + * + * @param {string} str Given string + * @return {string} Result + */ + mw.rcfilters.dm.FiltersViewModel.prototype.removeViewTriggers = function ( str ) { + if ( this.getViewFromString( str ) !== 'default' ) { + str = str.substr( 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 + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getViewFromString = function ( str ) { + return this.getViewByTrigger( str.substr( 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 + */ + mw.rcfilters.dm.FiltersViewModel.prototype.setSearch = function ( searchQuery ) { + var 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 + $.each( this.getFilterGroups(), function ( 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 + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getSearch = function () { + return this.searchQuery; + }; + + /** + * Switch the current view + * + * @private + * @param {string} view View name + */ + mw.rcfilters.dm.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 + */ + mw.rcfilters.dm.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} + */ + mw.rcfilters.dm.FiltersViewModel.prototype.isHighlightEnabled = function () { + 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 + */ + mw.rcfilters.dm.FiltersViewModel.prototype.toggleInvertedNamespaces = function ( enable ) { + this.toggleFilterSelected( this.getInvertModel().getName(), enable ); + }; + + /** + * Get the model object that represents the 'invert' filter + * + * @return {mw.rcfilters.dm.FilterItem} + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getInvertModel = function () { + return this.getGroup( 'invertGroup' ).getItemByParamName( 'invert' ); + }; + + /** + * Set highlight color for a specific filter item + * + * @param {string} filterName Name of the filter item + * @param {string} color Selected color + */ + mw.rcfilters.dm.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 + */ + mw.rcfilters.dm.FiltersViewModel.prototype.clearHighlightColor = function ( filterName ) { + this.getItemByName( filterName ).clearHighlightColor(); + }; + +}( mediaWiki, jQuery ) ); |