d1b7925c028f1410ab9f1679b2bfb7728f081077
[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.excludedByMap = {};
17 this.defaultParams = {};
18 this.defaultFiltersEmpty = null;
19
20 // Events
21 this.aggregate( { update: 'filterItemUpdate' } );
22 this.connect( this, { filterItemUpdate: 'onFilterItemUpdate' } );
23 };
24
25 /* Initialization */
26 OO.initClass( mw.rcfilters.dm.FiltersViewModel );
27 OO.mixinClass( mw.rcfilters.dm.FiltersViewModel, OO.EventEmitter );
28 OO.mixinClass( mw.rcfilters.dm.FiltersViewModel, OO.EmitterList );
29
30 /* Events */
31
32 /**
33 * @event initialize
34 *
35 * Filter list is initialized
36 */
37
38 /**
39 * @event itemUpdate
40 * @param {mw.rcfilters.dm.FilterItem} item Filter item updated
41 *
42 * Filter item has changed
43 */
44
45 /* Methods */
46
47 /**
48 * Respond to filter item change.
49 *
50 * @param {mw.rcfilters.dm.FilterItem} item Updated filter
51 * @fires itemUpdate
52 */
53 mw.rcfilters.dm.FiltersViewModel.prototype.onFilterItemUpdate = function ( item ) {
54 // Reapply the active state of filters
55 this.reapplyActiveFilters( item );
56
57 // Recheck group activity state
58 this.getGroup( item.getGroup() ).checkActive();
59
60 this.emit( 'itemUpdate', item );
61 };
62
63 /**
64 * Calculate the active state of the filters, based on selected filters in the group.
65 *
66 * @param {mw.rcfilters.dm.FilterItem} item Changed item
67 */
68 mw.rcfilters.dm.FiltersViewModel.prototype.reapplyActiveFilters = function ( item ) {
69 var selectedItemsCount,
70 group = item.getGroup(),
71 model = this;
72 if (
73 !this.getGroup( group ).getExclusionType() ||
74 this.getGroup( group ).getExclusionType() === 'default'
75 ) {
76 // Default behavior
77 // If any parameter is selected, but:
78 // - If there are unselected items in the group, they are inactive
79 // - If the entire group is selected, all are inactive
80
81 // Check what's selected in the group
82 selectedItemsCount = this.getGroupFilters( group ).filter( function ( filterItem ) {
83 return filterItem.isSelected();
84 } ).length;
85
86 this.getGroupFilters( group ).forEach( function ( filterItem ) {
87 filterItem.toggleActive(
88 selectedItemsCount > 0 ?
89 // If some items are selected
90 (
91 selectedItemsCount === model.groups[ group ].getItemCount() ?
92 // If **all** items are selected, they're all inactive
93 false :
94 // If not all are selected, then the selected are active
95 // and the unselected are inactive
96 filterItem.isSelected()
97 ) :
98 // No item is selected, everything is active
99 true
100 );
101 } );
102 } else if ( this.getGroup( group ).getExclusionType() === 'explicit' ) {
103 // Explicit behavior
104 // - Go over the list of excluded filters to change their
105 // active states accordingly
106
107 // For each item in the list, see if there are other selected
108 // filters that also exclude it. If it does, it will still be
109 // inactive.
110
111 item.getExcludedFilters().forEach( function ( filterName ) {
112 var filterItem = model.getItemByName( filterName );
113
114 // Note to reduce confusion:
115 // - item is the filter whose state changed and should exclude the other filters
116 // in its list of exclusions
117 // - filterItem is the filter that is potentially being excluded by the current item
118 // - anotherExcludingFilter is any other filter that excludes filterItem; we must check
119 // if that filter is selected, because if it is, we should not touch the excluded item
120 if (
121 // Check if there are any filters (other than the current one)
122 // that also exclude the filterName
123 !model.excludedByMap[ filterName ].some( function ( anotherExcludingFilterName ) {
124 var anotherExcludingFilter = model.getItemByName( anotherExcludingFilterName );
125
126 return (
127 anotherExcludingFilterName !== item.getName() &&
128 anotherExcludingFilter.isSelected()
129 );
130 } )
131 ) {
132 // Only change the state for filters that aren't
133 // also affected by other excluding selected filters
134 filterItem.toggleActive( !item.isSelected() );
135 }
136 } );
137 }
138 };
139
140 /**
141 * Set filters and preserve a group relationship based on
142 * the definition given by an object
143 *
144 * @param {Object} filters Filter group definition
145 */
146 mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filters ) {
147 var i, filterItem, selectedFilterNames, excludedFilters,
148 model = this,
149 items = [],
150 addToMap = function ( excludedFilters ) {
151 excludedFilters.forEach( function ( filterName ) {
152 model.excludedByMap[ filterName ] = model.excludedByMap[ filterName ] || [];
153 model.excludedByMap[ filterName ].push( filterItem.getName() );
154 } );
155 };
156
157 // Reset
158 this.clearItems();
159 this.groups = {};
160 this.excludedByMap = {};
161
162 $.each( filters, function ( group, data ) {
163 if ( !model.groups[ group ] ) {
164 model.groups[ group ] = new mw.rcfilters.dm.FilterGroup( {
165 type: data.type,
166 title: data.title,
167 separator: data.separator,
168 exclusionType: data.exclusionType
169 } );
170 }
171
172 selectedFilterNames = [];
173 for ( i = 0; i < data.filters.length; i++ ) {
174 excludedFilters = data.filters[ i ].excludes || [];
175
176 filterItem = new mw.rcfilters.dm.FilterItem( data.filters[ i ].name, {
177 group: group,
178 label: data.filters[ i ].label,
179 description: data.filters[ i ].description,
180 selected: data.filters[ i ].selected,
181 excludes: excludedFilters,
182 'default': data.filters[ i ].default
183 } );
184
185 // Map filters and what excludes them
186 addToMap( excludedFilters );
187
188 if ( data.type === 'send_unselected_if_any' ) {
189 // Store the default parameter state
190 // For this group type, parameter values are direct
191 model.defaultParams[ data.filters[ i ].name ] = Number( !!data.filters[ i ].default );
192 } else if (
193 data.type === 'string_options' &&
194 data.filters[ i ].default
195 ) {
196 selectedFilterNames.push( data.filters[ i ].name );
197 }
198
199 model.groups[ group ].addItems( filterItem );
200 items.push( filterItem );
201 }
202
203 if ( data.type === 'string_options' ) {
204 // Store the default parameter group state
205 // For this group, the parameter is group name and value is the names
206 // of selected items
207 model.defaultParams[ group ] = model.sanitizeStringOptionGroup( group, selectedFilterNames ).join( model.groups[ group ].getSeparator() );
208 }
209 } );
210
211 this.addItems( items );
212
213 this.emit( 'initialize' );
214 };
215
216 /**
217 * Get the names of all available filters
218 *
219 * @return {string[]} An array of filter names
220 */
221 mw.rcfilters.dm.FiltersViewModel.prototype.getFilterNames = function () {
222 return this.getItems().map( function ( item ) { return item.getName(); } );
223 };
224
225 /**
226 * Get the object that defines groups by their name.
227 *
228 * @return {Object} Filter groups
229 */
230 mw.rcfilters.dm.FiltersViewModel.prototype.getFilterGroups = function () {
231 return this.groups;
232 };
233
234 /**
235 * Update the representation of the parameters. These are the back-end
236 * parameters representing the filters, but they represent the given
237 * current state regardless of validity.
238 *
239 * This should only run after filters are already set.
240 *
241 * @param {Object} params Parameter state
242 */
243 mw.rcfilters.dm.FiltersViewModel.prototype.updateParameters = function ( params ) {
244 var model = this;
245
246 $.each( params, function ( name, value ) {
247 // Only store the parameters that exist in the system
248 if ( model.getItemByName( name ) ) {
249 model.parameters[ name ] = value;
250 }
251 } );
252 };
253
254 /**
255 * Get the value of a specific parameter
256 *
257 * @param {string} name Parameter name
258 * @return {number|string} Parameter value
259 */
260 mw.rcfilters.dm.FiltersViewModel.prototype.getParamValue = function ( name ) {
261 return this.parameters[ name ];
262 };
263
264 /**
265 * Get the current selected state of the filters
266 *
267 * @return {Object} Filters selected state
268 */
269 mw.rcfilters.dm.FiltersViewModel.prototype.getSelectedState = function () {
270 var i,
271 items = this.getItems(),
272 result = {};
273
274 for ( i = 0; i < items.length; i++ ) {
275 result[ items[ i ].getName() ] = items[ i ].isSelected();
276 }
277
278 return result;
279 };
280
281 /**
282 * Get the current full state of the filters
283 *
284 * @return {Object} Filters full state
285 */
286 mw.rcfilters.dm.FiltersViewModel.prototype.getFullState = function () {
287 var i,
288 items = this.getItems(),
289 result = {};
290
291 for ( i = 0; i < items.length; i++ ) {
292 result[ items[ i ].getName() ] = {
293 selected: items[ i ].isSelected(),
294 active: items[ i ].isActive()
295 };
296 }
297
298 return result;
299 };
300
301 /**
302 * Get the default parameters object
303 *
304 * @return {Object} Default parameter values
305 */
306 mw.rcfilters.dm.FiltersViewModel.prototype.getDefaultParams = function () {
307 return this.defaultParams;
308 };
309
310 /**
311 * Set all filter states to default values
312 */
313 mw.rcfilters.dm.FiltersViewModel.prototype.setFiltersToDefaults = function () {
314 var defaultFilterStates = this.getFiltersFromParameters( this.getDefaultParams() );
315
316 this.updateFilters( defaultFilterStates );
317 };
318
319 /**
320 * Analyze the groups and their filters and output an object representing
321 * the state of the parameters they represent.
322 *
323 * @param {Object} [filterGroups] An object defining the filter groups to
324 * translate to parameters. Its structure must follow that of this.groups
325 * see #getFilterGroups
326 * @return {Object} Parameter state object
327 */
328 mw.rcfilters.dm.FiltersViewModel.prototype.getParametersFromFilters = function ( filterGroups ) {
329 var i, filterItems, anySelected, values,
330 result = {},
331 groupItems = filterGroups || this.getFilterGroups();
332
333 $.each( groupItems, function ( group, model ) {
334 filterItems = model.getItems();
335
336 if ( model.getType() === 'send_unselected_if_any' ) {
337 // First, check if any of the items are selected at all.
338 // If none is selected, we're treating it as if they are
339 // all false
340 anySelected = filterItems.some( function ( filterItem ) {
341 return filterItem.isSelected();
342 } );
343
344 // Go over the items and define the correct values
345 for ( i = 0; i < filterItems.length; i++ ) {
346 result[ filterItems[ i ].getName() ] = anySelected ?
347 Number( !filterItems[ i ].isSelected() ) : 0;
348 }
349 } else if ( model.getType() === 'string_options' ) {
350 values = [];
351 for ( i = 0; i < filterItems.length; i++ ) {
352 if ( filterItems[ i ].isSelected() ) {
353 values.push( filterItems[ i ].getName() );
354 }
355 }
356
357 if ( values.length === 0 || values.length === filterItems.length ) {
358 result[ group ] = 'all';
359 } else {
360 result[ group ] = values.join( model.getSeparator() );
361 }
362 }
363 } );
364
365 return result;
366 };
367
368 /**
369 * Sanitize value group of a string_option groups type
370 * Remove duplicates and make sure to only use valid
371 * values.
372 *
373 * @private
374 * @param {string} groupName Group name
375 * @param {string[]} valueArray Array of values
376 * @return {string[]} Array of valid values
377 */
378 mw.rcfilters.dm.FiltersViewModel.prototype.sanitizeStringOptionGroup = function( groupName, valueArray ) {
379 var result = [],
380 validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
381 return filterItem.getName();
382 } );
383
384 if ( valueArray.indexOf( 'all' ) > -1 ) {
385 // If anywhere in the values there's 'all', we
386 // treat it as if only 'all' was selected.
387 // Example: param=valid1,valid2,all
388 // Result: param=all
389 return [ 'all' ];
390 }
391
392 // Get rid of any dupe and invalid parameter, only output
393 // valid ones
394 // Example: param=valid1,valid2,invalid1,valid1
395 // Result: param=valid1,valid2
396 valueArray.forEach( function ( value ) {
397 if (
398 validNames.indexOf( value ) > -1 &&
399 result.indexOf( value ) === -1
400 ) {
401 result.push( value );
402 }
403 } );
404
405 return result;
406 };
407
408 /**
409 * Check whether the current filter state is set to all false.
410 *
411 * @return {boolean} Current filters are all empty
412 */
413 mw.rcfilters.dm.FiltersViewModel.prototype.areCurrentFiltersEmpty = function () {
414 var currFilters = this.getSelectedState();
415
416 return Object.keys( currFilters ).every( function ( filterName ) {
417 return !currFilters[ filterName ];
418 } );
419 };
420
421 /**
422 * Check whether the default values of the filters are all false.
423 *
424 * @return {boolean} Default filters are all false
425 */
426 mw.rcfilters.dm.FiltersViewModel.prototype.areDefaultFiltersEmpty = function () {
427 var defaultFilters;
428
429 if ( this.defaultFiltersEmpty !== null ) {
430 // We only need to do this test once,
431 // because defaults are set once per session
432 defaultFilters = this.getFiltersFromParameters();
433 this.defaultFiltersEmpty = Object.keys( defaultFilters ).every( function ( filterName ) {
434 return !defaultFilters[ filterName ];
435 } );
436 }
437
438 return this.defaultFiltersEmpty;
439 };
440
441 /**
442 * This is the opposite of the #getParametersFromFilters method; this goes over
443 * the given parameters and translates into a selected/unselected value in the filters.
444 *
445 * @param {Object} params Parameters query object
446 * @return {Object} Filter state object
447 */
448 mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
449 var i, filterItem,
450 groupMap = {},
451 model = this,
452 base = this.getDefaultParams(),
453 result = {};
454
455 params = $.extend( {}, base, params );
456
457 $.each( params, function ( paramName, paramValue ) {
458 // Find the filter item
459 filterItem = model.getItemByName( paramName );
460 // Ignore if no filter item exists
461 if ( filterItem ) {
462 groupMap[ filterItem.getGroup() ] = groupMap[ filterItem.getGroup() ] || {};
463
464 // Mark the group if it has any items that are selected
465 groupMap[ filterItem.getGroup() ].hasSelected = (
466 groupMap[ filterItem.getGroup() ].hasSelected ||
467 !!Number( paramValue )
468 );
469
470 // Add the relevant filter into the group map
471 groupMap[ filterItem.getGroup() ].filters = groupMap[ filterItem.getGroup() ].filters || [];
472 groupMap[ filterItem.getGroup() ].filters.push( filterItem );
473 } else if ( model.groups.hasOwnProperty( paramName ) ) {
474 // This parameter represents a group (values are the filters)
475 // this is equivalent to checking if the group is 'string_options'
476 groupMap[ paramName ] = { filters: model.groups[ paramName ].getItems() };
477 }
478 } );
479
480 // Now that we know the groups' selection states, we need to go over
481 // the filters in the groups and mark their selected states appropriately
482 $.each( groupMap, function ( group, data ) {
483 var paramValues, filterItem,
484 allItemsInGroup = data.filters;
485
486 if ( model.groups[ group ].getType() === 'send_unselected_if_any' ) {
487 for ( i = 0; i < allItemsInGroup.length; i++ ) {
488 filterItem = allItemsInGroup[ i ];
489
490 result[ filterItem.getName() ] = data.hasSelected ?
491 // Flip the definition between the parameter
492 // state and the filter state
493 // This is what the 'toggleSelected' value of the filter is
494 !Number( params[ filterItem.getName() ] ) :
495 // Otherwise, there are no selected items in the
496 // group, which means the state is false
497 false;
498 }
499 } else if ( model.groups[ group ].getType() === 'string_options' ) {
500 paramValues = model.sanitizeStringOptionGroup( group, params[ group ].split( model.groups[ group ].getSeparator() ) );
501
502 for ( i = 0; i < allItemsInGroup.length; i++ ) {
503 filterItem = allItemsInGroup[ i ];
504
505 result[ filterItem.getName() ] = (
506 // If it is the word 'all'
507 paramValues.length === 1 && paramValues[ 0 ] === 'all' ||
508 // All values are written
509 paramValues.length === model.groups[ group ].getItemCount()
510 ) ?
511 // All true (either because all values are written or the term 'all' is written)
512 // is the same as all filters set to false
513 false :
514 // Otherwise, the filter is selected only if it appears in the parameter values
515 paramValues.indexOf( filterItem.getName() ) > -1;
516 }
517 }
518 } );
519 return result;
520 };
521
522 /**
523 * Get the item that matches the given name
524 *
525 * @param {string} name Filter name
526 * @return {mw.rcfilters.dm.FilterItem} Filter item
527 */
528 mw.rcfilters.dm.FiltersViewModel.prototype.getItemByName = function ( name ) {
529 return this.getItems().filter( function ( item ) {
530 return name === item.getName();
531 } )[ 0 ];
532 };
533
534 /**
535 * Set all filters to false or empty/all
536 * This is equivalent to display all.
537 */
538 mw.rcfilters.dm.FiltersViewModel.prototype.emptyAllFilters = function () {
539 var filters = {};
540
541 this.getItems().forEach( function ( filterItem ) {
542 filters[ filterItem.getName() ] = false;
543 } );
544
545 // Update filters
546 this.updateFilters( filters );
547 };
548
549 /**
550 * Toggle selected state of items by their names
551 *
552 * @param {Object} filterDef Filter definitions
553 */
554 mw.rcfilters.dm.FiltersViewModel.prototype.updateFilters = function ( filterDef ) {
555 var name, filterItem;
556
557 for ( name in filterDef ) {
558 filterItem = this.getItemByName( name );
559 filterItem.toggleSelected( filterDef[ name ] );
560 }
561 };
562
563 /**
564 * Get a group model from its name
565 *
566 * @param {string} groupName Group name
567 * @return {mw.rcfilters.dm.FilterGroup} Group model
568 */
569 mw.rcfilters.dm.FiltersViewModel.prototype.getGroup = function ( groupName ) {
570 return this.groups[ groupName ];
571 };
572
573 /**
574 * Get all filters within a specified group by its name
575 *
576 * @param {string} groupName Group name
577 * @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
578 */
579 mw.rcfilters.dm.FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
580 return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
581 };
582
583 /**
584 * Find items whose labels match the given string
585 *
586 * @param {string} str Search string
587 * @return {Object} An object of items to show
588 * arranged by their group names
589 */
590 mw.rcfilters.dm.FiltersViewModel.prototype.findMatches = function ( str ) {
591 var i,
592 result = {},
593 items = this.getItems();
594
595 // Normalize so we can search strings regardless of case
596 str = str.toLowerCase();
597 for ( i = 0; i < items.length; i++ ) {
598 if ( items[ i ].getLabel().toLowerCase().indexOf( str ) > -1 ) {
599 result[ items[ i ].getGroup() ] = result[ items[ i ].getGroup() ] || [];
600 result[ items[ i ].getGroup() ].push( items[ i ] );
601 }
602 }
603 return result;
604 };
605
606 }( mediaWiki, jQuery ) );