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