3 * View model for a filter group
5 * @mixins OO.EventEmitter
6 * @mixins OO.EmitterList
9 * @param {string} name Group name
10 * @param {Object} [config] Configuration options
11 * @cfg {string} [type='send_unselected_if_any'] Group type
12 * @cfg {string} [view='default'] Name of the display group this group
14 * @cfg {string} [title] Group title
15 * @cfg {boolean} [hidden] This group is hidden from the regular menu views
16 * @cfg {boolean} [allowArbitrary] Allows for an arbitrary value to be added to the
17 * group from the URL, even if it wasn't initially set up.
18 * @cfg {string} [separator='|'] Value separator for 'string_options' groups
19 * @cfg {boolean} [active] Group is active
20 * @cfg {boolean} [fullCoverage] This filters in this group collectively cover all results
21 * @cfg {Object} [conflicts] Defines the conflicts for this filter group
22 * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
23 * group. If the prefix has 'invert' state, the parameter is expected to be an object
24 * with 'default' and 'inverted' as keys.
25 * @cfg {Object} [whatsThis] Defines the messages that should appear for the 'what's this' popup
26 * @cfg {string} [whatsThis.header] The header of the whatsThis popup message
27 * @cfg {string} [whatsThis.body] The body of the whatsThis popup message
28 * @cfg {string} [whatsThis.url] The url for the link in the whatsThis popup message
29 * @cfg {string} [whatsThis.linkMessage] The text for the link in the whatsThis popup message
31 mw
.rcfilters
.dm
.FilterGroup
= function MwRcfiltersDmFilterGroup( name
, config
) {
32 config
= config
|| {};
35 OO
.EventEmitter
.call( this );
36 OO
.EmitterList
.call( this );
39 this.type
= config
.type
|| 'send_unselected_if_any';
40 this.view
= config
.view
|| 'default';
41 this.title
= config
.title
|| name
;
42 this.hidden
= !!config
.hidden
;
43 this.allowArbitrary
= !!config
.allowArbitrary
;
44 this.separator
= config
.separator
|| '|';
45 this.labelPrefixKey
= config
.labelPrefixKey
;
47 this.currSelected
= null;
48 this.active
= !!config
.active
;
49 this.fullCoverage
= !!config
.fullCoverage
;
51 this.whatsThis
= config
.whatsThis
|| {};
53 this.conflicts
= config
.conflicts
|| {};
54 this.defaultParams
= {};
55 this.defaultFilters
= {};
57 this.aggregate( { update
: 'filterItemUpdate' } );
58 this.connect( this, { filterItemUpdate
: 'onFilterItemUpdate' } );
62 OO
.initClass( mw
.rcfilters
.dm
.FilterGroup
);
63 OO
.mixinClass( mw
.rcfilters
.dm
.FilterGroup
, OO
.EventEmitter
);
64 OO
.mixinClass( mw
.rcfilters
.dm
.FilterGroup
, OO
.EmitterList
);
71 * Group state has been updated
77 * Initialize the group and create its filter items
79 * @param {Object} filterDefinition Filter definition for this group
80 * @param {string|Object} [groupDefault] Definition of the group default
82 mw
.rcfilters
.dm
.FilterGroup
.prototype.initializeFilters = function ( filterDefinition
, groupDefault
) {
88 filterDefinition
.forEach( function ( filter
) {
89 // Instantiate an item
91 filterItem
= new mw
.rcfilters
.dm
.FilterItem( filter
.name
, model
, {
92 group
: model
.getName(),
93 useDefaultAsBaseValue
: !!filter
.useDefaultAsBaseValue
,
94 label
: filter
.label
|| filter
.name
,
95 description
: filter
.description
|| '',
96 labelPrefixKey
: model
.labelPrefixKey
,
97 cssClass
: filter
.cssClass
,
98 identifiers
: filter
.identifiers
101 if ( filter
.subset
) {
102 filter
.subset
= filter
.subset
.map( function ( el
) {
108 filter
.subset
.forEach( function ( subsetFilterName
) { // eslint-disable-line no-loop-func
109 // Subsets (unlike conflicts) are always inside the same group
110 // We can re-map the names of the filters we are getting from
111 // the subsets with the group prefix
112 var subsetName
= model
.getPrefixedName( subsetFilterName
);
113 // For convenience, we should store each filter's "supersets" -- these are
114 // the filters that have that item in their subset list. This will just
115 // make it easier to go through whether the item has any other items
116 // that affect it (and are selected) at any given time
117 supersetMap
[ subsetName
] = supersetMap
[ subsetName
] || [];
118 mw
.rcfilters
.utils
.addArrayElementsUnique(
119 supersetMap
[ subsetName
],
123 // Translate subset param name to add the group name, so we
124 // get consistent naming. We know that subsets are only within
126 subsetNames
.push( subsetName
);
129 // Set translated subset
130 filterItem
.setSubset( subsetNames
);
133 items
.push( filterItem
);
135 // Store default parameter state; in this case, default is defined per filter
137 model
.getType() === 'send_unselected_if_any' ||
138 model
.getType() === 'boolean'
140 // Store the default parameter state
141 // For this group type, parameter values are direct
142 // We need to convert from a boolean to a string ('1' and '0')
143 model
.defaultParams
[ filter
.name
] = String( Number( !!filter
.default ) );
148 this.addItems( items
);
150 // Now that we have all items, we can apply the superset map
151 this.getItems().forEach( function ( filterItem
) {
152 filterItem
.setSuperset( supersetMap
[ filterItem
.getName() ] );
155 // Store default parameter state; in this case, default is defined per the
156 // entire group, given by groupDefault method parameter
157 if ( this.getType() === 'string_options' ) {
158 // Store the default parameter group state
159 // For this group, the parameter is group name and value is the names
161 this.defaultParams
[ this.getName() ] = mw
.rcfilters
.utils
.normalizeParamOptions(
164 groupDefault
.split( this.getSeparator() ) :
167 this.getItems().map( function ( item
) {
168 return item
.getParamName();
170 ).join( this.getSeparator() );
171 } else if ( this.getType() === 'single_option' ) {
172 defaultParam
= groupDefault
!== undefined ?
173 groupDefault
: this.getItems()[ 0 ].getParamName();
175 // For this group, the parameter is the group name,
176 // and a single item can be selected: default or first item
177 this.defaultParams
[ this.getName() ] = defaultParam
;
179 // Single option means there must be a single option
180 // selected, so we have to either select the default
181 // or select the first option
182 this.selectItemByParamName( defaultParam
);
185 // Store default filter state based on default params
186 this.defaultFilters
= this.getFilterRepresentation( this.getDefaultParams() );
188 // Check for filters that should be initially selected by their default value
189 this.getItems().forEach( function ( item
) {
191 item
.isUsingDefaultAsBaseValue() &&
193 // This setting can only be applied to these groups
194 // the other groups are way too complex for that
195 model
.getType() === 'single_option' ||
196 model
.getType() === 'boolean'
200 item
.toggleSelected( !!model
.defaultFilters
[ item
.getName() ] );
206 * Respond to filterItem update event
208 * @param {mw.rcfilters.dm.FilterItem} item Updated filter item
211 mw
.rcfilters
.dm
.FilterGroup
.prototype.onFilterItemUpdate = function ( item
) {
214 active
= this.areAnySelected();
218 this.getType() === 'single_option' &&
220 this.currSelected
!== item
222 this.currSelected
.toggleSelected( false );
225 // For 'single_option' groups, check if we just unselected all
226 // items. This should never be the result. If we did unselect
227 // all (like resetting all filters to false) then this group
228 // must choose its default item or the first item in the group
230 this.getType() === 'single_option' &&
231 !this.getItems().some( function ( filterItem
) {
232 return filterItem
.isSelected();
235 // Single option means there must be a single option
236 // selected, so we have to either select the default
237 // or select the first option
238 this.currSelected
= this.getItemByParamName( this.defaultParams
[ this.getName() ] );
239 this.currSelected
.toggleSelected( true );
245 this.active
!== active
||
246 this.currSelected
!== item
248 this.active
= active
;
249 this.currSelected
= item
;
251 this.emit( 'update' );
256 * Get group active state
258 * @return {boolean} Active state
260 mw
.rcfilters
.dm
.FilterGroup
.prototype.isActive = function () {
265 * Get group hidden state
267 * @return {boolean} Hidden state
269 mw
.rcfilters
.dm
.FilterGroup
.prototype.isHidden = function () {
274 * Get group allow arbitrary state
276 * @return {boolean} Group allows an arbitrary value from the URL
278 mw
.rcfilters
.dm
.FilterGroup
.prototype.isAllowArbitrary = function () {
279 return this.allowArbitrary
;
285 * @return {string} Group name
287 mw
.rcfilters
.dm
.FilterGroup
.prototype.getName = function () {
292 * Get the default param state of this group
294 * @return {Object} Default param state
296 mw
.rcfilters
.dm
.FilterGroup
.prototype.getDefaultParams = function () {
297 return this.defaultParams
;
301 * Get the default filter state of this group
303 * @return {Object} Default filter state
305 mw
.rcfilters
.dm
.FilterGroup
.prototype.getDefaultFilters = function () {
306 return this.defaultFilters
;
310 * This is for a single_option and string_options group types
311 * it returns the value of the default
313 * @return {string} Value of the default
315 mw
.rcfilters
.dm
.FilterGroup
.prototype.getDefaulParamValue = function () {
316 return this.defaultParams
[ this.getName() ];
319 * Get the messags defining the 'whats this' popup for this group
321 * @return {Object} What's this messages
323 mw
.rcfilters
.dm
.FilterGroup
.prototype.getWhatsThis = function () {
324 return this.whatsThis
;
328 * Check whether this group has a 'what's this' message
330 * @return {boolean} This group has a what's this message
332 mw
.rcfilters
.dm
.FilterGroup
.prototype.hasWhatsThis = function () {
333 return !!this.whatsThis
.body
;
337 * Get the conflicts associated with the entire group.
338 * Conflict object is set up by filter name keys and conflict
339 * definition. For example:
343 * filter: filterName,
349 * filter: filterName2,
354 * @return {Object} Conflict definition
356 mw
.rcfilters
.dm
.FilterGroup
.prototype.getConflicts = function () {
357 return this.conflicts
;
361 * Set conflicts for this group. See #getConflicts for the expected
362 * structure of the definition.
364 * @param {Object} conflicts Conflicts for this group
366 mw
.rcfilters
.dm
.FilterGroup
.prototype.setConflicts = function ( conflicts
) {
367 this.conflicts
= conflicts
;
371 * Set conflicts for each filter item in the group based on the
374 * @param {Object} conflicts Object representing the conflict map,
375 * keyed by the item name, where its value is an object for all its conflicts
377 mw
.rcfilters
.dm
.FilterGroup
.prototype.setFilterConflicts = function ( conflicts
) {
378 this.getItems().forEach( function ( filterItem
) {
379 if ( conflicts
[ filterItem
.getName() ] ) {
380 filterItem
.setConflicts( conflicts
[ filterItem
.getName() ] );
386 * Check whether this item has a potential conflict with the given item
388 * This checks whether the given item is in the list of conflicts of
389 * the current item, but makes no judgment about whether the conflict
390 * is currently at play (either one of the items may not be selected)
392 * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
393 * @return {boolean} This item has a conflict with the given item
395 mw
.rcfilters
.dm
.FilterGroup
.prototype.existsInConflicts = function ( filterItem
) {
396 return Object
.prototype.hasOwnProperty
.call( this.getConflicts(), filterItem
.getName() );
400 * Check whether there are any items selected
402 * @return {boolean} Any items in the group are selected
404 mw
.rcfilters
.dm
.FilterGroup
.prototype.areAnySelected = function () {
405 return this.getItems().some( function ( filterItem
) {
406 return filterItem
.isSelected();
411 * Check whether all items selected
413 * @return {boolean} All items are selected
415 mw
.rcfilters
.dm
.FilterGroup
.prototype.areAllSelected = function () {
419 this.getItems().forEach( function ( filterItem
) {
420 if ( filterItem
.isSelected() ) {
421 selected
.push( filterItem
);
423 unselected
.push( filterItem
);
427 if ( unselected
.length
=== 0 ) {
431 // check if every unselected is a subset of a selected
432 return unselected
.every( function ( unselectedFilterItem
) {
433 return selected
.some( function ( selectedFilterItem
) {
434 return selectedFilterItem
.existsInSubset( unselectedFilterItem
.getName() );
440 * Get all selected items in this group
442 * @param {mw.rcfilters.dm.FilterItem} [excludeItem] Item to exclude from the list
443 * @return {mw.rcfilters.dm.FilterItem[]} Selected items
445 mw
.rcfilters
.dm
.FilterGroup
.prototype.getSelectedItems = function ( excludeItem
) {
446 var excludeName
= ( excludeItem
&& excludeItem
.getName() ) || '';
448 return this.getItems().filter( function ( item
) {
449 return item
.getName() !== excludeName
&& item
.isSelected();
454 * Check whether all selected items are in conflict with the given item
456 * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
457 * @return {boolean} All selected items are in conflict with this item
459 mw
.rcfilters
.dm
.FilterGroup
.prototype.areAllSelectedInConflictWith = function ( filterItem
) {
460 var selectedItems
= this.getSelectedItems( filterItem
);
462 return selectedItems
.length
> 0 &&
464 // The group as a whole is in conflict with this item
465 this.existsInConflicts( filterItem
) ||
466 // All selected items are in conflict individually
467 selectedItems
.every( function ( selectedFilter
) {
468 return selectedFilter
.existsInConflicts( filterItem
);
474 * Check whether any of the selected items are in conflict with the given item
476 * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
477 * @return {boolean} Any of the selected items are in conflict with this item
479 mw
.rcfilters
.dm
.FilterGroup
.prototype.areAnySelectedInConflictWith = function ( filterItem
) {
480 var selectedItems
= this.getSelectedItems( filterItem
);
482 return selectedItems
.length
> 0 && (
483 // The group as a whole is in conflict with this item
484 this.existsInConflicts( filterItem
) ||
485 // Any selected items are in conflict individually
486 selectedItems
.some( function ( selectedFilter
) {
487 return selectedFilter
.existsInConflicts( filterItem
);
493 * Get the parameter representation from this group
495 * @param {Object} [filterRepresentation] An object defining the state
496 * of the filters in this group, keyed by their name and current selected
498 * @return {Object} Parameter representation
500 mw
.rcfilters
.dm
.FilterGroup
.prototype.getParamRepresentation = function ( filterRepresentation
) {
502 areAnySelected
= false,
503 buildFromCurrentState
= !filterRepresentation
,
504 defaultFilters
= this.getDefaultFilters(),
507 filterParamNames
= {},
508 getSelectedParameter = function ( filters
) {
512 // Find if any are selected
513 $.each( filters
, function ( name
, value
) {
515 selected
.push( name
);
519 item
= model
.getItemByName( selected
[ 0 ] );
520 return ( item
&& item
.getParamName() ) || '';
523 filterRepresentation
= filterRepresentation
|| {};
525 // Create or complete the filterRepresentation definition
526 this.getItems().forEach( function ( item
) {
527 // Map filter names to their parameter names
528 filterParamNames
[ item
.getName() ] = item
.getParamName();
530 if ( buildFromCurrentState
) {
531 // This means we have not been given a filter representation
532 // so we are building one based on current state
533 filterRepresentation
[ item
.getName() ] = item
.isSelected();
534 } else if ( !filterRepresentation
[ item
.getName() ] ) {
535 // We are given a filter representation, but we have to make
536 // sure that we fill in the missing filters if there are any
537 // we will assume they are all falsey, unless they have
538 // isUsingDefaultAsBaseValue, in which case they get their
541 item
.isUsingDefaultAsBaseValue() &&
543 // This setting can only be applied to these groups
544 // the other groups are way too complex for that
545 model
.getType() === 'single_option' ||
546 model
.getType() === 'boolean'
549 filterRepresentation
[ item
.getName() ] = !!defaultFilters
[ item
.getName() ];
551 filterRepresentation
[ item
.getName() ] = false;
555 if ( filterRepresentation
[ item
.getName() ] ) {
556 areAnySelected
= true;
562 this.getType() === 'send_unselected_if_any' ||
563 this.getType() === 'boolean'
565 // First, check if any of the items are selected at all.
566 // If none is selected, we're treating it as if they are
569 // Go over the items and define the correct values
570 $.each( filterRepresentation
, function ( name
, value
) {
571 // We must store all parameter values as strings '0' or '1'
572 if ( model
.getType() === 'send_unselected_if_any' ) {
573 result
[ filterParamNames
[ name
] ] = areAnySelected
?
574 String( Number( !value
) ) :
576 } else if ( model
.getType() === 'boolean' ) {
577 // Representation is straight-forward and direct from
578 // the parameter value to the filter state
579 result
[ filterParamNames
[ name
] ] = String( Number( !!value
) );
582 } else if ( this.getType() === 'string_options' ) {
585 $.each( filterRepresentation
, function ( name
, value
) {
588 values
.push( filterParamNames
[ name
] );
592 result
[ this.getName() ] = ( values
.length
=== Object
.keys( filterRepresentation
).length
) ?
593 'all' : values
.join( this.getSeparator() );
594 } else if ( this.getType() === 'single_option' ) {
595 result
[ this.getName() ] = getSelectedParameter( filterRepresentation
);
602 * Get the filter representation this group would provide
603 * based on given parameter states.
605 * @param {Object} [paramRepresentation] An object defining a parameter
606 * state to translate the filter state from. If not given, an object
607 * representing all filters as falsey is returned; same as if the parameter
608 * given were an empty object, or had some of the filters missing.
609 * @return {Object} Filter representation
611 mw
.rcfilters
.dm
.FilterGroup
.prototype.getFilterRepresentation = function ( paramRepresentation
) {
612 var areAnySelected
, paramValues
, defaultValue
, item
, currentValue
,
613 oneWasSelected
= false,
614 defaultParams
= this.getDefaultParams(),
615 defaultFilters
= this.getDefaultFilters(),
616 expandedParams
= $.extend( true, {}, paramRepresentation
),
618 paramToFilterMap
= {},
621 paramRepresentation
= paramRepresentation
|| {};
623 this.getType() === 'send_unselected_if_any' ||
624 this.getType() === 'boolean'
626 // Go over param representation; map and check for selections
627 this.getItems().forEach( function ( filterItem
) {
628 var paramName
= filterItem
.getParamName();
630 expandedParams
[ paramName
] = paramRepresentation
[ paramName
] || '0';
631 paramToFilterMap
[ paramName
] = filterItem
;
633 if ( Number( paramRepresentation
[ filterItem
.getParamName() ] ) ) {
634 areAnySelected
= true;
638 $.each( expandedParams
, function ( paramName
, paramValue
) {
639 var value
= paramValue
,
640 filterItem
= paramToFilterMap
[ paramName
];
642 if ( model
.getType() === 'send_unselected_if_any' ) {
643 // Flip the definition between the parameter
644 // state and the filter state
645 // This is what the 'toggleSelected' value of the filter is
646 result
[ filterItem
.getName() ] = areAnySelected
?
647 !Number( paramValue
) :
648 // Otherwise, there are no selected items in the
649 // group, which means the state is false
651 } else if ( model
.getType() === 'boolean' ) {
652 // Straight-forward definition of state
654 filterItem
.isUsingDefaultAsBaseValue() &&
655 paramRepresentation
[ filterItem
.getParamName() ] === undefined
657 value
= defaultParams
[ filterItem
.getParamName() ];
659 result
[ filterItem
.getName() ] = !!Number( value
);
662 } else if ( this.getType() === 'string_options' ) {
663 currentValue
= paramRepresentation
[ this.getName() ] || '';
665 // Normalize the given parameter values
666 paramValues
= mw
.rcfilters
.utils
.normalizeParamOptions(
672 this.getItems().map( function ( filterItem
) {
673 return filterItem
.getParamName();
676 // Translate the parameter values into a filter selection state
677 this.getItems().forEach( function ( filterItem
) {
678 // All true (either because all values are written or the term 'all' is written)
679 // is the same as all filters set to true
680 result
[ filterItem
.getName() ] = (
681 // If it is the word 'all'
682 paramValues
.length
=== 1 && paramValues
[ 0 ] === 'all' ||
683 // All values are written
684 paramValues
.length
=== model
.getItemCount()
687 // Otherwise, the filter is selected only if it appears in the parameter values
688 paramValues
.indexOf( filterItem
.getParamName() ) > -1;
690 } else if ( this.getType() === 'single_option' ) {
691 // There is parameter that fits a single filter and if not, get the default
692 this.getItems().forEach( function ( filterItem
) {
693 var selected
= false;
696 filterItem
.isUsingDefaultAsBaseValue() &&
697 paramRepresentation
[ model
.getName() ] === undefined
699 selected
= !!Number( paramRepresentation
[ model
.getName() ] );
701 selected
= filterItem
.getParamName() === paramRepresentation
[ model
.getName() ];
703 result
[ filterItem
.getName() ] = selected
;
704 oneWasSelected
= oneWasSelected
|| selected
;
708 // Go over result and make sure all filters are represented.
709 // If any filters are missing, they will get a falsey value
710 this.getItems().forEach( function ( filterItem
) {
713 // This setting can only be applied to these groups
714 // the other groups are way too complex for that
715 model
.getType() === 'single_option' ||
716 model
.getType() === 'boolean'
718 result
[ filterItem
.getName() ] === undefined &&
719 filterItem
.isUsingDefaultAsBaseValue()
721 result
[ filterItem
.getName() ] = !!defaultFilters
[ filterItem
.getName() ];
723 oneWasSelected
= oneWasSelected
|| !!result
[ filterItem
.getName() ];
726 // Make sure that at least one option is selected in
727 // single_option groups, no matter what path was taken
728 // If none was selected by the given definition, then
729 // we need to select the one in the base state -- either
730 // the default given, or the first item
732 this.getType() === 'single_option' &&
735 defaultValue
= this.getDefaultParams();
736 item
= this.getItemByParamName( defaultValue
[ this.getName() ] );
737 result
[ item
.getName() ] = true;
744 * Get item by its filter name
746 * @param {string} filterName Filter name
747 * @return {mw.rcfilters.dm.FilterItem} Filter item
749 mw
.rcfilters
.dm
.FilterGroup
.prototype.getItemByName = function ( filterName
) {
750 return this.getItems().filter( function ( item
) {
751 return item
.getName() === filterName
;
756 * Select an item by its parameter name
758 * @param {string} paramName Filter parameter name
760 mw
.rcfilters
.dm
.FilterGroup
.prototype.selectItemByParamName = function ( paramName
) {
761 this.getItems().forEach( function ( item
) {
762 item
.toggleSelected( item
.getParamName() === paramName
);
767 * Get item by its parameter name
769 * @param {string} paramName Parameter name
770 * @return {mw.rcfilters.dm.FilterItem} Filter item
772 mw
.rcfilters
.dm
.FilterGroup
.prototype.getItemByParamName = function ( paramName
) {
773 return this.getItems().filter( function ( item
) {
774 return item
.getParamName() === paramName
;
781 * @return {string} Group type
783 mw
.rcfilters
.dm
.FilterGroup
.prototype.getType = function () {
790 * @return {string} Display group
792 mw
.rcfilters
.dm
.FilterGroup
.prototype.getView = function () {
797 * Get the prefix used for the filter names inside this group.
799 * @param {string} [name] Filter name to prefix
800 * @return {string} Group prefix
802 mw
.rcfilters
.dm
.FilterGroup
.prototype.getNamePrefix = function () {
803 return this.getName() + '__';
807 * Get a filter name with the prefix used for the filter names inside this group.
809 * @param {string} name Filter name to prefix
810 * @return {string} Group prefix
812 mw
.rcfilters
.dm
.FilterGroup
.prototype.getPrefixedName = function ( name
) {
813 return this.getNamePrefix() + name
;
819 * @return {string} Title
821 mw
.rcfilters
.dm
.FilterGroup
.prototype.getTitle = function () {
826 * Get group's values separator
828 * @return {string} Values separator
830 mw
.rcfilters
.dm
.FilterGroup
.prototype.getSeparator = function () {
831 return this.separator
;
835 * Check whether the group is defined as full coverage
837 * @return {boolean} Group is full coverage
839 mw
.rcfilters
.dm
.FilterGroup
.prototype.isFullCoverage = function () {
840 return this.fullCoverage
;