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