Merge "RCFilters: Have the model accept multiple views"
[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} filters 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 * definition: {
221 * title: 'namespaces' // Message key
222 * type: 'string_options',
223 * separator: ';',
224 * labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
225 * fullCoverage: true
226 * },
227 * items: []
228 * }
229 * ]
230 * }
231 * }
232 */
233 mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filters, views ) {
234 var filterItem, filterConflictResult, groupConflictResult,
235 model = this,
236 items = [],
237 groupConflictMap = {},
238 filterConflictMap = {},
239 /*!
240 * Expand a conflict definition from group name to
241 * the list of all included filters in that group.
242 * We do this so that the direct relationship in the
243 * models are consistently item->items rather than
244 * mixing item->group with item->item.
245 *
246 * @param {Object} obj Conflict definition
247 * @return {Object} Expanded conflict definition
248 */
249 expandConflictDefinitions = function ( obj ) {
250 var result = {};
251
252 $.each( obj, function ( key, conflicts ) {
253 var filterName,
254 adjustedConflicts = {};
255
256 conflicts.forEach( function ( conflict ) {
257 var filter;
258
259 if ( conflict.filter ) {
260 filterName = model.groups[ conflict.group ].getPrefixedName( conflict.filter );
261 filter = model.getItemByName( filterName );
262
263 // Rename
264 adjustedConflicts[ filterName ] = $.extend(
265 {},
266 conflict,
267 {
268 filter: filterName,
269 item: filter
270 }
271 );
272 } else {
273 // This conflict is for an entire group. Split it up to
274 // represent each filter
275
276 // Get the relevant group items
277 model.groups[ conflict.group ].getItems().forEach( function ( groupItem ) {
278 // Rebuild the conflict
279 adjustedConflicts[ groupItem.getName() ] = $.extend(
280 {},
281 conflict,
282 {
283 filter: groupItem.getName(),
284 item: groupItem
285 }
286 );
287 } );
288 }
289 } );
290
291 result[ key ] = adjustedConflicts;
292 } );
293
294 return result;
295 };
296
297 // Reset
298 this.clearItems();
299 this.groups = {};
300 this.views = {};
301
302 views = views || {};
303
304 // Filters
305 this.views.default = { name: 'default', label: mw.msg( 'rcfilters-filterlist-title' ) };
306 filters.forEach( function ( data ) {
307 var i,
308 group = data.name;
309
310 if ( !model.groups[ group ] ) {
311 model.groups[ group ] = new mw.rcfilters.dm.FilterGroup( group, {
312 type: data.type,
313 title: data.title ? mw.msg( data.title ) : group,
314 separator: data.separator,
315 fullCoverage: !!data.fullCoverage,
316 whatsThis: {
317 body: data.whatsThisBody,
318 header: data.whatsThisHeader,
319 linkText: data.whatsThisLinkText,
320 url: data.whatsThisUrl
321 }
322 } );
323 }
324
325 // Filters are given to us with msg-keys, we need
326 // to translate those before we hand them off
327 for ( i = 0; i < data.filters.length; i++ ) {
328 data.filters[ i ].label = data.filters[ i ].label ? mw.msg( data.filters[ i ].label ) : data.filters[ i ].name;
329 data.filters[ i ].description = data.filters[ i ].description ? mw.msg( data.filters[ i ].description ) : '';
330 }
331
332 model.groups[ group ].initializeFilters( data.filters, data.default );
333 items = items.concat( model.groups[ group ].getItems() );
334
335 // Prepare conflicts
336 if ( data.conflicts ) {
337 // Group conflicts
338 groupConflictMap[ group ] = data.conflicts;
339 }
340
341 for ( i = 0; i < data.filters.length; i++ ) {
342 // Filter conflicts
343 if ( data.filters[ i ].conflicts ) {
344 filterItem = model.groups[ group ].getItemByParamName( data.filters[ i ].name );
345 filterConflictMap[ filterItem.getName() ] = data.filters[ i ].conflicts;
346 }
347 }
348 } );
349
350 if ( mw.config.get( 'wgStructuredChangeFiltersEnableExperimentalViews' ) ) {
351 $.each( views, function ( viewName, viewData ) {
352 model.views[ viewName ] = {
353 name: viewData.name,
354 title: viewData.title,
355 trigger: viewData.trigger
356 };
357
358 // Group
359 viewData.groups.forEach( function ( groupData ) {
360 model.groups[ groupData.name ] = new mw.rcfilters.dm.FilterGroup(
361 groupData.name,
362 $.extend( true, {}, groupData.definition, { view: viewName } )
363 );
364
365 // Add items
366 model.groups[ groupData.name ].initializeFilters( groupData.items );
367
368 // Add to global search list
369 items = items.concat( model.groups[ groupData.name ].getItems() );
370 } );
371 } );
372 }
373
374 // Add item references to the model, for lookup
375 this.addItems( items );
376
377 // Expand conflicts
378 groupConflictResult = expandConflictDefinitions( groupConflictMap );
379 filterConflictResult = expandConflictDefinitions( filterConflictMap );
380
381 // Set conflicts for groups
382 $.each( groupConflictResult, function ( group, conflicts ) {
383 model.groups[ group ].setConflicts( conflicts );
384 } );
385
386 // Set conflicts for items
387 $.each( filterConflictResult, function ( filterName, conflicts ) {
388 var filterItem = model.getItemByName( filterName );
389 // set conflicts for items in the group
390 filterItem.setConflicts( conflicts );
391 } );
392
393 // Create a map between known parameters and their models
394 $.each( this.groups, function ( group, groupModel ) {
395 if ( groupModel.getType() === 'send_unselected_if_any' ) {
396 // Individual filters
397 groupModel.getItems().forEach( function ( filterItem ) {
398 model.parameterMap[ filterItem.getParamName() ] = filterItem;
399 } );
400 } else if ( groupModel.getType() === 'string_options' ) {
401 // Group
402 model.parameterMap[ groupModel.getName() ] = groupModel;
403 }
404 } );
405
406 this.currentView = 'default';
407
408 // Finish initialization
409 this.emit( 'initialize' );
410 };
411
412 /**
413 * Get the names of all available filters
414 *
415 * @return {string[]} An array of filter names
416 */
417 mw.rcfilters.dm.FiltersViewModel.prototype.getFilterNames = function () {
418 return this.getItems().map( function ( item ) { return item.getName(); } );
419 };
420
421 /**
422 * Get the object that defines groups by their name.
423 *
424 * @return {Object} Filter groups
425 */
426 mw.rcfilters.dm.FiltersViewModel.prototype.getFilterGroups = function () {
427 return this.groups;
428 };
429
430 /**
431 * Get the object that defines groups that match a certain view by their name.
432 *
433 * @param {string} [view] Requested view. If not given, uses current view
434 * @return {Object} Filter groups matching a display group
435 */
436 mw.rcfilters.dm.FiltersViewModel.prototype.getFilterGroupsByView = function ( view ) {
437 var result = {};
438
439 view = view || this.getCurrentView();
440
441 $.each( this.groups, function ( groupName, groupModel ) {
442 if ( groupModel.getView() === view ) {
443 result[ groupName ] = groupModel;
444 }
445 } );
446
447 return result;
448 };
449
450 /**
451 * Get an array of filters matching the given display group.
452 *
453 * @param {string} [view] Requested view. If not given, uses current view
454 * @return {mw.rcfilters.dm.FilterItem} Filter items matching the group
455 */
456 mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersByView = function ( view ) {
457 var groups,
458 result = [];
459
460 view = view || this.getCurrentView();
461
462 groups = this.getFilterGroupsByView( view );
463
464 $.each( groups, function ( groupName, groupModel ) {
465 result = result.concat( groupModel.getItems() );
466 } );
467
468 return result;
469 };
470
471 /**
472 * Get the trigger for the requested view.
473 *
474 * @param {string} view View name
475 * @return {string} View trigger, if exists
476 */
477 mw.rcfilters.dm.FiltersViewModel.prototype.getViewTrigger = function ( view ) {
478 return ( this.views[ view ] && this.views[ view ].trigger ) || '';
479 };
480 /**
481 * Get the value of a specific parameter
482 *
483 * @param {string} name Parameter name
484 * @return {number|string} Parameter value
485 */
486 mw.rcfilters.dm.FiltersViewModel.prototype.getParamValue = function ( name ) {
487 return this.parameters[ name ];
488 };
489
490 /**
491 * Get the current selected state of the filters
492 *
493 * @return {Object} Filters selected state
494 */
495 mw.rcfilters.dm.FiltersViewModel.prototype.getSelectedState = function () {
496 var i,
497 items = this.getItems(),
498 result = {};
499
500 for ( i = 0; i < items.length; i++ ) {
501 result[ items[ i ].getName() ] = items[ i ].isSelected();
502 }
503
504 return result;
505 };
506
507 /**
508 * Get the current full state of the filters
509 *
510 * @return {Object} Filters full state
511 */
512 mw.rcfilters.dm.FiltersViewModel.prototype.getFullState = function () {
513 var i,
514 items = this.getItems(),
515 result = {};
516
517 for ( i = 0; i < items.length; i++ ) {
518 result[ items[ i ].getName() ] = {
519 selected: items[ i ].isSelected(),
520 conflicted: items[ i ].isConflicted(),
521 included: items[ i ].isIncluded()
522 };
523 }
524
525 return result;
526 };
527
528 /**
529 * Get an object representing default parameters state
530 *
531 * @return {Object} Default parameter values
532 */
533 mw.rcfilters.dm.FiltersViewModel.prototype.getDefaultParams = function () {
534 var result = {};
535
536 // Get default filter state
537 $.each( this.groups, function ( name, model ) {
538 $.extend( true, result, model.getDefaultParams() );
539 } );
540
541 return result;
542 };
543
544 /**
545 * Analyze the groups and their filters and output an object representing
546 * the state of the parameters they represent.
547 *
548 * @param {Object} [filterDefinition] An object defining the filter values,
549 * keyed by filter names.
550 * @return {Object} Parameter state object
551 */
552 mw.rcfilters.dm.FiltersViewModel.prototype.getParametersFromFilters = function ( filterDefinition ) {
553 var groupItemDefinition,
554 result = {},
555 groupItems = this.getFilterGroups();
556
557 if ( filterDefinition ) {
558 groupItemDefinition = {};
559 // Filter definition is "flat", but in effect
560 // each group needs to tell us its result based
561 // on the values in it. We need to split this list
562 // back into groupings so we can "feed" it to the
563 // loop below, and we need to expand it so it includes
564 // all filters (set to false)
565 this.getItems().forEach( function ( filterItem ) {
566 groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {};
567 groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = !!filterDefinition[ filterItem.getName() ];
568 } );
569 }
570
571 $.each( groupItems, function ( group, model ) {
572 $.extend(
573 result,
574 model.getParamRepresentation(
575 groupItemDefinition ?
576 groupItemDefinition[ group ] : null
577 )
578 );
579 } );
580
581 return result;
582 };
583
584 /**
585 * This is the opposite of the #getParametersFromFilters method; this goes over
586 * the given parameters and translates into a selected/unselected value in the filters.
587 *
588 * @param {Object} params Parameters query object
589 * @return {Object} Filter state object
590 */
591 mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
592 var groupMap = {},
593 model = this,
594 result = {};
595
596 // Go over the given parameters, break apart to groupings
597 // The resulting object represents the group with its parameter
598 // values. For example:
599 // {
600 // group1: {
601 // param1: "1",
602 // param2: "0",
603 // param3: "1"
604 // },
605 // group2: "param4|param5"
606 // }
607 $.each( params, function ( paramName, paramValue ) {
608 var itemOrGroup = model.parameterMap[ paramName ];
609
610 if ( itemOrGroup instanceof mw.rcfilters.dm.FilterItem ) {
611 groupMap[ itemOrGroup.getGroupName() ] = groupMap[ itemOrGroup.getGroupName() ] || {};
612 groupMap[ itemOrGroup.getGroupName() ][ itemOrGroup.getParamName() ] = paramValue;
613 } else if ( itemOrGroup instanceof mw.rcfilters.dm.FilterGroup ) {
614 // This parameter represents a group (values are the filters)
615 // this is equivalent to checking if the group is 'string_options'
616 groupMap[ itemOrGroup.getName() ] = groupMap[ itemOrGroup.getName() ] || {};
617 groupMap[ itemOrGroup.getName() ] = paramValue;
618 }
619 } );
620
621 // Go over all groups, so we make sure we get the complete output
622 // even if the parameters don't include a certain group
623 $.each( this.groups, function ( groupName, groupModel ) {
624 result = $.extend( true, {}, result, groupModel.getFilterRepresentation( groupMap[ groupName ] ) );
625 } );
626
627 return result;
628 };
629
630 /**
631 * Get the highlight parameters based on current filter configuration
632 *
633 * @return {Object} Object where keys are "<filter name>_color" and values
634 * are the selected highlight colors.
635 */
636 mw.rcfilters.dm.FiltersViewModel.prototype.getHighlightParameters = function () {
637 var result = {};
638
639 this.getItems().forEach( function ( filterItem ) {
640 result[ filterItem.getName() + '_color' ] = filterItem.getHighlightColor() || null;
641 } );
642 result.highlight = String( Number( this.isHighlightEnabled() ) );
643
644 return result;
645 };
646
647 /**
648 * Extract the highlight values from given object. Since highlights are
649 * the same for filter and parameters, it doesn't matter which one is
650 * given; values will be returned with a full list of the highlights
651 * with colors or null values.
652 *
653 * @param {Object} representation Object containing representation of
654 * some or all highlight values
655 * @return {Object} Object where keys are "<filter name>_color" and values
656 * are the selected highlight colors. The returned object
657 * contains all available filters either with a color value
658 * or with null.
659 */
660 mw.rcfilters.dm.FiltersViewModel.prototype.extractHighlightValues = function ( representation ) {
661 var result = {};
662
663 this.getItems().forEach( function ( filterItem ) {
664 var highlightName = filterItem.getName() + '_color';
665 result[ highlightName ] = representation[ highlightName ] || null;
666 } );
667
668 return result;
669 };
670
671 /**
672 * Sanitize value group of a string_option groups type
673 * Remove duplicates and make sure to only use valid
674 * values.
675 *
676 * @private
677 * @param {string} groupName Group name
678 * @param {string[]} valueArray Array of values
679 * @return {string[]} Array of valid values
680 */
681 mw.rcfilters.dm.FiltersViewModel.prototype.sanitizeStringOptionGroup = function ( groupName, valueArray ) {
682 var validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
683 return filterItem.getParamName();
684 } );
685
686 return mw.rcfilters.utils.normalizeParamOptions( valueArray, validNames );
687 };
688
689 /**
690 * Check whether the current filter state is set to all false.
691 *
692 * @return {boolean} Current filters are all empty
693 */
694 mw.rcfilters.dm.FiltersViewModel.prototype.areCurrentFiltersEmpty = function () {
695 // Check if there are either any selected items or any items
696 // that have highlight enabled
697 return !this.getItems().some( function ( filterItem ) {
698 return filterItem.isSelected() || filterItem.isHighlighted();
699 } );
700 };
701
702 /**
703 * Check whether the default values of the filters are all false.
704 *
705 * @return {boolean} Default filters are all false
706 */
707 mw.rcfilters.dm.FiltersViewModel.prototype.areDefaultFiltersEmpty = function () {
708 var defaultFilters;
709
710 if ( this.defaultFiltersEmpty !== null ) {
711 // We only need to do this test once,
712 // because defaults are set once per session
713 defaultFilters = this.getFiltersFromParameters( this.getDefaultParams() );
714 this.defaultFiltersEmpty = Object.keys( defaultFilters ).every( function ( filterName ) {
715 return !defaultFilters[ filterName ];
716 } );
717 }
718
719 return this.defaultFiltersEmpty;
720 };
721
722 /**
723 * Get the item that matches the given name
724 *
725 * @param {string} name Filter name
726 * @return {mw.rcfilters.dm.FilterItem} Filter item
727 */
728 mw.rcfilters.dm.FiltersViewModel.prototype.getItemByName = function ( name ) {
729 return this.getItems().filter( function ( item ) {
730 return name === item.getName();
731 } )[ 0 ];
732 };
733
734 /**
735 * Set all filters to false or empty/all
736 * This is equivalent to display all.
737 */
738 mw.rcfilters.dm.FiltersViewModel.prototype.emptyAllFilters = function () {
739 this.getItems().forEach( function ( filterItem ) {
740 this.toggleFilterSelected( filterItem.getName(), false );
741 }.bind( this ) );
742 };
743
744 /**
745 * Toggle selected state of one item
746 *
747 * @param {string} name Name of the filter item
748 * @param {boolean} [isSelected] Filter selected state
749 */
750 mw.rcfilters.dm.FiltersViewModel.prototype.toggleFilterSelected = function ( name, isSelected ) {
751 var item = this.getItemByName( name );
752
753 if ( item ) {
754 item.toggleSelected( isSelected );
755 }
756 };
757
758 /**
759 * Toggle selected state of items by their names
760 *
761 * @param {Object} filterDef Filter definitions
762 */
763 mw.rcfilters.dm.FiltersViewModel.prototype.toggleFiltersSelected = function ( filterDef ) {
764 Object.keys( filterDef ).forEach( function ( name ) {
765 this.toggleFilterSelected( name, filterDef[ name ] );
766 }.bind( this ) );
767 };
768
769 /**
770 * Get a group model from its name
771 *
772 * @param {string} groupName Group name
773 * @return {mw.rcfilters.dm.FilterGroup} Group model
774 */
775 mw.rcfilters.dm.FiltersViewModel.prototype.getGroup = function ( groupName ) {
776 return this.groups[ groupName ];
777 };
778
779 /**
780 * Get all filters within a specified group by its name
781 *
782 * @param {string} groupName Group name
783 * @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
784 */
785 mw.rcfilters.dm.FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
786 return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
787 };
788
789 /**
790 * Find items whose labels match the given string
791 *
792 * @param {string} query Search string
793 * @param {boolean} [returnFlat] Return a flat array. If false, the result
794 * is an object whose keys are the group names and values are an array of
795 * filters per group. If set to true, returns an array of filters regardless
796 * of their groups.
797 * @return {Object} An object of items to show
798 * arranged by their group names
799 */
800 mw.rcfilters.dm.FiltersViewModel.prototype.findMatches = function ( query, returnFlat ) {
801 var i, searchIsEmpty,
802 groupTitle,
803 result = {},
804 flatResult = [],
805 view = this.getViewByTrigger( query.substr( 0, 1 ) ),
806 items = this.getFiltersByView( view );
807
808 // Normalize so we can search strings regardless of case and view
809 query = query.toLowerCase();
810 if ( view !== 'default' ) {
811 query = query.substr( 1 );
812 }
813
814 // Check if the search if actually empty; this can be a problem when
815 // we use prefixes to denote different views
816 searchIsEmpty = query.length === 0;
817
818 // item label starting with the query string
819 for ( i = 0; i < items.length; i++ ) {
820 if (
821 searchIsEmpty ||
822 items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ||
823 (
824 // For tags, we want the parameter name to be included in the search
825 view === 'tags' &&
826 items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
827 )
828 ) {
829 result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
830 result[ items[ i ].getGroupName() ].push( items[ i ] );
831 flatResult.push( items[ i ] );
832 }
833 }
834
835 if ( $.isEmptyObject( result ) ) {
836 // item containing the query string in their label, description, or group title
837 for ( i = 0; i < items.length; i++ ) {
838 groupTitle = items[ i ].getGroupModel().getTitle();
839 if (
840 searchIsEmpty ||
841 items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
842 items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
843 groupTitle.toLowerCase().indexOf( query ) > -1 ||
844 (
845 // For tags, we want the parameter name to be included in the search
846 view === 'tags' &&
847 items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
848 )
849 ) {
850 result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
851 result[ items[ i ].getGroupName() ].push( items[ i ] );
852 flatResult.push( items[ i ] );
853 }
854 }
855 }
856
857 return returnFlat ? flatResult : result;
858 };
859
860 /**
861 * Get items that are highlighted
862 *
863 * @return {mw.rcfilters.dm.FilterItem[]} Highlighted items
864 */
865 mw.rcfilters.dm.FiltersViewModel.prototype.getHighlightedItems = function () {
866 return this.getItems().filter( function ( filterItem ) {
867 return filterItem.isHighlightSupported() &&
868 filterItem.getHighlightColor();
869 } );
870 };
871
872 /**
873 * Get items that allow highlights even if they're not currently highlighted
874 *
875 * @return {mw.rcfilters.dm.FilterItem[]} Items supporting highlights
876 */
877 mw.rcfilters.dm.FiltersViewModel.prototype.getItemsSupportingHighlights = function () {
878 return this.getItems().filter( function ( filterItem ) {
879 return filterItem.isHighlightSupported();
880 } );
881 };
882
883 /**
884 * Switch the current view
885 *
886 * @param {string} view View name
887 * @fires update
888 */
889 mw.rcfilters.dm.FiltersViewModel.prototype.switchView = function ( view ) {
890 if ( this.views[ view ] && this.currentView !== view ) {
891 this.currentView = view;
892 this.emit( 'update' );
893 }
894 };
895
896 /**
897 * Get the current view
898 *
899 * @return {string} Current view
900 */
901 mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentView = function () {
902 return this.currentView;
903 };
904
905 /**
906 * Get the label for the current view
907 *
908 * @return {string} Label for the current view
909 */
910 mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentViewLabel = function () {
911 return this.views[ this.getCurrentView() ].title;
912 };
913
914 /**
915 * Get an array of all available view names
916 *
917 * @return {string} Available view names
918 */
919 mw.rcfilters.dm.FiltersViewModel.prototype.getAvailableViews = function () {
920 return Object.keys( this.views );
921 };
922
923 /**
924 * Get the view that fits the given trigger
925 *
926 * @param {string} trigger Trigger
927 * @return {string} Name of view
928 */
929 mw.rcfilters.dm.FiltersViewModel.prototype.getViewByTrigger = function ( trigger ) {
930 var result = 'default';
931
932 $.each( this.views, function ( name, data ) {
933 if ( data.trigger === trigger ) {
934 result = name;
935 }
936 } );
937
938 return result;
939 };
940
941 /**
942 * Toggle the highlight feature on and off.
943 * Propagate the change to filter items.
944 *
945 * @param {boolean} enable Highlight should be enabled
946 * @fires highlightChange
947 */
948 mw.rcfilters.dm.FiltersViewModel.prototype.toggleHighlight = function ( enable ) {
949 enable = enable === undefined ? !this.highlightEnabled : enable;
950
951 if ( this.highlightEnabled !== enable ) {
952 this.highlightEnabled = enable;
953
954 this.getItems().forEach( function ( filterItem ) {
955 filterItem.toggleHighlight( this.highlightEnabled );
956 }.bind( this ) );
957
958 this.emit( 'highlightChange', this.highlightEnabled );
959 }
960 };
961
962 /**
963 * Check if the highlight feature is enabled
964 * @return {boolean}
965 */
966 mw.rcfilters.dm.FiltersViewModel.prototype.isHighlightEnabled = function () {
967 return !!this.highlightEnabled;
968 };
969
970 /**
971 * Toggle the inverted namespaces property on and off.
972 * Propagate the change to namespace filter items.
973 *
974 * @param {boolean} enable Inverted property is enabled
975 * @fires invertChange
976 */
977 mw.rcfilters.dm.FiltersViewModel.prototype.toggleInvertedNamespaces = function ( enable ) {
978 enable = enable === undefined ? !this.invertedNamespaces : enable;
979
980 if ( this.invertedNamespaces !== enable ) {
981 this.invertedNamespaces = enable;
982
983 this.getFiltersByView( 'namespaces' ).forEach( function ( filterItem ) {
984 filterItem.toggleInverted( this.invertedNamespaces );
985 }.bind( this ) );
986
987 this.emit( 'invertChange', this.invertedNamespaces );
988 }
989 };
990
991 /**
992 * Check if the namespaces selection is set to be inverted
993 * @return {boolean}
994 */
995 mw.rcfilters.dm.FiltersViewModel.prototype.areNamespacesInverted = function () {
996 return !!this.invertedNamespaces;
997 };
998
999 /**
1000 * Set highlight color for a specific filter item
1001 *
1002 * @param {string} filterName Name of the filter item
1003 * @param {string} color Selected color
1004 */
1005 mw.rcfilters.dm.FiltersViewModel.prototype.setHighlightColor = function ( filterName, color ) {
1006 this.getItemByName( filterName ).setHighlightColor( color );
1007 };
1008
1009 /**
1010 * Clear highlight for a specific filter item
1011 *
1012 * @param {string} filterName Name of the filter item
1013 */
1014 mw.rcfilters.dm.FiltersViewModel.prototype.clearHighlightColor = function ( filterName ) {
1015 this.getItemByName( filterName ).clearHighlightColor();
1016 };
1017
1018 /**
1019 * Clear highlight for all filter items
1020 */
1021 mw.rcfilters.dm.FiltersViewModel.prototype.clearAllHighlightColors = function () {
1022 this.getItems().forEach( function ( filterItem ) {
1023 filterItem.clearHighlightColor();
1024 } );
1025 };
1026
1027 /**
1028 * Return a version of the given string that is without any
1029 * view triggers.
1030 *
1031 * @param {string} str Given string
1032 * @return {string} Result
1033 */
1034 mw.rcfilters.dm.FiltersViewModel.prototype.removeViewTriggers = function ( str ) {
1035 if ( this.getViewByTrigger( str.substr( 0, 1 ) ) !== 'default' ) {
1036 str = str.substr( 1 );
1037 }
1038
1039 return str;
1040 };
1041 }( mediaWiki, jQuery ) );