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