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