Merge "debug: Add basic accessibility support to debug console"
[lhc/web/wiklou.git] / resources / src / mediawiki.rcfilters / dm / FiltersViewModel.js
1 ( function () {
2 var FilterGroup = require( './FilterGroup.js' ),
3 FilterItem = require( './FilterItem.js' ),
4 FiltersViewModel;
5
6 /**
7 * View model for the filters selection and display
8 *
9 * @class mw.rcfilters.dm.FiltersViewModel
10 * @mixins OO.EventEmitter
11 * @mixins OO.EmitterList
12 *
13 * @constructor
14 */
15 FiltersViewModel = function MwRcfiltersDmFiltersViewModel() {
16 // Mixin constructor
17 OO.EventEmitter.call( this );
18 OO.EmitterList.call( this );
19
20 this.groups = {};
21 this.defaultParams = {};
22 this.highlightEnabled = false;
23 this.parameterMap = {};
24 this.emptyParameterState = null;
25
26 this.views = {};
27 this.currentView = 'default';
28 this.searchQuery = null;
29
30 // Events
31 this.aggregate( { update: 'filterItemUpdate' } );
32 this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
33 };
34
35 /* Initialization */
36 OO.initClass( FiltersViewModel );
37 OO.mixinClass( FiltersViewModel, OO.EventEmitter );
38 OO.mixinClass( FiltersViewModel, OO.EmitterList );
39
40 /* Events */
41
42 /**
43 * @event initialize
44 *
45 * Filter list is initialized
46 */
47
48 /**
49 * @event update
50 *
51 * Model has been updated
52 */
53
54 /**
55 * @event itemUpdate
56 * @param {mw.rcfilters.dm.FilterItem} item Filter item updated
57 *
58 * Filter item has changed
59 */
60
61 /**
62 * @event highlightChange
63 * @param {boolean} Highlight feature is enabled
64 *
65 * Highlight feature has been toggled enabled or disabled
66 */
67
68 /* Methods */
69
70 /**
71 * Re-assess the states of filter items based on the interactions between them
72 *
73 * @param {mw.rcfilters.dm.FilterItem} [item] Changed item. If not given, the
74 * method will go over the state of all items
75 */
76 FiltersViewModel.prototype.reassessFilterInteractions = function ( item ) {
77 var allSelected,
78 model = this,
79 iterationItems = item !== undefined ? [ item ] : this.getItems();
80
81 iterationItems.forEach( function ( checkedItem ) {
82 var allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ),
83 groupModel = checkedItem.getGroupModel();
84
85 // Check for subsets (included filters) plus the item itself:
86 allCheckedItems.forEach( function ( filterItemName ) {
87 var itemInSubset = model.getItemByName( filterItemName );
88
89 itemInSubset.toggleIncluded(
90 // If any of itemInSubset's supersets are selected, this item
91 // is included
92 itemInSubset.getSuperset().some( function ( supersetName ) {
93 return ( model.getItemByName( supersetName ).isSelected() );
94 } )
95 );
96 } );
97
98 // Update coverage for the changed group
99 if ( groupModel.isFullCoverage() ) {
100 allSelected = groupModel.areAllSelected();
101 groupModel.getItems().forEach( function ( filterItem ) {
102 filterItem.toggleFullyCovered( allSelected );
103 } );
104 }
105 } );
106
107 // Check for conflicts
108 // In this case, we must go over all items, since
109 // conflicts are bidirectional and depend not only on
110 // individual items, but also on the selected states of
111 // the groups they're in.
112 this.getItems().forEach( function ( filterItem ) {
113 var inConflict = false,
114 filterItemGroup = filterItem.getGroupModel();
115
116 // For each item, see if that item is still conflicting
117 // eslint-disable-next-line no-jquery/no-each-util
118 $.each( model.groups, function ( groupName, groupModel ) {
119 if ( filterItem.getGroupName() === groupName ) {
120 // Check inside the group
121 inConflict = groupModel.areAnySelectedInConflictWith( filterItem );
122 } else {
123 // According to the spec, if two items conflict from two different
124 // groups, the conflict only lasts if the groups **only have selected
125 // items that are conflicting**. If a group has selected items that
126 // are conflicting and non-conflicting, the scope of the result has
127 // expanded enough to completely remove the conflict.
128
129 // For example, see two groups with conflicts:
130 // userExpLevel: [
131 // {
132 // name: 'experienced',
133 // conflicts: [ 'unregistered' ]
134 // }
135 // ],
136 // registration: [
137 // {
138 // name: 'registered',
139 // },
140 // {
141 // name: 'unregistered',
142 // }
143 // ]
144 // If we select 'experienced', then 'unregistered' is in conflict (and vice versa),
145 // because, inherently, 'experienced' filter only includes registered users, and so
146 // both filters are in conflict with one another.
147 // However, the minute we select 'registered', the scope of our results
148 // has expanded to no longer have a conflict with 'experienced' filter, and
149 // so the conflict is removed.
150
151 // In our case, we need to check if the entire group conflicts with
152 // the entire item's group, so we follow the above spec
153 inConflict = (
154 // The foreign group is in conflict with this item
155 groupModel.areAllSelectedInConflictWith( filterItem ) &&
156 // Every selected member of the item's own group is also
157 // in conflict with the other group
158 filterItemGroup.findSelectedItems().every( function ( otherGroupItem ) {
159 return groupModel.areAllSelectedInConflictWith( otherGroupItem );
160 } )
161 );
162 }
163
164 // If we're in conflict, this will return 'false' which
165 // will break the loop. Otherwise, we're not in conflict
166 // and the loop continues
167 return !inConflict;
168 } );
169
170 // Toggle the item state
171 filterItem.toggleConflicted( inConflict );
172 } );
173 };
174
175 /**
176 * Get whether the model has any conflict in its items
177 *
178 * @return {boolean} There is a conflict
179 */
180 FiltersViewModel.prototype.hasConflict = function () {
181 return this.getItems().some( function ( filterItem ) {
182 return filterItem.isSelected() && filterItem.isConflicted();
183 } );
184 };
185
186 /**
187 * Get the first item with a current conflict
188 *
189 * @return {mw.rcfilters.dm.FilterItem|undefined} Conflicted item or undefined when not found
190 */
191 FiltersViewModel.prototype.getFirstConflictedItem = function () {
192 var i, filterItem, items = this.getItems();
193 for ( i = 0; i < items.length; i++ ) {
194 filterItem = items[ i ];
195 if ( filterItem.isSelected() && filterItem.isConflicted() ) {
196 return filterItem;
197 }
198 }
199 };
200
201 /**
202 * Set filters and preserve a group relationship based on
203 * the definition given by an object
204 *
205 * @param {Array} filterGroups Filters definition
206 * @param {Object} [views] Extra views definition
207 * Expected in the following format:
208 * {
209 * namespaces: {
210 * label: 'namespaces', // Message key
211 * trigger: ':',
212 * groups: [
213 * {
214 * // Group info
215 * name: 'namespaces' // Parameter name
216 * title: 'namespaces' // Message key
217 * type: 'string_options',
218 * separator: ';',
219 * labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
220 * fullCoverage: true
221 * items: []
222 * }
223 * ]
224 * }
225 * }
226 */
227 FiltersViewModel.prototype.initializeFilters = function ( filterGroups, views ) {
228 var filterConflictResult, groupConflictResult,
229 allViews = {},
230 model = this,
231 items = [],
232 groupConflictMap = {},
233 filterConflictMap = {},
234 /*!
235 * Expand a conflict definition from group name to
236 * the list of all included filters in that group.
237 * We do this so that the direct relationship in the
238 * models are consistently item->items rather than
239 * mixing item->group with item->item.
240 *
241 * @param {Object} obj Conflict definition
242 * @return {Object} Expanded conflict definition
243 */
244 expandConflictDefinitions = function ( obj ) {
245 var result = {};
246
247 // eslint-disable-next-line no-jquery/no-each-util
248 $.each( obj, function ( key, conflicts ) {
249 var filterName,
250 adjustedConflicts = {};
251
252 conflicts.forEach( function ( conflict ) {
253 var filter;
254
255 if ( conflict.filter ) {
256 filterName = model.groups[ conflict.group ].getPrefixedName( conflict.filter );
257 filter = model.getItemByName( filterName );
258
259 // Rename
260 adjustedConflicts[ filterName ] = $.extend(
261 {},
262 conflict,
263 {
264 filter: filterName,
265 item: filter
266 }
267 );
268 } else {
269 // This conflict is for an entire group. Split it up to
270 // represent each filter
271
272 // Get the relevant group items
273 model.groups[ conflict.group ].getItems().forEach( function ( groupItem ) {
274 // Rebuild the conflict
275 adjustedConflicts[ groupItem.getName() ] = $.extend(
276 {},
277 conflict,
278 {
279 filter: groupItem.getName(),
280 item: groupItem
281 }
282 );
283 } );
284 }
285 } );
286
287 result[ key ] = adjustedConflicts;
288 } );
289
290 return result;
291 };
292
293 // Reset
294 this.clearItems();
295 this.groups = {};
296 this.views = {};
297
298 // Clone
299 filterGroups = OO.copy( filterGroups );
300
301 // Normalize definition from the server
302 filterGroups.forEach( function ( data ) {
303 var i;
304 // What's this information needs to be normalized
305 data.whatsThis = {
306 body: data.whatsThisBody,
307 header: data.whatsThisHeader,
308 linkText: data.whatsThisLinkText,
309 url: data.whatsThisUrl
310 };
311
312 // Title is a msg-key
313 data.title = data.title ? mw.msg( data.title ) : data.name;
314
315 // Filters are given to us with msg-keys, we need
316 // to translate those before we hand them off
317 for ( i = 0; i < data.filters.length; i++ ) {
318 data.filters[ i ].label = data.filters[ i ].label ? mw.msg( data.filters[ i ].label ) : data.filters[ i ].name;
319 data.filters[ i ].description = data.filters[ i ].description ? mw.msg( data.filters[ i ].description ) : '';
320 }
321 } );
322
323 // Collect views
324 allViews = $.extend( true, {
325 default: {
326 title: mw.msg( 'rcfilters-filterlist-title' ),
327 groups: filterGroups
328 }
329 }, views );
330
331 // Go over all views
332 // eslint-disable-next-line no-jquery/no-each-util
333 $.each( allViews, function ( viewName, viewData ) {
334 // Define the view
335 model.views[ viewName ] = {
336 name: viewData.name,
337 title: viewData.title,
338 trigger: viewData.trigger
339 };
340
341 // Go over groups
342 viewData.groups.forEach( function ( groupData ) {
343 var group = groupData.name;
344
345 if ( !model.groups[ group ] ) {
346 model.groups[ group ] = new FilterGroup(
347 group,
348 $.extend( true, {}, groupData, { view: viewName } )
349 );
350 }
351
352 model.groups[ group ].initializeFilters( groupData.filters, groupData.default );
353 items = items.concat( model.groups[ group ].getItems() );
354
355 // Prepare conflicts
356 if ( groupData.conflicts ) {
357 // Group conflicts
358 groupConflictMap[ group ] = groupData.conflicts;
359 }
360
361 groupData.filters.forEach( function ( itemData ) {
362 var filterItem = model.groups[ group ].getItemByParamName( itemData.name );
363 // Filter conflicts
364 if ( itemData.conflicts ) {
365 filterConflictMap[ filterItem.getName() ] = itemData.conflicts;
366 }
367 } );
368 } );
369 } );
370
371 // Add item references to the model, for lookup
372 this.addItems( items );
373
374 // Expand conflicts
375 groupConflictResult = expandConflictDefinitions( groupConflictMap );
376 filterConflictResult = expandConflictDefinitions( filterConflictMap );
377
378 // Set conflicts for groups
379 // eslint-disable-next-line no-jquery/no-each-util
380 $.each( groupConflictResult, function ( group, conflicts ) {
381 model.groups[ group ].setConflicts( conflicts );
382 } );
383
384 // Set conflicts for items
385 // eslint-disable-next-line no-jquery/no-each-util
386 $.each( filterConflictResult, function ( filterName, conflicts ) {
387 var filterItem = model.getItemByName( filterName );
388 // set conflicts for items in the group
389 filterItem.setConflicts( conflicts );
390 } );
391
392 // Create a map between known parameters and their models
393 // eslint-disable-next-line no-jquery/no-each-util
394 $.each( this.groups, function ( group, groupModel ) {
395 if (
396 groupModel.getType() === 'send_unselected_if_any' ||
397 groupModel.getType() === 'boolean' ||
398 groupModel.getType() === 'any_value'
399 ) {
400 // Individual filters
401 groupModel.getItems().forEach( function ( filterItem ) {
402 model.parameterMap[ filterItem.getParamName() ] = filterItem;
403 } );
404 } else if (
405 groupModel.getType() === 'string_options' ||
406 groupModel.getType() === 'single_option'
407 ) {
408 // Group
409 model.parameterMap[ groupModel.getName() ] = groupModel;
410 }
411 } );
412
413 this.setSearch( '' );
414
415 this.updateHighlightedState();
416
417 // Finish initialization
418 this.emit( 'initialize' );
419 };
420
421 /**
422 * Update filter view model state based on a parameter object
423 *
424 * @param {Object} params Parameters object
425 */
426 FiltersViewModel.prototype.updateStateFromParams = function ( params ) {
427 var filtersValue;
428 // For arbitrary numeric single_option values make sure the values
429 // are normalized to fit within the limits
430 // eslint-disable-next-line no-jquery/no-each-util
431 $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
432 params[ groupName ] = groupModel.normalizeArbitraryValue( params[ groupName ] );
433 } );
434
435 // Update filter values
436 filtersValue = this.getFiltersFromParameters( params );
437 Object.keys( filtersValue ).forEach( function ( filterName ) {
438 this.getItemByName( filterName ).setValue( filtersValue[ filterName ] );
439 }.bind( this ) );
440
441 // Update highlight state
442 this.getItemsSupportingHighlights().forEach( function ( filterItem ) {
443 var color = params[ filterItem.getName() + '_color' ];
444 if ( color ) {
445 filterItem.setHighlightColor( color );
446 } else {
447 filterItem.clearHighlightColor();
448 }
449 } );
450 this.updateHighlightedState();
451
452 // Check all filter interactions
453 this.reassessFilterInteractions();
454 };
455
456 /**
457 * Get a representation of an empty (falsey) parameter state
458 *
459 * @return {Object} Empty parameter state
460 */
461 FiltersViewModel.prototype.getEmptyParameterState = function () {
462 if ( !this.emptyParameterState ) {
463 this.emptyParameterState = $.extend(
464 true,
465 {},
466 this.getParametersFromFilters( {} ),
467 this.getEmptyHighlightParameters()
468 );
469 }
470 return this.emptyParameterState;
471 };
472
473 /**
474 * Get a representation of only the non-falsey parameters
475 *
476 * @param {Object} [parameters] A given parameter state to minimize. If not given the current
477 * state of the system will be used.
478 * @return {Object} Empty parameter state
479 */
480 FiltersViewModel.prototype.getMinimizedParamRepresentation = function ( parameters ) {
481 var result = {};
482
483 parameters = parameters ? $.extend( true, {}, parameters ) : this.getCurrentParameterState();
484
485 // Params
486 // eslint-disable-next-line no-jquery/no-each-util
487 $.each( this.getEmptyParameterState(), function ( param, value ) {
488 if ( parameters[ param ] !== undefined && parameters[ param ] !== value ) {
489 result[ param ] = parameters[ param ];
490 }
491 } );
492
493 // Highlights
494 Object.keys( this.getEmptyHighlightParameters() ).forEach( function ( param ) {
495 if ( parameters[ param ] ) {
496 // If a highlight parameter is not undefined and not null
497 // add it to the result
498 result[ param ] = parameters[ param ];
499 }
500 } );
501
502 return result;
503 };
504
505 /**
506 * Get a representation of the full parameter list, including all base values
507 *
508 * @return {Object} Full parameter representation
509 */
510 FiltersViewModel.prototype.getExpandedParamRepresentation = function () {
511 return $.extend(
512 true,
513 {},
514 this.getEmptyParameterState(),
515 this.getCurrentParameterState()
516 );
517 };
518
519 /**
520 * Get a parameter representation of the current state of the model
521 *
522 * @param {boolean} [removeStickyParams] Remove sticky filters from final result
523 * @return {Object} Parameter representation of the current state of the model
524 */
525 FiltersViewModel.prototype.getCurrentParameterState = function ( removeStickyParams ) {
526 var state = this.getMinimizedParamRepresentation( $.extend(
527 true,
528 {},
529 this.getParametersFromFilters( this.getSelectedState() ),
530 this.getHighlightParameters()
531 ) );
532
533 if ( removeStickyParams ) {
534 state = this.removeStickyParams( state );
535 }
536
537 return state;
538 };
539
540 /**
541 * Delete sticky parameters from given object.
542 *
543 * @param {Object} paramState Parameter state
544 * @return {Object} Parameter state without sticky parameters
545 */
546 FiltersViewModel.prototype.removeStickyParams = function ( paramState ) {
547 this.getStickyParams().forEach( function ( paramName ) {
548 delete paramState[ paramName ];
549 } );
550
551 return paramState;
552 };
553
554 /**
555 * Turn the highlight feature on or off
556 */
557 FiltersViewModel.prototype.updateHighlightedState = function () {
558 this.toggleHighlight( this.getHighlightedItems().length > 0 );
559 };
560
561 /**
562 * Get the object that defines groups by their name.
563 *
564 * @return {Object} Filter groups
565 */
566 FiltersViewModel.prototype.getFilterGroups = function () {
567 return this.groups;
568 };
569
570 /**
571 * Get the object that defines groups that match a certain view by their name.
572 *
573 * @param {string} [view] Requested view. If not given, uses current view
574 * @return {Object} Filter groups matching a display group
575 */
576 FiltersViewModel.prototype.getFilterGroupsByView = function ( view ) {
577 var result = {};
578
579 view = view || this.getCurrentView();
580
581 // eslint-disable-next-line no-jquery/no-each-util
582 $.each( this.groups, function ( groupName, groupModel ) {
583 if ( groupModel.getView() === view ) {
584 result[ groupName ] = groupModel;
585 }
586 } );
587
588 return result;
589 };
590
591 /**
592 * Get an array of filters matching the given display group.
593 *
594 * @param {string} [view] Requested view. If not given, uses current view
595 * @return {mw.rcfilters.dm.FilterItem} Filter items matching the group
596 */
597 FiltersViewModel.prototype.getFiltersByView = function ( view ) {
598 var groups,
599 result = [];
600
601 view = view || this.getCurrentView();
602
603 groups = this.getFilterGroupsByView( view );
604
605 // eslint-disable-next-line no-jquery/no-each-util
606 $.each( groups, function ( groupName, groupModel ) {
607 result = result.concat( groupModel.getItems() );
608 } );
609
610 return result;
611 };
612
613 /**
614 * Get the trigger for the requested view.
615 *
616 * @param {string} view View name
617 * @return {string} View trigger, if exists
618 */
619 FiltersViewModel.prototype.getViewTrigger = function ( view ) {
620 return ( this.views[ view ] && this.views[ view ].trigger ) || '';
621 };
622
623 /**
624 * Get the value of a specific parameter
625 *
626 * @param {string} name Parameter name
627 * @return {number|string} Parameter value
628 */
629 FiltersViewModel.prototype.getParamValue = function ( name ) {
630 return this.parameters[ name ];
631 };
632
633 /**
634 * Get the current selected state of the filters
635 *
636 * @param {boolean} [onlySelected] return an object containing only the filters with a value
637 * @return {Object} Filters selected state
638 */
639 FiltersViewModel.prototype.getSelectedState = function ( onlySelected ) {
640 var i,
641 items = this.getItems(),
642 result = {};
643
644 for ( i = 0; i < items.length; i++ ) {
645 if ( !onlySelected || items[ i ].getValue() ) {
646 result[ items[ i ].getName() ] = items[ i ].getValue();
647 }
648 }
649
650 return result;
651 };
652
653 /**
654 * Get the current full state of the filters
655 *
656 * @return {Object} Filters full state
657 */
658 FiltersViewModel.prototype.getFullState = function () {
659 var i,
660 items = this.getItems(),
661 result = {};
662
663 for ( i = 0; i < items.length; i++ ) {
664 result[ items[ i ].getName() ] = {
665 selected: items[ i ].isSelected(),
666 conflicted: items[ i ].isConflicted(),
667 included: items[ i ].isIncluded()
668 };
669 }
670
671 return result;
672 };
673
674 /**
675 * Get an object representing default parameters state
676 *
677 * @return {Object} Default parameter values
678 */
679 FiltersViewModel.prototype.getDefaultParams = function () {
680 var result = {};
681
682 // Get default filter state
683 // eslint-disable-next-line no-jquery/no-each-util
684 $.each( this.groups, function ( name, model ) {
685 if ( !model.isSticky() ) {
686 $.extend( true, result, model.getDefaultParams() );
687 }
688 } );
689
690 return result;
691 };
692
693 /**
694 * Get a parameter representation of all sticky parameters
695 *
696 * @return {Object} Sticky parameter values
697 */
698 FiltersViewModel.prototype.getStickyParams = function () {
699 var result = [];
700
701 // eslint-disable-next-line no-jquery/no-each-util
702 $.each( this.groups, function ( name, model ) {
703 if ( model.isSticky() ) {
704 if ( model.isPerGroupRequestParameter() ) {
705 result.push( name );
706 } else {
707 // Each filter is its own param
708 result = result.concat( model.getItems().map( function ( filterItem ) {
709 return filterItem.getParamName();
710 } ) );
711 }
712 }
713 } );
714
715 return result;
716 };
717
718 /**
719 * Get a parameter representation of all sticky parameters
720 *
721 * @return {Object} Sticky parameter values
722 */
723 FiltersViewModel.prototype.getStickyParamsValues = function () {
724 var result = {};
725
726 // eslint-disable-next-line no-jquery/no-each-util
727 $.each( this.groups, function ( name, model ) {
728 if ( model.isSticky() ) {
729 $.extend( true, result, model.getParamRepresentation() );
730 }
731 } );
732
733 return result;
734 };
735
736 /**
737 * Analyze the groups and their filters and output an object representing
738 * the state of the parameters they represent.
739 *
740 * @param {Object} [filterDefinition] An object defining the filter values,
741 * keyed by filter names.
742 * @return {Object} Parameter state object
743 */
744 FiltersViewModel.prototype.getParametersFromFilters = function ( filterDefinition ) {
745 var groupItemDefinition,
746 result = {},
747 groupItems = this.getFilterGroups();
748
749 if ( filterDefinition ) {
750 groupItemDefinition = {};
751 // Filter definition is "flat", but in effect
752 // each group needs to tell us its result based
753 // on the values in it. We need to split this list
754 // back into groupings so we can "feed" it to the
755 // loop below, and we need to expand it so it includes
756 // all filters (set to false)
757 this.getItems().forEach( function ( filterItem ) {
758 groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {};
759 groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = filterItem.coerceValue( filterDefinition[ filterItem.getName() ] );
760 } );
761 }
762
763 // eslint-disable-next-line no-jquery/no-each-util
764 $.each( groupItems, function ( group, model ) {
765 $.extend(
766 result,
767 model.getParamRepresentation(
768 groupItemDefinition ?
769 groupItemDefinition[ group ] : null
770 )
771 );
772 } );
773
774 return result;
775 };
776
777 /**
778 * This is the opposite of the #getParametersFromFilters method; this goes over
779 * the given parameters and translates into a selected/unselected value in the filters.
780 *
781 * @param {Object} params Parameters query object
782 * @return {Object} Filter state object
783 */
784 FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
785 var groupMap = {},
786 model = this,
787 result = {};
788
789 // Go over the given parameters, break apart to groupings
790 // The resulting object represents the group with its parameter
791 // values. For example:
792 // {
793 // group1: {
794 // param1: "1",
795 // param2: "0",
796 // param3: "1"
797 // },
798 // group2: "param4|param5"
799 // }
800 // eslint-disable-next-line no-jquery/no-each-util
801 $.each( params, function ( paramName, paramValue ) {
802 var groupName,
803 itemOrGroup = model.parameterMap[ paramName ];
804
805 if ( itemOrGroup ) {
806 groupName = itemOrGroup instanceof FilterItem ?
807 itemOrGroup.getGroupName() : itemOrGroup.getName();
808
809 groupMap[ groupName ] = groupMap[ groupName ] || {};
810 groupMap[ groupName ][ paramName ] = paramValue;
811 }
812 } );
813
814 // Go over all groups, so we make sure we get the complete output
815 // even if the parameters don't include a certain group
816 // eslint-disable-next-line no-jquery/no-each-util
817 $.each( this.groups, function ( groupName, groupModel ) {
818 result = $.extend( true, {}, result, groupModel.getFilterRepresentation( groupMap[ groupName ] ) );
819 } );
820
821 return result;
822 };
823
824 /**
825 * Get the highlight parameters based on current filter configuration
826 *
827 * @return {Object} Object where keys are `<filter name>_color` and values
828 * are the selected highlight colors.
829 */
830 FiltersViewModel.prototype.getHighlightParameters = function () {
831 var highlightEnabled = this.isHighlightEnabled(),
832 result = {};
833
834 this.getItems().forEach( function ( filterItem ) {
835 if ( filterItem.isHighlightSupported() ) {
836 result[ filterItem.getName() + '_color' ] = highlightEnabled && filterItem.isHighlighted() ?
837 filterItem.getHighlightColor() :
838 null;
839 }
840 } );
841
842 return result;
843 };
844
845 /**
846 * Get an object representing the complete empty state of highlights
847 *
848 * @return {Object} Object containing all the highlight parameters set to their negative value
849 */
850 FiltersViewModel.prototype.getEmptyHighlightParameters = function () {
851 var result = {};
852
853 this.getItems().forEach( function ( filterItem ) {
854 if ( filterItem.isHighlightSupported() ) {
855 result[ filterItem.getName() + '_color' ] = null;
856 }
857 } );
858
859 return result;
860 };
861
862 /**
863 * Get an array of currently applied highlight colors
864 *
865 * @return {string[]} Currently applied highlight colors
866 */
867 FiltersViewModel.prototype.getCurrentlyUsedHighlightColors = function () {
868 var result = [];
869
870 if ( this.isHighlightEnabled() ) {
871 this.getHighlightedItems().forEach( function ( filterItem ) {
872 var color = filterItem.getHighlightColor();
873
874 if ( result.indexOf( color ) === -1 ) {
875 result.push( color );
876 }
877 } );
878 }
879
880 return result;
881 };
882
883 /**
884 * Sanitize value group of a string_option groups type
885 * Remove duplicates and make sure to only use valid
886 * values.
887 *
888 * @private
889 * @param {string} groupName Group name
890 * @param {string[]} valueArray Array of values
891 * @return {string[]} Array of valid values
892 */
893 FiltersViewModel.prototype.sanitizeStringOptionGroup = function ( groupName, valueArray ) {
894 var validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
895 return filterItem.getParamName();
896 } );
897
898 return mw.rcfilters.utils.normalizeParamOptions( valueArray, validNames );
899 };
900
901 /**
902 * Check whether no visible filter is selected.
903 *
904 * Filter groups that are hidden or sticky are not shown in the
905 * active filters area and therefore not included in this check.
906 *
907 * @return {boolean} No visible filter is selected
908 */
909 FiltersViewModel.prototype.areVisibleFiltersEmpty = function () {
910 // Check if there are either any selected items or any items
911 // that have highlight enabled
912 return !this.getItems().some( function ( filterItem ) {
913 var visible = !filterItem.getGroupModel().isSticky() && !filterItem.getGroupModel().isHidden(),
914 active = ( filterItem.isSelected() || filterItem.isHighlighted() );
915 return visible && active;
916 } );
917 };
918
919 /**
920 * Check whether the invert state is a valid one. A valid invert state is one where
921 * there are actual namespaces selected.
922 *
923 * This is done to compare states to previous ones that may have had the invert model
924 * selected but effectively had no namespaces, so are not effectively different than
925 * ones where invert is not selected.
926 *
927 * @return {boolean} Invert is effectively selected
928 */
929 FiltersViewModel.prototype.areNamespacesEffectivelyInverted = function () {
930 return this.getInvertModel().isSelected() &&
931 this.findSelectedItems().some( function ( itemModel ) {
932 return itemModel.getGroupModel().getName() === 'namespace';
933 } );
934 };
935
936 /**
937 * Get the item that matches the given name
938 *
939 * @param {string} name Filter name
940 * @return {mw.rcfilters.dm.FilterItem} Filter item
941 */
942 FiltersViewModel.prototype.getItemByName = function ( name ) {
943 return this.getItems().filter( function ( item ) {
944 return name === item.getName();
945 } )[ 0 ];
946 };
947
948 /**
949 * Set all filters to false or empty/all
950 * This is equivalent to display all.
951 */
952 FiltersViewModel.prototype.emptyAllFilters = function () {
953 this.getItems().forEach( function ( filterItem ) {
954 if ( !filterItem.getGroupModel().isSticky() ) {
955 this.toggleFilterSelected( filterItem.getName(), false );
956 }
957 }.bind( this ) );
958 };
959
960 /**
961 * Toggle selected state of one item
962 *
963 * @param {string} name Name of the filter item
964 * @param {boolean} [isSelected] Filter selected state
965 */
966 FiltersViewModel.prototype.toggleFilterSelected = function ( name, isSelected ) {
967 var item = this.getItemByName( name );
968
969 if ( item ) {
970 item.toggleSelected( isSelected );
971 }
972 };
973
974 /**
975 * Toggle selected state of items by their names
976 *
977 * @param {Object} filterDef Filter definitions
978 */
979 FiltersViewModel.prototype.toggleFiltersSelected = function ( filterDef ) {
980 Object.keys( filterDef ).forEach( function ( name ) {
981 this.toggleFilterSelected( name, filterDef[ name ] );
982 }.bind( this ) );
983 };
984
985 /**
986 * Get a group model from its name
987 *
988 * @param {string} groupName Group name
989 * @return {mw.rcfilters.dm.FilterGroup} Group model
990 */
991 FiltersViewModel.prototype.getGroup = function ( groupName ) {
992 return this.groups[ groupName ];
993 };
994
995 /**
996 * Get all filters within a specified group by its name
997 *
998 * @param {string} groupName Group name
999 * @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
1000 */
1001 FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
1002 return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
1003 };
1004
1005 /**
1006 * Find items whose labels match the given string
1007 *
1008 * @param {string} query Search string
1009 * @param {boolean} [returnFlat] Return a flat array. If false, the result
1010 * is an object whose keys are the group names and values are an array of
1011 * filters per group. If set to true, returns an array of filters regardless
1012 * of their groups.
1013 * @return {Object} An object of items to show
1014 * arranged by their group names
1015 */
1016 FiltersViewModel.prototype.findMatches = function ( query, returnFlat ) {
1017 var i, searchIsEmpty,
1018 groupTitle,
1019 result = {},
1020 flatResult = [],
1021 view = this.getViewByTrigger( query.substr( 0, 1 ) ),
1022 items = this.getFiltersByView( view );
1023
1024 // Normalize so we can search strings regardless of case and view
1025 query = query.trim().toLowerCase();
1026 if ( view !== 'default' ) {
1027 query = query.substr( 1 );
1028 }
1029 // Trim again to also intercept cases where the spaces were after the trigger
1030 // eg: '# str'
1031 query = query.trim();
1032
1033 // Check if the search if actually empty; this can be a problem when
1034 // we use prefixes to denote different views
1035 searchIsEmpty = query.length === 0;
1036
1037 // item label starting with the query string
1038 for ( i = 0; i < items.length; i++ ) {
1039 if (
1040 searchIsEmpty ||
1041 items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ||
1042 (
1043 // For tags, we want the parameter name to be included in the search
1044 view === 'tags' &&
1045 items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
1046 )
1047 ) {
1048 result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
1049 result[ items[ i ].getGroupName() ].push( items[ i ] );
1050 flatResult.push( items[ i ] );
1051 }
1052 }
1053
1054 if ( $.isEmptyObject( result ) ) {
1055 // item containing the query string in their label, description, or group title
1056 for ( i = 0; i < items.length; i++ ) {
1057 groupTitle = items[ i ].getGroupModel().getTitle();
1058 if (
1059 searchIsEmpty ||
1060 items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
1061 items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
1062 groupTitle.toLowerCase().indexOf( query ) > -1 ||
1063 (
1064 // For tags, we want the parameter name to be included in the search
1065 view === 'tags' &&
1066 items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
1067 )
1068 ) {
1069 result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
1070 result[ items[ i ].getGroupName() ].push( items[ i ] );
1071 flatResult.push( items[ i ] );
1072 }
1073 }
1074 }
1075
1076 return returnFlat ? flatResult : result;
1077 };
1078
1079 /**
1080 * Get items that are highlighted
1081 *
1082 * @return {mw.rcfilters.dm.FilterItem[]} Highlighted items
1083 */
1084 FiltersViewModel.prototype.getHighlightedItems = function () {
1085 return this.getItems().filter( function ( filterItem ) {
1086 return filterItem.isHighlightSupported() &&
1087 filterItem.getHighlightColor();
1088 } );
1089 };
1090
1091 /**
1092 * Get items that allow highlights even if they're not currently highlighted
1093 *
1094 * @return {mw.rcfilters.dm.FilterItem[]} Items supporting highlights
1095 */
1096 FiltersViewModel.prototype.getItemsSupportingHighlights = function () {
1097 return this.getItems().filter( function ( filterItem ) {
1098 return filterItem.isHighlightSupported();
1099 } );
1100 };
1101
1102 /**
1103 * Get all selected items
1104 *
1105 * @return {mw.rcfilters.dm.FilterItem[]} Selected items
1106 */
1107 FiltersViewModel.prototype.findSelectedItems = function () {
1108 var allSelected = [];
1109
1110 // eslint-disable-next-line no-jquery/no-each-util
1111 $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
1112 allSelected = allSelected.concat( groupModel.findSelectedItems() );
1113 } );
1114
1115 return allSelected;
1116 };
1117
1118 /**
1119 * Get the current view
1120 *
1121 * @return {string} Current view
1122 */
1123 FiltersViewModel.prototype.getCurrentView = function () {
1124 return this.currentView;
1125 };
1126
1127 /**
1128 * Get the label for the current view
1129 *
1130 * @param {string} viewName View name
1131 * @return {string} Label for the current view
1132 */
1133 FiltersViewModel.prototype.getViewTitle = function ( viewName ) {
1134 viewName = viewName || this.getCurrentView();
1135
1136 return this.views[ viewName ] && this.views[ viewName ].title;
1137 };
1138
1139 /**
1140 * Get the view that fits the given trigger
1141 *
1142 * @param {string} trigger Trigger
1143 * @return {string} Name of view
1144 */
1145 FiltersViewModel.prototype.getViewByTrigger = function ( trigger ) {
1146 var result = 'default';
1147
1148 // eslint-disable-next-line no-jquery/no-each-util
1149 $.each( this.views, function ( name, data ) {
1150 if ( data.trigger === trigger ) {
1151 result = name;
1152 }
1153 } );
1154
1155 return result;
1156 };
1157
1158 /**
1159 * Return a version of the given string that is without any
1160 * view triggers.
1161 *
1162 * @param {string} str Given string
1163 * @return {string} Result
1164 */
1165 FiltersViewModel.prototype.removeViewTriggers = function ( str ) {
1166 if ( this.getViewFromString( str ) !== 'default' ) {
1167 str = str.substr( 1 );
1168 }
1169
1170 return str;
1171 };
1172
1173 /**
1174 * Get the view from the given string by a trigger, if it exists
1175 *
1176 * @param {string} str Given string
1177 * @return {string} View name
1178 */
1179 FiltersViewModel.prototype.getViewFromString = function ( str ) {
1180 return this.getViewByTrigger( str.substr( 0, 1 ) );
1181 };
1182
1183 /**
1184 * Set the current search for the system.
1185 * This also dictates what items and groups are visible according
1186 * to the search in #findMatches
1187 *
1188 * @param {string} searchQuery Search query, including triggers
1189 * @fires searchChange
1190 */
1191 FiltersViewModel.prototype.setSearch = function ( searchQuery ) {
1192 var visibleGroups, visibleGroupNames;
1193
1194 if ( this.searchQuery !== searchQuery ) {
1195 // Check if the view changed
1196 this.switchView( this.getViewFromString( searchQuery ) );
1197
1198 visibleGroups = this.findMatches( searchQuery );
1199 visibleGroupNames = Object.keys( visibleGroups );
1200
1201 // Update visibility of items and groups
1202 // eslint-disable-next-line no-jquery/no-each-util
1203 $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
1204 // Check if the group is visible at all
1205 groupModel.toggleVisible( visibleGroupNames.indexOf( groupName ) !== -1 );
1206 groupModel.setVisibleItems( visibleGroups[ groupName ] || [] );
1207 } );
1208
1209 this.searchQuery = searchQuery;
1210 this.emit( 'searchChange', this.searchQuery );
1211 }
1212 };
1213
1214 /**
1215 * Get the current search
1216 *
1217 * @return {string} Current search query
1218 */
1219 FiltersViewModel.prototype.getSearch = function () {
1220 return this.searchQuery;
1221 };
1222
1223 /**
1224 * Switch the current view
1225 *
1226 * @private
1227 * @param {string} view View name
1228 */
1229 FiltersViewModel.prototype.switchView = function ( view ) {
1230 if ( this.views[ view ] && this.currentView !== view ) {
1231 this.currentView = view;
1232 }
1233 };
1234
1235 /**
1236 * Toggle the highlight feature on and off.
1237 * Propagate the change to filter items.
1238 *
1239 * @param {boolean} enable Highlight should be enabled
1240 * @fires highlightChange
1241 */
1242 FiltersViewModel.prototype.toggleHighlight = function ( enable ) {
1243 enable = enable === undefined ? !this.highlightEnabled : enable;
1244
1245 if ( this.highlightEnabled !== enable ) {
1246 this.highlightEnabled = enable;
1247 this.emit( 'highlightChange', this.highlightEnabled );
1248 }
1249 };
1250
1251 /**
1252 * Check if the highlight feature is enabled
1253 * @return {boolean}
1254 */
1255 FiltersViewModel.prototype.isHighlightEnabled = function () {
1256 return !!this.highlightEnabled;
1257 };
1258
1259 /**
1260 * Toggle the inverted namespaces property on and off.
1261 * Propagate the change to namespace filter items.
1262 *
1263 * @param {boolean} enable Inverted property is enabled
1264 */
1265 FiltersViewModel.prototype.toggleInvertedNamespaces = function ( enable ) {
1266 this.toggleFilterSelected( this.getInvertModel().getName(), enable );
1267 };
1268
1269 /**
1270 * Get the model object that represents the 'invert' filter
1271 *
1272 * @return {mw.rcfilters.dm.FilterItem}
1273 */
1274 FiltersViewModel.prototype.getInvertModel = function () {
1275 return this.getGroup( 'invertGroup' ).getItemByParamName( 'invert' );
1276 };
1277
1278 /**
1279 * Set highlight color for a specific filter item
1280 *
1281 * @param {string} filterName Name of the filter item
1282 * @param {string} color Selected color
1283 */
1284 FiltersViewModel.prototype.setHighlightColor = function ( filterName, color ) {
1285 this.getItemByName( filterName ).setHighlightColor( color );
1286 };
1287
1288 /**
1289 * Clear highlight for a specific filter item
1290 *
1291 * @param {string} filterName Name of the filter item
1292 */
1293 FiltersViewModel.prototype.clearHighlightColor = function ( filterName ) {
1294 this.getItemByName( filterName ).clearHighlightColor();
1295 };
1296
1297 module.exports = FiltersViewModel;
1298
1299 }() );