RCFilters UI: Filter interaction: conflicts
[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
19 // Events
20 this.aggregate( { update: 'filterItemUpdate' } );
21 this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
22 };
23
24 /* Initialization */
25 OO.initClass( mw.rcfilters.dm.FiltersViewModel );
26 OO.mixinClass( mw.rcfilters.dm.FiltersViewModel, OO.EventEmitter );
27 OO.mixinClass( mw.rcfilters.dm.FiltersViewModel, OO.EmitterList );
28
29 /* Events */
30
31 /**
32 * @event initialize
33 *
34 * Filter list is initialized
35 */
36
37 /**
38 * @event itemUpdate
39 * @param {mw.rcfilters.dm.FilterItem} item Filter item updated
40 *
41 * Filter item has changed
42 */
43
44 /* Methods */
45
46 /**
47 * Re-assess the states of filter items based on the interactions between them
48 *
49 * @param {mw.rcfilters.dm.FilterItem} [item] Changed item. If not given, the
50 * method will go over the state of all items
51 */
52 mw.rcfilters.dm.FiltersViewModel.prototype.reassessFilterInteractions = function ( item ) {
53 var allSelected,
54 model = this,
55 iterationItems = item !== undefined ? [ item ] : this.getItems();
56
57 iterationItems.forEach( function ( checkedItem ) {
58 var allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ),
59 groupModel = checkedItem.getGroupModel();
60
61 // Check for subsets (included filters) plus the item itself:
62 allCheckedItems.forEach( function ( filterItemName ) {
63 var itemInSubset = model.getItemByName( filterItemName );
64
65 itemInSubset.toggleIncluded(
66 // If any of itemInSubset's supersets are selected, this item
67 // is included
68 itemInSubset.getSuperset().some( function ( supersetName ) {
69 return ( model.getItemByName( supersetName ).isSelected() );
70 } )
71 );
72 } );
73
74 // Update coverage for the changed group
75 if ( groupModel.isFullCoverage() ) {
76 allSelected = groupModel.areAllSelected();
77 groupModel.getItems().forEach( function ( filterItem ) {
78 filterItem.toggleFullyCovered( allSelected );
79 } );
80 }
81 } );
82
83 // Check for conflicts
84 // In this case, we must go over all items, since
85 // conflicts are bidirectional and depend not only on
86 // individual items, but also on the selected states of
87 // the groups they're in.
88 this.getItems().forEach( function ( filterItem ) {
89 var inConflict = false,
90 filterItemGroup = filterItem.getGroupModel();
91
92 // For each item, see if that item is still conflicting
93 $.each( model.groups, function ( groupName, groupModel ) {
94 if ( filterItem.getGroupName() === groupName ) {
95 // Check inside the group
96 inConflict = groupModel.areAnySelectedInConflictWith( filterItem );
97 } else {
98 // According to the spec, if two items conflict from two different
99 // groups, the conflict only lasts if the groups **only have selected
100 // items that are conflicting**. If a group has selected items that
101 // are conflicting and non-conflicting, the scope of the result has
102 // expanded enough to completely remove the conflict.
103
104 // For example, see two groups with conflicts:
105 // userExpLevel: [
106 // {
107 // name: 'experienced',
108 // conflicts: [ 'unregistered' ]
109 // }
110 // ],
111 // registration: [
112 // {
113 // name: 'registered',
114 // },
115 // {
116 // name: 'unregistered',
117 // }
118 // ]
119 // If we select 'experienced', then 'unregistered' is in conflict (and vice versa),
120 // because, inherently, 'experienced' filter only includes registered users, and so
121 // both filters are in conflict with one another.
122 // However, the minute we select 'registered', the scope of our results
123 // has expanded to no longer have a conflict with 'experienced' filter, and
124 // so the conflict is removed.
125
126 // In our case, we need to check if the entire group conflicts with
127 // the entire item's group, so we follow the above spec
128 inConflict = (
129 // The foreign group is in conflict with this item
130 groupModel.areAllSelectedInConflictWith( filterItem ) &&
131 // Every selected member of the item's own group is also
132 // in conflict with the other group
133 filterItemGroup.getSelectedItems().every( function ( otherGroupItem ) {
134 return groupModel.areAllSelectedInConflictWith( otherGroupItem );
135 } )
136 );
137 }
138
139 // If we're in conflict, this will return 'false' which
140 // will break the loop. Otherwise, we're not in conflict
141 // and the loop continues
142 return !inConflict;
143 } );
144
145 // Toggle the item state
146 filterItem.toggleConflicted( inConflict );
147 } );
148 };
149
150 /**
151 * Set filters and preserve a group relationship based on
152 * the definition given by an object
153 *
154 * @param {Object} filters Filter group definition
155 */
156 mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filters ) {
157 var i, filterItem, selectedFilterNames,
158 model = this,
159 items = [],
160 addArrayElementsUnique = function ( arr, elements ) {
161 elements = Array.isArray( elements ) ? elements : [ elements ];
162
163 elements.forEach( function ( element ) {
164 if ( arr.indexOf( element ) === -1 ) {
165 arr.push( element );
166 }
167 } );
168
169 return arr;
170 },
171 conflictMap = {},
172 supersetMap = {};
173
174 // Reset
175 this.clearItems();
176 this.groups = {};
177
178 $.each( filters, function ( group, data ) {
179 if ( !model.groups[ group ] ) {
180 model.groups[ group ] = new mw.rcfilters.dm.FilterGroup( group, {
181 type: data.type,
182 title: data.title,
183 separator: data.separator,
184 fullCoverage: !!data.fullCoverage
185 } );
186 }
187
188 selectedFilterNames = [];
189 for ( i = 0; i < data.filters.length; i++ ) {
190 filterItem = new mw.rcfilters.dm.FilterItem( data.filters[ i ].name, model.groups[ group ], {
191 group: group,
192 label: data.filters[ i ].label,
193 description: data.filters[ i ].description,
194 subset: data.filters[ i ].subset
195 } );
196
197 // For convenience, we should store each filter's "supersets" -- these are
198 // the filters that have that item in their subset list. This will just
199 // make it easier to go through whether the item has any other items
200 // that affect it (and are selected) at any given time
201 if ( data.filters[ i ].subset ) {
202 data.filters[ i ].subset.forEach( function ( subsetFilterName ) { // eslint-disable-line no-loop-func
203 supersetMap[ subsetFilterName ] = supersetMap[ subsetFilterName ] || [];
204 addArrayElementsUnique(
205 supersetMap[ subsetFilterName ],
206 filterItem.getName()
207 );
208 } );
209 }
210
211 // Conflicts are bi-directional, which means FilterA can define having
212 // a conflict with FilterB, and this conflict should appear in **both**
213 // filter definitions.
214 // We need to remap all the 'conflicts' so they reflect the entire state
215 // in either direction regardless of which filter defined the other as conflicting.
216 if ( data.filters[ i ].conflicts ) {
217 conflictMap[ filterItem.getName() ] = conflictMap[ filterItem.getName() ] || [];
218 addArrayElementsUnique(
219 conflictMap[ filterItem.getName() ],
220 data.filters[ i ].conflicts
221 );
222
223 data.filters[ i ].conflicts.forEach( function ( conflictingFilterName ) { // eslint-disable-line no-loop-func
224 // Add this filter to the conflicts of each of the filters in its list
225 conflictMap[ conflictingFilterName ] = conflictMap[ conflictingFilterName ] || [];
226 addArrayElementsUnique(
227 conflictMap[ conflictingFilterName ],
228 filterItem.getName()
229 );
230 } );
231 }
232
233 if ( data.type === 'send_unselected_if_any' ) {
234 // Store the default parameter state
235 // For this group type, parameter values are direct
236 model.defaultParams[ data.filters[ i ].name ] = Number( !!data.filters[ i ].default );
237 } else if (
238 data.type === 'string_options' &&
239 data.filters[ i ].default
240 ) {
241 selectedFilterNames.push( data.filters[ i ].name );
242 }
243
244 model.groups[ group ].addItems( filterItem );
245 items.push( filterItem );
246 }
247
248 if ( data.type === 'string_options' ) {
249 // Store the default parameter group state
250 // For this group, the parameter is group name and value is the names
251 // of selected items
252 model.defaultParams[ group ] = model.sanitizeStringOptionGroup( group, selectedFilterNames ).join( model.groups[ group ].getSeparator() );
253 }
254 } );
255
256 items.forEach( function ( filterItem ) {
257 // Apply conflict map to the items
258 // Now that we mapped all items and conflicts bi-directionally
259 // we need to apply the definition to each filter again
260 filterItem.setConflicts( conflictMap[ filterItem.getName() ] );
261
262 // Apply the superset map
263 filterItem.setSuperset( supersetMap[ filterItem.getName() ] );
264 } );
265
266 // Add items to the model
267 this.addItems( items );
268
269 this.emit( 'initialize' );
270 };
271
272 /**
273 * Get the names of all available filters
274 *
275 * @return {string[]} An array of filter names
276 */
277 mw.rcfilters.dm.FiltersViewModel.prototype.getFilterNames = function () {
278 return this.getItems().map( function ( item ) { return item.getName(); } );
279 };
280
281 /**
282 * Get the object that defines groups by their name.
283 *
284 * @return {Object} Filter groups
285 */
286 mw.rcfilters.dm.FiltersViewModel.prototype.getFilterGroups = function () {
287 return this.groups;
288 };
289
290 /**
291 * Get the value of a specific parameter
292 *
293 * @param {string} name Parameter name
294 * @return {number|string} Parameter value
295 */
296 mw.rcfilters.dm.FiltersViewModel.prototype.getParamValue = function ( name ) {
297 return this.parameters[ name ];
298 };
299
300 /**
301 * Get the current selected state of the filters
302 *
303 * @return {Object} Filters selected state
304 */
305 mw.rcfilters.dm.FiltersViewModel.prototype.getSelectedState = function () {
306 var i,
307 items = this.getItems(),
308 result = {};
309
310 for ( i = 0; i < items.length; i++ ) {
311 result[ items[ i ].getName() ] = items[ i ].isSelected();
312 }
313
314 return result;
315 };
316
317 /**
318 * Get the current full state of the filters
319 *
320 * @return {Object} Filters full state
321 */
322 mw.rcfilters.dm.FiltersViewModel.prototype.getFullState = function () {
323 var i,
324 items = this.getItems(),
325 result = {};
326
327 for ( i = 0; i < items.length; i++ ) {
328 result[ items[ i ].getName() ] = {
329 selected: items[ i ].isSelected(),
330 conflicted: items[ i ].isConflicted(),
331 included: items[ i ].isIncluded()
332 };
333 }
334
335 return result;
336 };
337
338 /**
339 * Get the default parameters object
340 *
341 * @return {Object} Default parameter values
342 */
343 mw.rcfilters.dm.FiltersViewModel.prototype.getDefaultParams = function () {
344 return this.defaultParams;
345 };
346
347 /**
348 * Set all filter states to default values
349 */
350 mw.rcfilters.dm.FiltersViewModel.prototype.setFiltersToDefaults = function () {
351 var defaultFilterStates = this.getFiltersFromParameters( this.getDefaultParams() );
352
353 this.updateFilters( defaultFilterStates );
354 };
355
356 /**
357 * Analyze the groups and their filters and output an object representing
358 * the state of the parameters they represent.
359 *
360 * @param {Object} [filterGroups] An object defining the filter groups to
361 * translate to parameters. Its structure must follow that of this.groups
362 * see #getFilterGroups
363 * @return {Object} Parameter state object
364 */
365 mw.rcfilters.dm.FiltersViewModel.prototype.getParametersFromFilters = function ( filterGroups ) {
366 var i, filterItems, anySelected, values,
367 result = {},
368 groupItems = filterGroups || this.getFilterGroups();
369
370 $.each( groupItems, function ( group, model ) {
371 filterItems = model.getItems();
372
373 if ( model.getType() === 'send_unselected_if_any' ) {
374 // First, check if any of the items are selected at all.
375 // If none is selected, we're treating it as if they are
376 // all false
377 anySelected = filterItems.some( function ( filterItem ) {
378 return filterItem.isSelected();
379 } );
380
381 // Go over the items and define the correct values
382 for ( i = 0; i < filterItems.length; i++ ) {
383 result[ filterItems[ i ].getName() ] = anySelected ?
384 Number( !filterItems[ i ].isSelected() ) : 0;
385 }
386 } else if ( model.getType() === 'string_options' ) {
387 values = [];
388 for ( i = 0; i < filterItems.length; i++ ) {
389 if ( filterItems[ i ].isSelected() ) {
390 values.push( filterItems[ i ].getName() );
391 }
392 }
393
394 if ( values.length === 0 || values.length === filterItems.length ) {
395 result[ group ] = 'all';
396 } else {
397 result[ group ] = values.join( model.getSeparator() );
398 }
399 }
400 } );
401
402 return result;
403 };
404
405 /**
406 * Sanitize value group of a string_option groups type
407 * Remove duplicates and make sure to only use valid
408 * values.
409 *
410 * @private
411 * @param {string} groupName Group name
412 * @param {string[]} valueArray Array of values
413 * @return {string[]} Array of valid values
414 */
415 mw.rcfilters.dm.FiltersViewModel.prototype.sanitizeStringOptionGroup = function( groupName, valueArray ) {
416 var result = [],
417 validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
418 return filterItem.getName();
419 } );
420
421 if ( valueArray.indexOf( 'all' ) > -1 ) {
422 // If anywhere in the values there's 'all', we
423 // treat it as if only 'all' was selected.
424 // Example: param=valid1,valid2,all
425 // Result: param=all
426 return [ 'all' ];
427 }
428
429 // Get rid of any dupe and invalid parameter, only output
430 // valid ones
431 // Example: param=valid1,valid2,invalid1,valid1
432 // Result: param=valid1,valid2
433 valueArray.forEach( function ( value ) {
434 if (
435 validNames.indexOf( value ) > -1 &&
436 result.indexOf( value ) === -1
437 ) {
438 result.push( value );
439 }
440 } );
441
442 return result;
443 };
444
445 /**
446 * Check whether the current filter state is set to all false.
447 *
448 * @return {boolean} Current filters are all empty
449 */
450 mw.rcfilters.dm.FiltersViewModel.prototype.areCurrentFiltersEmpty = function () {
451 var currFilters = this.getSelectedState();
452
453 return Object.keys( currFilters ).every( function ( filterName ) {
454 return !currFilters[ filterName ];
455 } );
456 };
457
458 /**
459 * Check whether the default values of the filters are all false.
460 *
461 * @return {boolean} Default filters are all false
462 */
463 mw.rcfilters.dm.FiltersViewModel.prototype.areDefaultFiltersEmpty = function () {
464 var defaultFilters;
465
466 if ( this.defaultFiltersEmpty !== null ) {
467 // We only need to do this test once,
468 // because defaults are set once per session
469 defaultFilters = this.getFiltersFromParameters();
470 this.defaultFiltersEmpty = Object.keys( defaultFilters ).every( function ( filterName ) {
471 return !defaultFilters[ filterName ];
472 } );
473 }
474
475 return this.defaultFiltersEmpty;
476 };
477
478 /**
479 * This is the opposite of the #getParametersFromFilters method; this goes over
480 * the given parameters and translates into a selected/unselected value in the filters.
481 *
482 * @param {Object} params Parameters query object
483 * @return {Object} Filter state object
484 */
485 mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
486 var i, filterItem,
487 groupMap = {},
488 model = this,
489 base = this.getDefaultParams(),
490 result = {};
491
492 params = $.extend( {}, base, params );
493
494 $.each( params, function ( paramName, paramValue ) {
495 // Find the filter item
496 filterItem = model.getItemByName( paramName );
497 // Ignore if no filter item exists
498 if ( filterItem ) {
499 groupMap[ filterItem.getGroupName() ] = groupMap[ filterItem.getGroupName() ] || {};
500
501 // Mark the group if it has any items that are selected
502 groupMap[ filterItem.getGroupName() ].hasSelected = (
503 groupMap[ filterItem.getGroupName() ].hasSelected ||
504 !!Number( paramValue )
505 );
506
507 // Add the relevant filter into the group map
508 groupMap[ filterItem.getGroupName() ].filters = groupMap[ filterItem.getGroupName() ].filters || [];
509 groupMap[ filterItem.getGroupName() ].filters.push( filterItem );
510 } else if ( model.groups.hasOwnProperty( paramName ) ) {
511 // This parameter represents a group (values are the filters)
512 // this is equivalent to checking if the group is 'string_options'
513 groupMap[ paramName ] = { filters: model.groups[ paramName ].getItems() };
514 }
515 } );
516
517 // Now that we know the groups' selection states, we need to go over
518 // the filters in the groups and mark their selected states appropriately
519 $.each( groupMap, function ( group, data ) {
520 var paramValues, filterItem,
521 allItemsInGroup = data.filters;
522
523 if ( model.groups[ group ].getType() === 'send_unselected_if_any' ) {
524 for ( i = 0; i < allItemsInGroup.length; i++ ) {
525 filterItem = allItemsInGroup[ i ];
526
527 result[ filterItem.getName() ] = data.hasSelected ?
528 // Flip the definition between the parameter
529 // state and the filter state
530 // This is what the 'toggleSelected' value of the filter is
531 !Number( params[ filterItem.getName() ] ) :
532 // Otherwise, there are no selected items in the
533 // group, which means the state is false
534 false;
535 }
536 } else if ( model.groups[ group ].getType() === 'string_options' ) {
537 paramValues = model.sanitizeStringOptionGroup( group, params[ group ].split( model.groups[ group ].getSeparator() ) );
538
539 for ( i = 0; i < allItemsInGroup.length; i++ ) {
540 filterItem = allItemsInGroup[ i ];
541
542 result[ filterItem.getName() ] = (
543 // If it is the word 'all'
544 paramValues.length === 1 && paramValues[ 0 ] === 'all' ||
545 // All values are written
546 paramValues.length === model.groups[ group ].getItemCount()
547 ) ?
548 // All true (either because all values are written or the term 'all' is written)
549 // is the same as all filters set to false
550 false :
551 // Otherwise, the filter is selected only if it appears in the parameter values
552 paramValues.indexOf( filterItem.getName() ) > -1;
553 }
554 }
555 } );
556 return result;
557 };
558
559 /**
560 * Get the item that matches the given name
561 *
562 * @param {string} name Filter name
563 * @return {mw.rcfilters.dm.FilterItem} Filter item
564 */
565 mw.rcfilters.dm.FiltersViewModel.prototype.getItemByName = function ( name ) {
566 return this.getItems().filter( function ( item ) {
567 return name === item.getName();
568 } )[ 0 ];
569 };
570
571 /**
572 * Set all filters to false or empty/all
573 * This is equivalent to display all.
574 */
575 mw.rcfilters.dm.FiltersViewModel.prototype.emptyAllFilters = function () {
576 var filters = {};
577
578 this.getItems().forEach( function ( filterItem ) {
579 filters[ filterItem.getName() ] = false;
580 } );
581
582 // Update filters
583 this.updateFilters( filters );
584 };
585
586 /**
587 * Toggle selected state of items by their names
588 *
589 * @param {Object} filterDef Filter definitions
590 */
591 mw.rcfilters.dm.FiltersViewModel.prototype.updateFilters = function ( filterDef ) {
592 var name, filterItem;
593
594 for ( name in filterDef ) {
595 filterItem = this.getItemByName( name );
596 filterItem.toggleSelected( filterDef[ name ] );
597 }
598 };
599
600 /**
601 * Get a group model from its name
602 *
603 * @param {string} groupName Group name
604 * @return {mw.rcfilters.dm.FilterGroup} Group model
605 */
606 mw.rcfilters.dm.FiltersViewModel.prototype.getGroup = function ( groupName ) {
607 return this.groups[ groupName ];
608 };
609
610 /**
611 * Get all filters within a specified group by its name
612 *
613 * @param {string} groupName Group name
614 * @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
615 */
616 mw.rcfilters.dm.FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
617 return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
618 };
619
620 /**
621 * Find items whose labels match the given string
622 *
623 * @param {string} query Search string
624 * @return {Object} An object of items to show
625 * arranged by their group names
626 */
627 mw.rcfilters.dm.FiltersViewModel.prototype.findMatches = function ( query ) {
628 var i,
629 groupTitle,
630 result = {},
631 items = this.getItems();
632
633 // Normalize so we can search strings regardless of case
634 query = query.toLowerCase();
635
636 // item label starting with the query string
637 for ( i = 0; i < items.length; i++ ) {
638 if ( items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ) {
639 result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
640 result[ items[ i ].getGroupName() ].push( items[ i ] );
641 }
642 }
643
644 if ( $.isEmptyObject( result ) ) {
645 // item containing the query string in their label, description, or group title
646 for ( i = 0; i < items.length; i++ ) {
647 groupTitle = items[ i ].getGroupModel().getTitle();
648 if (
649 items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
650 items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
651 groupTitle.toLowerCase().indexOf( query ) > -1
652 ) {
653 result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
654 result[ items[ i ].getGroupName() ].push( items[ i ] );
655 }
656 }
657 }
658
659 return result;
660 };
661
662 }( mediaWiki, jQuery ) );