1 const FilterGroup = require( './FilterGroup.js' ),
2 FilterItem = require( './FilterItem.js' ),
3 utils = require( '../utils.js' );
6 * View model for the filters selection and display.
8 * @class mw.rcfilters.dm.FiltersViewModel
10 * @mixes OO.EventEmitter
11 * @mixes OO.EmitterList
13 const FiltersViewModel = function MwRcfiltersDmFiltersViewModel() {
15 OO.EventEmitter.call( this );
16 OO.EmitterList.call( this );
19 this.defaultParams = {};
20 this.highlightEnabled = false;
21 this.parameterMap = {};
22 this.emptyParameterState = null;
25 this.currentView = 'default';
26 this.searchQuery = null;
29 this.aggregate( { update: 'filterItemUpdate' } );
30 this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
34 OO.initClass( FiltersViewModel );
35 OO.mixinClass( FiltersViewModel, OO.EventEmitter );
36 OO.mixinClass( FiltersViewModel, OO.EmitterList );
41 * Filter list is initialized.
48 * Model has been updated.
55 * Filter item has changed.
58 * @param {mw.rcfilters.dm.FilterItem} item Filter item updated
63 * Highlight feature has been toggled enabled or disabled.
65 * @event highlightChange
66 * @param {boolean} Highlight feature is enabled
73 * Re-assess the states of filter items based on the interactions between them
75 * @param {mw.rcfilters.dm.FilterItem} [item] Changed item. If not given, the
76 * method will go over the state of all items
78 FiltersViewModel.prototype.reassessFilterInteractions = function ( item ) {
79 const iterationItems = item !== undefined ? [ item ] : this.getItems();
81 iterationItems.forEach( ( checkedItem ) => {
82 const allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ),
83 groupModel = checkedItem.getGroupModel();
85 // Check for subsets (included filters) plus the item itself:
86 allCheckedItems.forEach( ( filterItemName ) => {
87 const itemInSubset = this.getItemByName( filterItemName );
89 itemInSubset.toggleIncluded(
90 // If any of itemInSubset's supersets are selected, this item
92 itemInSubset.getSuperset().some( ( supersetName ) => ( this.getItemByName( supersetName ).isSelected() ) )
96 // Update coverage for the changed group
97 if ( groupModel.isFullCoverage() ) {
98 const allSelected = groupModel.areAllSelected();
99 groupModel.getItems().forEach( ( filterItem ) => {
100 filterItem.toggleFullyCovered( allSelected );
105 // Check for conflicts
106 // In this case, we must go over all items, since
107 // conflicts are bidirectional and depend not only on
108 // individual items, but also on the selected states of
109 // the groups they're in.
110 this.getItems().forEach( ( filterItem ) => {
111 let inConflict = false;
112 const filterItemGroup = filterItem.getGroupModel();
114 // For each item, see if that item is still conflicting
115 // eslint-disable-next-line no-jquery/no-each-util
116 $.each( this.groups, ( groupName, groupModel ) => {
117 if ( filterItem.getGroupName() === groupName ) {
118 // Check inside the group
119 inConflict = groupModel.areAnySelectedInConflictWith( filterItem );
121 // According to the spec, if two items conflict from two different
122 // groups, the conflict only lasts if the groups **only have selected
123 // items that are conflicting**. If a group has selected items that
124 // are conflicting and non-conflicting, the scope of the result has
125 // expanded enough to completely remove the conflict.
127 // For example, see two groups with conflicts:
130 // name: 'experienced',
131 // conflicts: [ 'unregistered' ]
136 // name: 'registered',
139 // name: 'unregistered',
142 // If we select 'experienced', then 'unregistered' is in conflict (and vice versa),
143 // because, inherently, 'experienced' filter only includes registered users, and so
144 // both filters are in conflict with one another.
145 // However, the minute we select 'registered', the scope of our results
146 // has expanded to no longer have a conflict with 'experienced' filter, and
147 // so the conflict is removed.
149 // In our case, we need to check if the entire group conflicts with
150 // the entire item's group, so we follow the above spec
152 // The foreign group is in conflict with this item
153 groupModel.areAllSelectedInConflictWith( filterItem ) &&
154 // Every selected member of the item's own group is also
155 // in conflict with the other group
156 filterItemGroup.findSelectedItems().every( ( otherGroupItem ) => groupModel.areAllSelectedInConflictWith( otherGroupItem ) )
160 // If we're in conflict, this will return 'false' which
161 // will break the loop. Otherwise, we're not in conflict
162 // and the loop continues
166 // Toggle the item state
167 filterItem.toggleConflicted( inConflict );
172 * Get whether the model has any conflict in its items
174 * @return {boolean} There is a conflict
176 FiltersViewModel.prototype.hasConflict = function () {
177 return this.getItems().some( ( filterItem ) => filterItem.isSelected() && filterItem.isConflicted() );
181 * Get the first item with a current conflict
184 * @return {mw.rcfilters.dm.FilterItem|undefined} Conflicted item or undefined when not found
186 FiltersViewModel.prototype.getFirstConflictedItem = function () {
187 const items = this.getItems();
188 for ( let i = 0; i < items.length; i++ ) {
189 const filterItem = items[ i ];
190 if ( filterItem.isSelected() && filterItem.isConflicted() ) {
197 * Set filters and preserve a group relationship based on
198 * the definition given by an object
200 * @param {Array} filterGroups Filters definition
201 * @param {Object} [views] Extra views definition
202 * Expected in the following format:
205 * label: 'namespaces', // Message key
210 * name: 'namespaces' // Parameter name
211 * title: 'namespaces' // Message key
212 * type: 'string_options',
214 * labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
222 FiltersViewModel.prototype.initializeFilters = function ( filterGroups, views ) {
224 groupConflictMap = {},
225 filterConflictMap = {},
227 * Expand a conflict definition from group name to
228 * the list of all included filters in that group.
229 * We do this so that the direct relationship in the
230 * models are consistently item->items rather than
231 * mixing item->group with item->item.
233 * @param {Object} obj Conflict definition
234 * @return {Object} Expanded conflict definition
236 expandConflictDefinitions = ( obj ) => {
239 // eslint-disable-next-line no-jquery/no-each-util
240 $.each( obj, ( key, conflicts ) => {
241 const adjustedConflicts = {};
243 conflicts.forEach( ( conflict ) => {
246 if ( conflict.filter ) {
247 const filterName = this.groups[ conflict.group ].getPrefixedName( conflict.filter );
248 filter = this.getItemByName( filterName );
251 adjustedConflicts[ filterName ] = Object.assign(
260 // This conflict is for an entire group. Split it up to
261 // represent each filter
263 // Get the relevant group items
264 this.groups[ conflict.group ].getItems().forEach( ( groupItem ) => {
265 // Rebuild the conflict
266 adjustedConflicts[ groupItem.getName() ] = Object.assign(
270 filter: groupItem.getName(),
278 result[ key ] = adjustedConflicts;
290 filterGroups = OO.copy( filterGroups );
292 // Normalize definition from the server
293 filterGroups.forEach( ( data ) => {
295 // What's this information needs to be normalized
297 body: data.whatsThisBody,
298 header: data.whatsThisHeader,
299 linkText: data.whatsThisLinkText,
300 url: data.whatsThisUrl
303 // Title is a msg-key
304 // eslint-disable-next-line mediawiki/msg-doc
305 data.title = data.title ? mw.msg( data.title ) : data.name;
307 // Filters are given to us with msg-keys, we need
308 // to translate those before we hand them off
309 for ( i = 0; i < data.filters.length; i++ ) {
310 // eslint-disable-next-line mediawiki/msg-doc
311 data.filters[ i ].label = data.filters[ i ].label ? mw.msg( data.filters[ i ].label ) : data.filters[ i ].name;
312 // eslint-disable-next-line mediawiki/msg-doc
313 data.filters[ i ].description = data.filters[ i ].description ? mw.msg( data.filters[ i ].description ) : '';
318 const allViews = $.extend( true, {
320 title: mw.msg( 'rcfilters-filterlist-title' ),
326 // eslint-disable-next-line no-jquery/no-each-util
327 $.each( allViews, ( viewName, viewData ) => {
329 this.views[ viewName ] = {
331 title: viewData.title,
332 trigger: viewData.trigger
336 viewData.groups.forEach( ( groupData ) => {
337 const group = groupData.name;
339 if ( !this.groups[ group ] ) {
340 this.groups[ group ] = new FilterGroup(
342 $.extend( true, {}, groupData, { view: viewName } )
346 this.groups[ group ].initializeFilters( groupData.filters, groupData.default );
347 items.push( ...this.groups[ group ].getItems() );
350 if ( groupData.conflicts ) {
352 groupConflictMap[ group ] = groupData.conflicts;
355 groupData.filters.forEach( ( itemData ) => {
356 const filterItem = this.groups[ group ].getItemByParamName( itemData.name );
358 if ( itemData.conflicts ) {
359 filterConflictMap[ filterItem.getName() ] = itemData.conflicts;
365 // Add item references to the model, for lookup
366 this.addItems( items );
369 const groupConflictResult = expandConflictDefinitions( groupConflictMap );
370 const filterConflictResult = expandConflictDefinitions( filterConflictMap );
372 // Set conflicts for groups
373 // eslint-disable-next-line no-jquery/no-each-util
374 $.each( groupConflictResult, ( group, conflicts ) => {
375 this.groups[ group ].setConflicts( conflicts );
378 // Set conflicts for items
379 // eslint-disable-next-line no-jquery/no-each-util
380 $.each( filterConflictResult, ( filterName, conflicts ) => {
381 const filterItem = this.getItemByName( filterName );
382 // set conflicts for items in the group
383 filterItem.setConflicts( conflicts );
386 // Create a map between known parameters and their models
387 // eslint-disable-next-line no-jquery/no-each-util
388 $.each( this.groups, ( group, groupModel ) => {
390 groupModel.getType() === 'send_unselected_if_any' ||
391 groupModel.getType() === 'boolean' ||
392 groupModel.getType() === 'any_value'
394 // Individual filters
395 groupModel.getItems().forEach( ( filterItem ) => {
396 this.parameterMap[ filterItem.getParamName() ] = filterItem;
399 groupModel.getType() === 'string_options' ||
400 groupModel.getType() === 'single_option'
403 this.parameterMap[ groupModel.getName() ] = groupModel;
407 this.setSearch( '' );
409 this.updateHighlightedState();
411 // Finish initialization
412 this.emit( 'initialize' );
416 * Update filter view model state based on a parameter object
418 * @param {Object} params Parameters object
420 FiltersViewModel.prototype.updateStateFromParams = function ( params ) {
421 // For arbitrary numeric single_option values make sure the values
422 // are normalized to fit within the limits
423 // eslint-disable-next-line no-jquery/no-each-util
424 $.each( this.getFilterGroups(), ( groupName, groupModel ) => {
425 params[ groupName ] = groupModel.normalizeArbitraryValue( params[ groupName ] );
428 // Update filter values
429 const filtersValue = this.getFiltersFromParameters( params );
430 Object.keys( filtersValue ).forEach( ( filterName ) => {
431 this.getItemByName( filterName ).setValue( filtersValue[ filterName ] );
434 // Update highlight state
435 this.getItemsSupportingHighlights().forEach( ( filterItem ) => {
436 const color = params[ filterItem.getName() + '_color' ];
438 filterItem.setHighlightColor( color );
440 filterItem.clearHighlightColor();
443 this.updateHighlightedState();
445 // Check all filter interactions
446 this.reassessFilterInteractions();
450 * Get a representation of an empty (falsey) parameter state
452 * @return {Object} Empty parameter state
454 FiltersViewModel.prototype.getEmptyParameterState = function () {
455 if ( !this.emptyParameterState ) {
456 this.emptyParameterState = $.extend(
459 this.getParametersFromFilters( {} ),
460 this.getEmptyHighlightParameters()
463 return this.emptyParameterState;
467 * Get a representation of only the non-falsey parameters
469 * @param {Object} [parameters] A given parameter state to minimize. If not given the current
470 * state of the system will be used.
471 * @return {Object} Empty parameter state
473 FiltersViewModel.prototype.getMinimizedParamRepresentation = function ( parameters ) {
476 parameters = parameters ? $.extend( true, {}, parameters ) : this.getCurrentParameterState();
479 // eslint-disable-next-line no-jquery/no-each-util
480 $.each( this.getEmptyParameterState(), ( param, value ) => {
481 if ( parameters[ param ] !== undefined && parameters[ param ] !== value ) {
482 result[ param ] = parameters[ param ];
487 Object.keys( this.getEmptyHighlightParameters() ).forEach( ( param ) => {
488 if ( parameters[ param ] ) {
489 // If a highlight parameter is not undefined and not null
490 // add it to the result
491 result[ param ] = parameters[ param ];
499 * Get a representation of the full parameter list, including all base values
501 * @return {Object} Full parameter representation
503 FiltersViewModel.prototype.getExpandedParamRepresentation = function () {
507 this.getEmptyParameterState(),
508 this.getCurrentParameterState()
513 * Get a parameter representation of the current state of the model
515 * @param {boolean} [removeStickyParams] Remove sticky filters from final result
516 * @return {Object} Parameter representation of the current state of the model
518 FiltersViewModel.prototype.getCurrentParameterState = function ( removeStickyParams ) {
519 let state = this.getMinimizedParamRepresentation( $.extend(
522 this.getParametersFromFilters( this.getSelectedState() ),
523 this.getHighlightParameters()
526 if ( removeStickyParams ) {
527 state = this.removeStickyParams( state );
534 * Delete sticky parameters from given object.
536 * @param {Object} paramState Parameter state
537 * @return {Object} Parameter state without sticky parameters
539 FiltersViewModel.prototype.removeStickyParams = function ( paramState ) {
540 this.getStickyParams().forEach( ( paramName ) => {
541 delete paramState[ paramName ];
548 * Turn the highlight feature on or off
550 FiltersViewModel.prototype.updateHighlightedState = function () {
551 this.toggleHighlight( this.getHighlightedItems().length > 0 );
555 * Get the object that defines groups by their name.
557 * @return {Object} Filter groups
559 FiltersViewModel.prototype.getFilterGroups = function () {
564 * Get the object that defines groups that match a certain view by their name.
566 * @param {string} [view] Requested view. If not given, uses current view
567 * @return {Object} Filter groups matching a display group
569 FiltersViewModel.prototype.getFilterGroupsByView = function ( view ) {
572 view = view || this.getCurrentView();
574 // eslint-disable-next-line no-jquery/no-each-util
575 $.each( this.groups, ( groupName, groupModel ) => {
576 if ( groupModel.getView() === view ) {
577 result[ groupName ] = groupModel;
585 * Get an array of filters matching the given display group.
588 * @param {string} [view] Requested view. If not given, uses current view
589 * @return {mw.rcfilters.dm.FilterItem} Filter items matching the group
591 FiltersViewModel.prototype.getFiltersByView = function ( view ) {
594 view = view || this.getCurrentView();
596 const groups = this.getFilterGroupsByView( view );
598 // eslint-disable-next-line no-jquery/no-each-util
599 $.each( groups, ( groupName, groupModel ) => {
600 result.push( ...groupModel.getItems() );
607 * Get the trigger for the requested view.
609 * @param {string} view View name
610 * @return {string} View trigger, if exists
612 FiltersViewModel.prototype.getViewTrigger = function ( view ) {
613 return ( this.views[ view ] && this.views[ view ].trigger ) || '';
617 * Get the value of a specific parameter
619 * @param {string} name Parameter name
620 * @return {number|string} Parameter value
622 FiltersViewModel.prototype.getParamValue = function ( name ) {
623 return this.parameters[ name ];
627 * Get the current selected state of the filters
629 * @param {boolean} [onlySelected] return an object containing only the filters with a value
630 * @return {Object} Filters selected state
632 FiltersViewModel.prototype.getSelectedState = function ( onlySelected ) {
633 const items = this.getItems(),
636 for ( let i = 0; i < items.length; i++ ) {
637 if ( !onlySelected || items[ i ].getValue() ) {
638 result[ items[ i ].getName() ] = items[ i ].getValue();
646 * Get the current full state of the filters
648 * @return {Object} Filters full state
650 FiltersViewModel.prototype.getFullState = function () {
651 const items = this.getItems(),
654 for ( let i = 0; i < items.length; i++ ) {
655 result[ items[ i ].getName() ] = {
656 selected: items[ i ].isSelected(),
657 conflicted: items[ i ].isConflicted(),
658 included: items[ i ].isIncluded()
666 * Get an object representing default parameters state
668 * @return {Object} Default parameter values
670 FiltersViewModel.prototype.getDefaultParams = function () {
673 // Get default filter state
674 // eslint-disable-next-line no-jquery/no-each-util
675 $.each( this.groups, ( name, model ) => {
676 if ( !model.isSticky() ) {
677 $.extend( true, result, model.getDefaultParams() );
685 * Get a parameter representation of all sticky parameters
687 * @return {Object} Sticky parameter values
689 FiltersViewModel.prototype.getStickyParams = function () {
692 // eslint-disable-next-line no-jquery/no-each-util
693 $.each( this.groups, ( name, model ) => {
694 if ( model.isSticky() ) {
695 if ( model.isPerGroupRequestParameter() ) {
698 // Each filter is its own param
699 result = result.concat( model.getItems().map( ( filterItem ) => filterItem.getParamName() ) );
708 * Get a parameter representation of all sticky parameters
710 * @return {Object} Sticky parameter values
712 FiltersViewModel.prototype.getStickyParamsValues = function () {
715 // eslint-disable-next-line no-jquery/no-each-util
716 $.each( this.groups, ( name, model ) => {
717 if ( model.isSticky() ) {
718 $.extend( true, result, model.getParamRepresentation() );
726 * Analyze the groups and their filters and output an object representing
727 * the state of the parameters they represent.
729 * @param {Object} [filterDefinition] An object defining the filter values,
730 * keyed by filter names.
731 * @return {Object} Parameter state object
733 FiltersViewModel.prototype.getParametersFromFilters = function ( filterDefinition ) {
734 let groupItemDefinition;
736 groupItems = this.getFilterGroups();
738 if ( filterDefinition ) {
739 groupItemDefinition = {};
740 // Filter definition is "flat", but in effect
741 // each group needs to tell us its result based
742 // on the values in it. We need to split this list
743 // back into groupings so we can "feed" it to the
744 // loop below, and we need to expand it so it includes
745 // all filters (set to false)
746 this.getItems().forEach( ( filterItem ) => {
747 groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {};
748 groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = filterItem.coerceValue( filterDefinition[ filterItem.getName() ] );
752 // eslint-disable-next-line no-jquery/no-each-util
753 $.each( groupItems, ( group, model ) => {
756 model.getParamRepresentation(
757 groupItemDefinition ?
758 groupItemDefinition[ group ] : null
767 * This is the opposite of the #getParametersFromFilters method; this goes over
768 * the given parameters and translates into a selected/unselected value in the filters.
770 * @param {Object} params Parameters query object
771 * @return {Object} Filter state object
773 FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
777 // Go over the given parameters, break apart to groupings
778 // The resulting object represents the group with its parameter
779 // values. For example:
786 // group2: "param4|param5"
788 // eslint-disable-next-line no-jquery/no-each-util
789 $.each( params, ( paramName, paramValue ) => {
790 const itemOrGroup = this.parameterMap[ paramName ];
793 const groupName = itemOrGroup instanceof FilterItem ?
794 itemOrGroup.getGroupName() : itemOrGroup.getName();
796 groupMap[ groupName ] = groupMap[ groupName ] || {};
797 groupMap[ groupName ][ paramName ] = paramValue;
801 // Go over all groups, so we make sure we get the complete output
802 // even if the parameters don't include a certain group
803 // eslint-disable-next-line no-jquery/no-each-util
804 $.each( this.groups, ( groupName, groupModel ) => {
805 result = $.extend( true, {}, result, groupModel.getFilterRepresentation( groupMap[ groupName ] ) );
812 * Get the highlight parameters based on current filter configuration
814 * @return {Object} Object where keys are `<filter name>_color` and values
815 * are the selected highlight colors.
817 FiltersViewModel.prototype.getHighlightParameters = function () {
818 const highlightEnabled = this.isHighlightEnabled(),
821 this.getItems().forEach( ( filterItem ) => {
822 if ( filterItem.isHighlightSupported() ) {
823 result[ filterItem.getName() + '_color' ] = highlightEnabled && filterItem.isHighlighted() ?
824 filterItem.getHighlightColor() :
833 * Get an object representing the complete empty state of highlights
835 * @return {Object} Object containing all the highlight parameters set to their negative value
837 FiltersViewModel.prototype.getEmptyHighlightParameters = function () {
840 this.getItems().forEach( ( filterItem ) => {
841 if ( filterItem.isHighlightSupported() ) {
842 result[ filterItem.getName() + '_color' ] = null;
850 * Get an array of currently applied highlight colors
852 * @return {string[]} Currently applied highlight colors
854 FiltersViewModel.prototype.getCurrentlyUsedHighlightColors = function () {
857 if ( this.isHighlightEnabled() ) {
858 this.getHighlightedItems().forEach( ( filterItem ) => {
859 const color = filterItem.getHighlightColor();
861 if ( result.indexOf( color ) === -1 ) {
862 result.push( color );
871 * Sanitize value group of a string_option groups type
872 * Remove duplicates and make sure to only use valid
876 * @param {string} groupName Group name
877 * @param {string[]} valueArray Array of values
878 * @return {string[]} Array of valid values
880 FiltersViewModel.prototype.sanitizeStringOptionGroup = function ( groupName, valueArray ) {
881 const validNames = this.getGroupFilters( groupName ).map( ( filterItem ) => filterItem.getParamName() );
883 return utils.normalizeParamOptions( valueArray, validNames );
887 * Check whether no visible filter is selected.
889 * Filter groups that are hidden or sticky are not shown in the
890 * active filters area and therefore not included in this check.
892 * @return {boolean} No visible filter is selected
894 FiltersViewModel.prototype.areVisibleFiltersEmpty = function () {
895 // Check if there are either any selected items or any items
896 // that have highlight enabled
897 return !this.getItems().some( ( filterItem ) => {
898 const visible = !filterItem.getGroupModel().isSticky() && !filterItem.getGroupModel().isHidden(),
899 active = ( filterItem.isSelected() || filterItem.isHighlighted() );
900 return visible && active;
905 * Check whether the namespace invert state is a valid one. A valid invert state is one
906 * where there are actual namespaces selected.
908 * This is done to compare states to previous ones that may have had the invert model
909 * selected but effectively had no namespaces, so are not effectively different than
910 * ones where invert is not selected.
912 * @return {boolean} Invert is effectively selected
914 FiltersViewModel.prototype.areNamespacesEffectivelyInverted = function () {
915 return this.getNamespacesInvertModel().isSelected() &&
916 this.findSelectedItems().some( ( itemModel ) => itemModel.getGroupModel().getName() === 'namespace' );
920 * Check whether the tag invert state is a valid one. A valid invert state is one
921 * where there are actual tags selected.
923 * This is done to compare states to previous ones that may have had the invert model
924 * selected but effectively had no tags, so are not effectively different than
925 * ones where invert is not selected.
927 * @return {boolean} Invert is effectively selected
929 FiltersViewModel.prototype.areTagsEffectivelyInverted = function () {
930 return this.getTagsInvertModel().isSelected() &&
931 this.findSelectedItems().some( ( itemModel ) => itemModel.getGroupModel().getName() === 'tagfilter' );
935 * Get the item that matches the given name
938 * @param {string} name Filter name
939 * @return {mw.rcfilters.dm.FilterItem} Filter item
941 FiltersViewModel.prototype.getItemByName = function ( name ) {
942 return this.getItems().filter( ( item ) => name === item.getName() )[ 0 ];
946 * Set all filters to false or empty/all
947 * This is equivalent to display all.
949 FiltersViewModel.prototype.emptyAllFilters = function () {
950 this.getItems().forEach( ( filterItem ) => {
951 if ( !filterItem.getGroupModel().isSticky() ) {
952 this.toggleFilterSelected( filterItem.getName(), false );
958 * Toggle selected state of one item
960 * @param {string} name Name of the filter item
961 * @param {boolean} [isSelected] Filter selected state
963 FiltersViewModel.prototype.toggleFilterSelected = function ( name, isSelected ) {
964 const item = this.getItemByName( name );
967 item.toggleSelected( isSelected );
972 * Toggle selected state of items by their names
974 * @param {Object} filterDef Filter definitions
976 FiltersViewModel.prototype.toggleFiltersSelected = function ( filterDef ) {
977 Object.keys( filterDef ).forEach( ( name ) => {
978 this.toggleFilterSelected( name, filterDef[ name ] );
983 * Get a group model from its name
986 * @param {string} groupName Group name
987 * @return {mw.rcfilters.dm.FilterGroup} Group model
989 FiltersViewModel.prototype.getGroup = function ( groupName ) {
990 return this.groups[ groupName ];
994 * Get all filters within a specified group by its name
997 * @param {string} groupName Group name
998 * @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
1000 FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
1001 return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
1005 * Find items whose labels match the given string
1007 * @param {string} query Search string
1008 * @param {boolean} [returnFlat] Return a flat array. If false, the result
1009 * is an object whose keys are the group names and values are an array of
1010 * filters per group. If set to true, returns an array of filters regardless
1012 * @return {Object} An object of items to show
1013 * arranged by their group names
1015 FiltersViewModel.prototype.findMatches = function ( query, returnFlat ) {
1018 view = this.getViewByTrigger( query.slice( 0, 1 ) ),
1019 items = this.getFiltersByView( view );
1021 // Normalize so we can search strings regardless of case and view
1022 query = query.trim().toLowerCase();
1023 if ( view !== 'default' ) {
1024 query = query.slice( 1 );
1026 // Trim again to also intercept cases where the spaces were after the trigger
1028 query = query.trim();
1030 // Check if the search if actually empty; this can be a problem when
1031 // we use prefixes to denote different views
1032 const searchIsEmpty = query.length === 0;
1034 // item label starting with the query string
1035 for ( let i = 0; i < items.length; i++ ) {
1038 items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ||
1040 // For tags, we want the parameter name to be included in the search
1042 items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
1045 result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
1046 result[ items[ i ].getGroupName() ].push( items[ i ] );
1047 flatResult.push( items[ i ] );
1051 if ( $.isEmptyObject( result ) ) {
1052 // item containing the query string in their label, description, or group title
1053 for ( let i = 0; i < items.length; i++ ) {
1054 const groupTitle = items[ i ].getGroupModel().getTitle();
1057 items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
1058 items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
1059 groupTitle.toLowerCase().indexOf( query ) > -1 ||
1061 // For tags, we want the parameter name to be included in the search
1063 items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
1066 result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
1067 result[ items[ i ].getGroupName() ].push( items[ i ] );
1068 flatResult.push( items[ i ] );
1073 return returnFlat ? flatResult : result;
1077 * Get items that are highlighted
1080 * @return {mw.rcfilters.dm.FilterItem[]} Highlighted items
1082 FiltersViewModel.prototype.getHighlightedItems = function () {
1083 return this.getItems().filter( ( filterItem ) => filterItem.isHighlightSupported() &&
1084 filterItem.getHighlightColor() );
1088 * Get items that allow highlights even if they're not currently highlighted
1091 * @return {mw.rcfilters.dm.FilterItem[]} Items supporting highlights
1093 FiltersViewModel.prototype.getItemsSupportingHighlights = function () {
1094 return this.getItems().filter( ( filterItem ) => filterItem.isHighlightSupported() );
1098 * Get all selected items
1101 * @return {mw.rcfilters.dm.FilterItem[]} Selected items
1103 FiltersViewModel.prototype.findSelectedItems = function () {
1104 let allSelected = [];
1106 // eslint-disable-next-line no-jquery/no-each-util
1107 $.each( this.getFilterGroups(), ( groupName, groupModel ) => {
1108 allSelected = allSelected.concat( groupModel.findSelectedItems() );
1115 * Get the current view
1117 * @return {string} Current view
1119 FiltersViewModel.prototype.getCurrentView = function () {
1120 return this.currentView;
1124 * Get the label for the current view
1126 * @param {string} viewName View name
1127 * @return {string} Label for the current view
1129 FiltersViewModel.prototype.getViewTitle = function ( viewName ) {
1130 viewName = viewName || this.getCurrentView();
1132 return this.views[ viewName ] && this.views[ viewName ].title;
1136 * Get the view that fits the given trigger
1138 * @param {string} trigger Trigger
1139 * @return {string} Name of view
1141 FiltersViewModel.prototype.getViewByTrigger = function ( trigger ) {
1142 let result = 'default';
1144 // eslint-disable-next-line no-jquery/no-each-util
1145 $.each( this.views, ( name, data ) => {
1146 if ( data.trigger === trigger ) {
1155 * Return a version of the given string that is without any
1158 * @param {string} str Given string
1159 * @return {string} Result
1161 FiltersViewModel.prototype.removeViewTriggers = function ( str ) {
1162 if ( this.getViewFromString( str ) !== 'default' ) {
1163 str = str.slice( 1 );
1170 * Get the view from the given string by a trigger, if it exists
1172 * @param {string} str Given string
1173 * @return {string} View name
1175 FiltersViewModel.prototype.getViewFromString = function ( str ) {
1176 return this.getViewByTrigger( str.slice( 0, 1 ) );
1180 * Set the current search for the system.
1181 * This also dictates what items and groups are visible according
1182 * to the search in #findMatches
1184 * @param {string} searchQuery Search query, including triggers
1185 * @fires searchChange
1187 FiltersViewModel.prototype.setSearch = function ( searchQuery ) {
1188 let visibleGroups, visibleGroupNames;
1190 if ( this.searchQuery !== searchQuery ) {
1191 // Check if the view changed
1192 this.switchView( this.getViewFromString( searchQuery ) );
1194 visibleGroups = this.findMatches( searchQuery );
1195 visibleGroupNames = Object.keys( visibleGroups );
1197 // Update visibility of items and groups
1198 // eslint-disable-next-line no-jquery/no-each-util
1199 $.each( this.getFilterGroups(), ( groupName, groupModel ) => {
1200 // Check if the group is visible at all
1201 groupModel.toggleVisible( visibleGroupNames.indexOf( groupName ) !== -1 );
1202 groupModel.setVisibleItems( visibleGroups[ groupName ] || [] );
1205 this.searchQuery = searchQuery;
1206 this.emit( 'searchChange', this.searchQuery );
1211 * Get the current search
1213 * @return {string} Current search query
1215 FiltersViewModel.prototype.getSearch = function () {
1216 return this.searchQuery;
1220 * Switch the current view
1223 * @param {string} view View name
1225 FiltersViewModel.prototype.switchView = function ( view ) {
1226 if ( this.views[ view ] && this.currentView !== view ) {
1227 this.currentView = view;
1232 * Toggle the highlight feature on and off.
1233 * Propagate the change to filter items.
1235 * @param {boolean} enable Highlight should be enabled
1236 * @fires highlightChange
1238 FiltersViewModel.prototype.toggleHighlight = function ( enable ) {
1239 enable = enable === undefined ? !this.highlightEnabled : enable;
1241 if ( this.highlightEnabled !== enable ) {
1242 this.highlightEnabled = enable;
1243 this.emit( 'highlightChange', this.highlightEnabled );
1248 * Check if the highlight feature is enabled
1252 FiltersViewModel.prototype.isHighlightEnabled = function () {
1253 return !!this.highlightEnabled;
1257 * Toggle the inverted tags property on and off.
1258 * Propagate the change to tag filter items.
1260 * @param {boolean} enable Inverted property is enabled
1262 FiltersViewModel.prototype.toggleInvertedTags = function ( enable ) {
1263 this.toggleFilterSelected( this.getTagsInvertModel().getName(), enable );
1267 * Toggle the inverted namespaces property on and off.
1268 * Propagate the change to namespace filter items.
1270 * @param {boolean} enable Inverted property is enabled
1272 FiltersViewModel.prototype.toggleInvertedNamespaces = function ( enable ) {
1273 this.toggleFilterSelected( this.getNamespacesInvertModel().getName(), enable );
1277 * Get the model object that represents the 'invert' filter
1280 * @param {string} view
1281 * @return {mw.rcfilters.dm.FilterItem|null}
1283 FiltersViewModel.prototype.getInvertModel = function ( view ) {
1284 if ( view === 'namespaces' ) {
1285 return this.getNamespacesInvertModel();
1287 if ( view === 'tags' ) {
1288 return this.getTagsInvertModel();
1295 * Get the model object that represents the 'invert' filter
1298 * @return {mw.rcfilters.dm.FilterItem}
1300 FiltersViewModel.prototype.getNamespacesInvertModel = function () {
1301 return this.getGroup( 'invertGroup' ).getItemByParamName( 'invert' );
1305 * Get the model object that represents the 'invert' filter
1308 * @return {mw.rcfilters.dm.FilterItem}
1310 FiltersViewModel.prototype.getTagsInvertModel = function () {
1311 return this.getGroup( 'invertTagsGroup' ).getItemByParamName( 'inverttags' );
1315 * Set highlight color for a specific filter item
1317 * @param {string} filterName Name of the filter item
1318 * @param {string} color Selected color
1320 FiltersViewModel.prototype.setHighlightColor = function ( filterName, color ) {
1321 this.getItemByName( filterName ).setHighlightColor( color );
1325 * Clear highlight for a specific filter item
1327 * @param {string} filterName Name of the filter item
1329 FiltersViewModel.prototype.clearHighlightColor = function ( filterName ) {
1330 this.getItemByName( filterName ).clearHighlightColor();
1333 module.exports = FiltersViewModel;