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