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