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 {boolean} [isSticky] This group is using a 'sticky' default; meaning
15 * that every time a value is changed, it becomes the new default
16 * @cfg {string} [title] Group title
17 * @cfg {boolean} [hidden] This group is hidden from the regular menu views
18 * @cfg {boolean} [allowArbitrary] Allows for an arbitrary value to be added to the
19 * group from the URL, even if it wasn't initially set up.
20 * @cfg {string} [separator='|'] Value separator for 'string_options' groups
21 * @cfg {boolean} [active] Group is active
22 * @cfg {boolean} [fullCoverage] This filters in this group collectively cover all results
23 * @cfg {Object} [conflicts] Defines the conflicts for this filter group
24 * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
25 * group. If the prefix has 'invert' state, the parameter is expected to be an object
26 * with 'default' and 'inverted' as keys.
27 * @cfg {Object} [whatsThis] Defines the messages that should appear for the 'what's this' popup
28 * @cfg {string} [whatsThis.header] The header of the whatsThis popup message
29 * @cfg {string} [whatsThis.body] The body of the whatsThis popup message
30 * @cfg {string} [whatsThis.url] The url for the link in the whatsThis popup message
31 * @cfg {string} [whatsThis.linkMessage] The text for the link in the whatsThis popup message
33 mw
.rcfilters
.dm
.FilterGroup
= function MwRcfiltersDmFilterGroup( name
, config
) {
34 config
= config
|| {};
37 OO
.EventEmitter
.call( this );
38 OO
.EmitterList
.call( this );
41 this.type
= config
.type
|| 'send_unselected_if_any';
42 this.view
= config
.view
|| 'default';
43 this.sticky
= !!config
.isSticky
;
44 this.title
= config
.title
|| name
;
45 this.hidden
= !!config
.hidden
;
46 this.allowArbitrary
= !!config
.allowArbitrary
;
47 this.separator
= config
.separator
|| '|';
48 this.labelPrefixKey
= config
.labelPrefixKey
;
50 this.currSelected
= null;
51 this.active
= !!config
.active
;
52 this.fullCoverage
= !!config
.fullCoverage
;
54 this.whatsThis
= config
.whatsThis
|| {};
56 this.conflicts
= config
.conflicts
|| {};
57 this.defaultParams
= {};
58 this.defaultFilters
= {};
60 this.aggregate( { update
: 'filterItemUpdate' } );
61 this.connect( this, { filterItemUpdate
: 'onFilterItemUpdate' } );
65 OO
.initClass( mw
.rcfilters
.dm
.FilterGroup
);
66 OO
.mixinClass( mw
.rcfilters
.dm
.FilterGroup
, OO
.EventEmitter
);
67 OO
.mixinClass( mw
.rcfilters
.dm
.FilterGroup
, OO
.EmitterList
);
74 * Group state has been updated
80 * Initialize the group and create its filter items
82 * @param {Object} filterDefinition Filter definition for this group
83 * @param {string|Object} [groupDefault] Definition of the group default
85 mw
.rcfilters
.dm
.FilterGroup
.prototype.initializeFilters = function ( filterDefinition
, groupDefault
) {
91 filterDefinition
.forEach( function ( filter
) {
92 // Instantiate an item
94 filterItem
= new mw
.rcfilters
.dm
.FilterItem( filter
.name
, model
, {
95 group
: model
.getName(),
96 label
: filter
.label
|| filter
.name
,
97 description
: filter
.description
|| '',
98 labelPrefixKey
: model
.labelPrefixKey
,
99 cssClass
: filter
.cssClass
,
100 identifiers
: filter
.identifiers
103 if ( filter
.subset
) {
104 filter
.subset
= filter
.subset
.map( function ( el
) {
110 filter
.subset
.forEach( function ( subsetFilterName
) { // eslint-disable-line no-loop-func
111 // Subsets (unlike conflicts) are always inside the same group
112 // We can re-map the names of the filters we are getting from
113 // the subsets with the group prefix
114 var subsetName
= model
.getPrefixedName( subsetFilterName
);
115 // For convenience, we should store each filter's "supersets" -- these are
116 // the filters that have that item in their subset list. This will just
117 // make it easier to go through whether the item has any other items
118 // that affect it (and are selected) at any given time
119 supersetMap
[ subsetName
] = supersetMap
[ subsetName
] || [];
120 mw
.rcfilters
.utils
.addArrayElementsUnique(
121 supersetMap
[ subsetName
],
125 // Translate subset param name to add the group name, so we
126 // get consistent naming. We know that subsets are only within
128 subsetNames
.push( subsetName
);
131 // Set translated subset
132 filterItem
.setSubset( subsetNames
);
135 items
.push( filterItem
);
137 // Store default parameter state; in this case, default is defined per filter
139 model
.getType() === 'send_unselected_if_any' ||
140 model
.getType() === 'boolean'
142 // Store the default parameter state
143 // For this group type, parameter values are direct
144 // We need to convert from a boolean to a string ('1' and '0')
145 model
.defaultParams
[ filter
.name
] = String( Number( !!filter
.default ) );
150 this.addItems( items
);
152 // Now that we have all items, we can apply the superset map
153 this.getItems().forEach( function ( filterItem
) {
154 filterItem
.setSuperset( supersetMap
[ filterItem
.getName() ] );
157 // Store default parameter state; in this case, default is defined per the
158 // entire group, given by groupDefault method parameter
159 if ( this.getType() === 'string_options' ) {
160 // Store the default parameter group state
161 // For this group, the parameter is group name and value is the names
163 this.defaultParams
[ this.getName() ] = mw
.rcfilters
.utils
.normalizeParamOptions(
166 groupDefault
.split( this.getSeparator() ) :
169 this.getItems().map( function ( item
) {
170 return item
.getParamName();
172 ).join( this.getSeparator() );
173 } else if ( this.getType() === 'single_option' ) {
174 defaultParam
= groupDefault
!== undefined ?
175 groupDefault
: this.getItems()[ 0 ].getParamName();
177 // For this group, the parameter is the group name,
178 // and a single item can be selected: default or first item
179 this.defaultParams
[ this.getName() ] = defaultParam
;
182 // Store default filter state based on default params
183 this.defaultFilters
= this.getFilterRepresentation( this.getDefaultParams() );
185 // Check for filters that should be initially selected by their default value
186 if ( this.isSticky() ) {
187 $.each( this.defaultFilters
, function ( filterName
, filterValue
) {
188 model
.getItemByName( filterName
).toggleSelected( filterValue
);
192 // Verify that single_option group has at least one item selected
194 this.getType() === 'single_option' &&
195 this.getSelectedItems().length
=== 0
197 defaultParam
= groupDefault
!== undefined ?
198 groupDefault
: this.getItems()[ 0 ].getParamName();
200 // Single option means there must be a single option
201 // selected, so we have to either select the default
202 // or select the first option
203 this.selectItemByParamName( defaultParam
);
208 * Respond to filterItem update event
210 * @param {mw.rcfilters.dm.FilterItem} item Updated filter item
213 mw
.rcfilters
.dm
.FilterGroup
.prototype.onFilterItemUpdate = function ( item
) {
216 active
= this.areAnySelected();
220 this.getType() === 'single_option' &&
222 this.currSelected
!== item
224 this.currSelected
.toggleSelected( false );
227 // For 'single_option' groups, check if we just unselected all
228 // items. This should never be the result. If we did unselect
229 // all (like resetting all filters to false) then this group
230 // must choose its default item or the first item in the group
232 this.getType() === 'single_option' &&
233 !this.getItems().some( function ( filterItem
) {
234 return filterItem
.isSelected();
237 // Single option means there must be a single option
238 // selected, so we have to either select the default
239 // or select the first option
240 this.currSelected
= this.getItemByParamName( this.defaultParams
[ this.getName() ] ) ||
241 this.getItems()[ 0 ];
242 this.currSelected
.toggleSelected( true );
248 this.active
!== active
||
249 this.currSelected
!== item
251 if ( this.isSticky() ) {
252 // If this group is sticky, then change the default according to the
253 // current selection.
254 this.defaultParams
= this.getParamRepresentation( this.getSelectedState() );
257 this.active
= active
;
258 this.currSelected
= item
;
260 this.emit( 'update' );
265 * Get group active state
267 * @return {boolean} Active state
269 mw
.rcfilters
.dm
.FilterGroup
.prototype.isActive = function () {
274 * Get group hidden state
276 * @return {boolean} Hidden state
278 mw
.rcfilters
.dm
.FilterGroup
.prototype.isHidden = function () {
283 * Get group allow arbitrary state
285 * @return {boolean} Group allows an arbitrary value from the URL
287 mw
.rcfilters
.dm
.FilterGroup
.prototype.isAllowArbitrary = function () {
288 return this.allowArbitrary
;
294 * @return {string} Group name
296 mw
.rcfilters
.dm
.FilterGroup
.prototype.getName = function () {
301 * Get the default param state of this group
303 * @return {Object} Default param state
305 mw
.rcfilters
.dm
.FilterGroup
.prototype.getDefaultParams = function () {
306 return this.defaultParams
;
310 * Get the default filter state of this group
312 * @return {Object} Default filter state
314 mw
.rcfilters
.dm
.FilterGroup
.prototype.getDefaultFilters = function () {
315 return this.defaultFilters
;
319 * This is for a single_option and string_options group types
320 * it returns the value of the default
322 * @return {string} Value of the default
324 mw
.rcfilters
.dm
.FilterGroup
.prototype.getDefaulParamValue = function () {
325 return this.defaultParams
[ this.getName() ];
328 * Get the messags defining the 'whats this' popup for this group
330 * @return {Object} What's this messages
332 mw
.rcfilters
.dm
.FilterGroup
.prototype.getWhatsThis = function () {
333 return this.whatsThis
;
337 * Check whether this group has a 'what's this' message
339 * @return {boolean} This group has a what's this message
341 mw
.rcfilters
.dm
.FilterGroup
.prototype.hasWhatsThis = function () {
342 return !!this.whatsThis
.body
;
346 * Get the conflicts associated with the entire group.
347 * Conflict object is set up by filter name keys and conflict
348 * definition. For example:
352 * filter: filterName,
358 * filter: filterName2,
363 * @return {Object} Conflict definition
365 mw
.rcfilters
.dm
.FilterGroup
.prototype.getConflicts = function () {
366 return this.conflicts
;
370 * Set conflicts for this group. See #getConflicts for the expected
371 * structure of the definition.
373 * @param {Object} conflicts Conflicts for this group
375 mw
.rcfilters
.dm
.FilterGroup
.prototype.setConflicts = function ( conflicts
) {
376 this.conflicts
= conflicts
;
380 * Set conflicts for each filter item in the group based on the
383 * @param {Object} conflicts Object representing the conflict map,
384 * keyed by the item name, where its value is an object for all its conflicts
386 mw
.rcfilters
.dm
.FilterGroup
.prototype.setFilterConflicts = function ( conflicts
) {
387 this.getItems().forEach( function ( filterItem
) {
388 if ( conflicts
[ filterItem
.getName() ] ) {
389 filterItem
.setConflicts( conflicts
[ filterItem
.getName() ] );
395 * Check whether this item has a potential conflict with the given item
397 * This checks whether the given item is in the list of conflicts of
398 * the current item, but makes no judgment about whether the conflict
399 * is currently at play (either one of the items may not be selected)
401 * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
402 * @return {boolean} This item has a conflict with the given item
404 mw
.rcfilters
.dm
.FilterGroup
.prototype.existsInConflicts = function ( filterItem
) {
405 return Object
.prototype.hasOwnProperty
.call( this.getConflicts(), filterItem
.getName() );
409 * Check whether there are any items selected
411 * @return {boolean} Any items in the group are selected
413 mw
.rcfilters
.dm
.FilterGroup
.prototype.areAnySelected = function () {
414 return this.getItems().some( function ( filterItem
) {
415 return filterItem
.isSelected();
420 * Check whether all items selected
422 * @return {boolean} All items are selected
424 mw
.rcfilters
.dm
.FilterGroup
.prototype.areAllSelected = function () {
428 this.getItems().forEach( function ( filterItem
) {
429 if ( filterItem
.isSelected() ) {
430 selected
.push( filterItem
);
432 unselected
.push( filterItem
);
436 if ( unselected
.length
=== 0 ) {
440 // check if every unselected is a subset of a selected
441 return unselected
.every( function ( unselectedFilterItem
) {
442 return selected
.some( function ( selectedFilterItem
) {
443 return selectedFilterItem
.existsInSubset( unselectedFilterItem
.getName() );
449 * Get all selected items in this group
451 * @param {mw.rcfilters.dm.FilterItem} [excludeItem] Item to exclude from the list
452 * @return {mw.rcfilters.dm.FilterItem[]} Selected items
454 mw
.rcfilters
.dm
.FilterGroup
.prototype.getSelectedItems = function ( excludeItem
) {
455 var excludeName
= ( excludeItem
&& excludeItem
.getName() ) || '';
457 return this.getItems().filter( function ( item
) {
458 return item
.getName() !== excludeName
&& item
.isSelected();
463 * Check whether all selected items are in conflict with the given item
465 * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
466 * @return {boolean} All selected items are in conflict with this item
468 mw
.rcfilters
.dm
.FilterGroup
.prototype.areAllSelectedInConflictWith = function ( filterItem
) {
469 var selectedItems
= this.getSelectedItems( filterItem
);
471 return selectedItems
.length
> 0 &&
473 // The group as a whole is in conflict with this item
474 this.existsInConflicts( filterItem
) ||
475 // All selected items are in conflict individually
476 selectedItems
.every( function ( selectedFilter
) {
477 return selectedFilter
.existsInConflicts( filterItem
);
483 * Check whether any of the selected items are in conflict with the given item
485 * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
486 * @return {boolean} Any of the selected items are in conflict with this item
488 mw
.rcfilters
.dm
.FilterGroup
.prototype.areAnySelectedInConflictWith = function ( filterItem
) {
489 var selectedItems
= this.getSelectedItems( filterItem
);
491 return selectedItems
.length
> 0 && (
492 // The group as a whole is in conflict with this item
493 this.existsInConflicts( filterItem
) ||
494 // Any selected items are in conflict individually
495 selectedItems
.some( function ( selectedFilter
) {
496 return selectedFilter
.existsInConflicts( filterItem
);
502 * Get the parameter representation from this group
504 * @param {Object} [filterRepresentation] An object defining the state
505 * of the filters in this group, keyed by their name and current selected
507 * @return {Object} Parameter representation
509 mw
.rcfilters
.dm
.FilterGroup
.prototype.getParamRepresentation = function ( filterRepresentation
) {
511 areAnySelected
= false,
512 buildFromCurrentState
= !filterRepresentation
,
513 defaultFilters
= this.getDefaultFilters(),
516 filterParamNames
= {},
517 getSelectedParameter = function ( filters
) {
521 // Find if any are selected
522 $.each( filters
, function ( name
, value
) {
524 selected
.push( name
);
528 item
= model
.getItemByName( selected
[ 0 ] );
529 return ( item
&& item
.getParamName() ) || '';
532 filterRepresentation
= filterRepresentation
|| {};
534 // Create or complete the filterRepresentation definition
535 this.getItems().forEach( function ( item
) {
536 // Map filter names to their parameter names
537 filterParamNames
[ item
.getName() ] = item
.getParamName();
539 if ( buildFromCurrentState
) {
540 // This means we have not been given a filter representation
541 // so we are building one based on current state
542 filterRepresentation
[ item
.getName() ] = item
.isSelected();
543 } else if ( filterRepresentation
[ item
.getName() ] === undefined ) {
544 // We are given a filter representation, but we have to make
545 // sure that we fill in the missing filters if there are any
546 // we will assume they are all falsey
547 if ( model
.isSticky() ) {
548 filterRepresentation
[ item
.getName() ] = !!defaultFilters
[ item
.getName() ];
550 filterRepresentation
[ item
.getName() ] = false;
554 if ( filterRepresentation
[ item
.getName() ] ) {
555 areAnySelected
= true;
561 this.getType() === 'send_unselected_if_any' ||
562 this.getType() === 'boolean'
564 // First, check if any of the items are selected at all.
565 // If none is selected, we're treating it as if they are
568 // Go over the items and define the correct values
569 $.each( filterRepresentation
, function ( name
, value
) {
570 // We must store all parameter values as strings '0' or '1'
571 if ( model
.getType() === 'send_unselected_if_any' ) {
572 result
[ filterParamNames
[ name
] ] = areAnySelected
?
573 String( Number( !value
) ) :
575 } else if ( model
.getType() === 'boolean' ) {
576 // Representation is straight-forward and direct from
577 // the parameter value to the filter state
578 result
[ filterParamNames
[ name
] ] = String( Number( !!value
) );
581 } else if ( this.getType() === 'string_options' ) {
584 $.each( filterRepresentation
, function ( name
, value
) {
587 values
.push( filterParamNames
[ name
] );
591 result
[ this.getName() ] = ( values
.length
=== Object
.keys( filterRepresentation
).length
) ?
592 'all' : values
.join( this.getSeparator() );
593 } else if ( this.getType() === 'single_option' ) {
594 result
[ this.getName() ] = getSelectedParameter( filterRepresentation
);
601 * Get the filter representation this group would provide
602 * based on given parameter states.
604 * @param {Object} [paramRepresentation] An object defining a parameter
605 * state to translate the filter state from. If not given, an object
606 * representing all filters as falsey is returned; same as if the parameter
607 * given were an empty object, or had some of the filters missing.
608 * @return {Object} Filter representation
610 mw
.rcfilters
.dm
.FilterGroup
.prototype.getFilterRepresentation = function ( paramRepresentation
) {
611 var areAnySelected
, paramValues
, item
, currentValue
,
612 oneWasSelected
= false,
613 defaultParams
= this.getDefaultParams(),
614 expandedParams
= $.extend( true, {}, paramRepresentation
),
616 paramToFilterMap
= {},
619 if ( this.isSticky() ) {
620 // If the group is sticky, check if all parameters are represented
621 // and for those that aren't represented, add them with their default
623 paramRepresentation
= $.extend( true, {}, this.getDefaultParams(), paramRepresentation
);
626 paramRepresentation
= paramRepresentation
|| {};
628 this.getType() === 'send_unselected_if_any' ||
629 this.getType() === 'boolean'
631 // Go over param representation; map and check for selections
632 this.getItems().forEach( function ( filterItem
) {
633 var paramName
= filterItem
.getParamName();
635 expandedParams
[ paramName
] = paramRepresentation
[ paramName
] || '0';
636 paramToFilterMap
[ paramName
] = filterItem
;
638 if ( Number( paramRepresentation
[ filterItem
.getParamName() ] ) ) {
639 areAnySelected
= true;
643 $.each( expandedParams
, function ( paramName
, paramValue
) {
644 var filterItem
= paramToFilterMap
[ paramName
];
646 if ( model
.getType() === 'send_unselected_if_any' ) {
647 // Flip the definition between the parameter
648 // state and the filter state
649 // This is what the 'toggleSelected' value of the filter is
650 result
[ filterItem
.getName() ] = areAnySelected
?
651 !Number( paramValue
) :
652 // Otherwise, there are no selected items in the
653 // group, which means the state is false
655 } else if ( model
.getType() === 'boolean' ) {
656 // Straight-forward definition of state
657 result
[ filterItem
.getName() ] = !!Number( paramRepresentation
[ filterItem
.getParamName() ] );
660 } else if ( this.getType() === 'string_options' ) {
661 currentValue
= paramRepresentation
[ this.getName() ] || '';
663 // Normalize the given parameter values
664 paramValues
= mw
.rcfilters
.utils
.normalizeParamOptions(
670 this.getItems().map( function ( filterItem
) {
671 return filterItem
.getParamName();
674 // Translate the parameter values into a filter selection state
675 this.getItems().forEach( function ( filterItem
) {
676 // All true (either because all values are written or the term 'all' is written)
677 // is the same as all filters set to true
678 result
[ filterItem
.getName() ] = (
679 // If it is the word 'all'
680 paramValues
.length
=== 1 && paramValues
[ 0 ] === 'all' ||
681 // All values are written
682 paramValues
.length
=== model
.getItemCount()
685 // Otherwise, the filter is selected only if it appears in the parameter values
686 paramValues
.indexOf( filterItem
.getParamName() ) > -1;
688 } else if ( this.getType() === 'single_option' ) {
689 // There is parameter that fits a single filter and if not, get the default
690 this.getItems().forEach( function ( filterItem
) {
691 var selected
= filterItem
.getParamName() === paramRepresentation
[ model
.getName() ];
693 result
[ filterItem
.getName() ] = selected
;
694 oneWasSelected
= oneWasSelected
|| selected
;
698 // Go over result and make sure all filters are represented.
699 // If any filters are missing, they will get a falsey value
700 this.getItems().forEach( function ( filterItem
) {
701 if ( result
[ filterItem
.getName() ] === undefined ) {
702 result
[ filterItem
.getName() ] = false;
706 // Make sure that at least one option is selected in
707 // single_option groups, no matter what path was taken
708 // If none was selected by the given definition, then
709 // we need to select the one in the base state -- either
710 // the default given, or the first item
712 this.getType() === 'single_option' &&
715 if ( defaultParams
[ this.getName() ] ) {
716 item
= this.getItemByParamName( defaultParams
[ this.getName() ] );
718 item
= this.getItems()[ 0 ];
720 result
[ item
.getName() ] = true;
727 * Get current selected state of all filter items in this group
729 * @return {Object} Selected state
731 mw
.rcfilters
.dm
.FilterGroup
.prototype.getSelectedState = function () {
734 this.getItems().forEach( function ( filterItem
) {
735 state
[ filterItem
.getName() ] = filterItem
.isSelected();
742 * Get item by its filter name
744 * @param {string} filterName Filter name
745 * @return {mw.rcfilters.dm.FilterItem} Filter item
747 mw
.rcfilters
.dm
.FilterGroup
.prototype.getItemByName = function ( filterName
) {
748 return this.getItems().filter( function ( item
) {
749 return item
.getName() === filterName
;
754 * Select an item by its parameter name
756 * @param {string} paramName Filter parameter name
758 mw
.rcfilters
.dm
.FilterGroup
.prototype.selectItemByParamName = function ( paramName
) {
759 this.getItems().forEach( function ( item
) {
760 item
.toggleSelected( item
.getParamName() === String( paramName
) );
765 * Get item by its parameter name
767 * @param {string} paramName Parameter name
768 * @return {mw.rcfilters.dm.FilterItem} Filter item
770 mw
.rcfilters
.dm
.FilterGroup
.prototype.getItemByParamName = function ( paramName
) {
771 return this.getItems().filter( function ( item
) {
772 return item
.getParamName() === String( paramName
);
779 * @return {string} Group type
781 mw
.rcfilters
.dm
.FilterGroup
.prototype.getType = function () {
788 * @return {string} Display group
790 mw
.rcfilters
.dm
.FilterGroup
.prototype.getView = function () {
795 * Get the prefix used for the filter names inside this group.
797 * @param {string} [name] Filter name to prefix
798 * @return {string} Group prefix
800 mw
.rcfilters
.dm
.FilterGroup
.prototype.getNamePrefix = function () {
801 return this.getName() + '__';
805 * Get a filter name with the prefix used for the filter names inside this group.
807 * @param {string} name Filter name to prefix
808 * @return {string} Group prefix
810 mw
.rcfilters
.dm
.FilterGroup
.prototype.getPrefixedName = function ( name
) {
811 return this.getNamePrefix() + name
;
817 * @return {string} Title
819 mw
.rcfilters
.dm
.FilterGroup
.prototype.getTitle = function () {
824 * Get group's values separator
826 * @return {string} Values separator
828 mw
.rcfilters
.dm
.FilterGroup
.prototype.getSeparator = function () {
829 return this.separator
;
833 * Check whether the group is defined as full coverage
835 * @return {boolean} Group is full coverage
837 mw
.rcfilters
.dm
.FilterGroup
.prototype.isFullCoverage = function () {
838 return this.fullCoverage
;
842 * Check whether the group is defined as sticky default
844 * @return {boolean} Group is sticky default
846 mw
.rcfilters
.dm
.FilterGroup
.prototype.isSticky = function () {