RC filters: Let the group widget know its own name
[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 name: group,
166 type: data.type,
167 title: data.title,
168 separator: data.separator,
169 exclusionType: data.exclusionType
170 } );
171 }
172
173 selectedFilterNames = [];
174 for ( i = 0; i < data.filters.length; i++ ) {
175 excludedFilters = data.filters[ i ].excludes || [];
176
177 filterItem = new mw.rcfilters.dm.FilterItem( data.filters[ i ].name, {
178 group: group,
179 label: data.filters[ i ].label,
180 description: data.filters[ i ].description,
181 selected: data.filters[ i ].selected,
182 excludes: excludedFilters,
183 'default': data.filters[ i ].default
184 } );
185
186 // Map filters and what excludes them
187 addToMap( excludedFilters );
188
189 if ( data.type === 'send_unselected_if_any' ) {
190 // Store the default parameter state
191 // For this group type, parameter values are direct
192 model.defaultParams[ data.filters[ i ].name ] = Number( !!data.filters[ i ].default );
193 } else if (
194 data.type === 'string_options' &&
195 data.filters[ i ].default
196 ) {
197 selectedFilterNames.push( data.filters[ i ].name );
198 }
199
200 model.groups[ group ].addItems( filterItem );
201 items.push( filterItem );
202 }
203
204 if ( data.type === 'string_options' ) {
205 // Store the default parameter group state
206 // For this group, the parameter is group name and value is the names
207 // of selected items
208 model.defaultParams[ group ] = model.sanitizeStringOptionGroup( group, selectedFilterNames ).join( model.groups[ group ].getSeparator() );
209 }
210 } );
211
212 this.addItems( items );
213
214 this.emit( 'initialize' );
215 };
216
217 /**
218 * Get the names of all available filters
219 *
220 * @return {string[]} An array of filter names
221 */
222 mw.rcfilters.dm.FiltersViewModel.prototype.getFilterNames = function () {
223 return this.getItems().map( function ( item ) { return item.getName(); } );
224 };
225
226 /**
227 * Get the object that defines groups by their name.
228 *
229 * @return {Object} Filter groups
230 */
231 mw.rcfilters.dm.FiltersViewModel.prototype.getFilterGroups = function () {
232 return this.groups;
233 };
234
235 /**
236 * Update the representation of the parameters. These are the back-end
237 * parameters representing the filters, but they represent the given
238 * current state regardless of validity.
239 *
240 * This should only run after filters are already set.
241 *
242 * @param {Object} params Parameter state
243 */
244 mw.rcfilters.dm.FiltersViewModel.prototype.updateParameters = function ( params ) {
245 var model = this;
246
247 $.each( params, function ( name, value ) {
248 // Only store the parameters that exist in the system
249 if ( model.getItemByName( name ) ) {
250 model.parameters[ name ] = value;
251 }
252 } );
253 };
254
255 /**
256 * Get the value of a specific parameter
257 *
258 * @param {string} name Parameter name
259 * @return {number|string} Parameter value
260 */
261 mw.rcfilters.dm.FiltersViewModel.prototype.getParamValue = function ( name ) {
262 return this.parameters[ name ];
263 };
264
265 /**
266 * Get the current selected state of the filters
267 *
268 * @return {Object} Filters selected state
269 */
270 mw.rcfilters.dm.FiltersViewModel.prototype.getSelectedState = function () {
271 var i,
272 items = this.getItems(),
273 result = {};
274
275 for ( i = 0; i < items.length; i++ ) {
276 result[ items[ i ].getName() ] = items[ i ].isSelected();
277 }
278
279 return result;
280 };
281
282 /**
283 * Get the current full state of the filters
284 *
285 * @return {Object} Filters full state
286 */
287 mw.rcfilters.dm.FiltersViewModel.prototype.getFullState = function () {
288 var i,
289 items = this.getItems(),
290 result = {};
291
292 for ( i = 0; i < items.length; i++ ) {
293 result[ items[ i ].getName() ] = {
294 selected: items[ i ].isSelected(),
295 active: items[ i ].isActive()
296 };
297 }
298
299 return result;
300 };
301
302 /**
303 * Get the default parameters object
304 *
305 * @return {Object} Default parameter values
306 */
307 mw.rcfilters.dm.FiltersViewModel.prototype.getDefaultParams = function () {
308 return this.defaultParams;
309 };
310
311 /**
312 * Set all filter states to default values
313 */
314 mw.rcfilters.dm.FiltersViewModel.prototype.setFiltersToDefaults = function () {
315 var defaultFilterStates = this.getFiltersFromParameters( this.getDefaultParams() );
316
317 this.updateFilters( defaultFilterStates );
318 };
319
320 /**
321 * Analyze the groups and their filters and output an object representing
322 * the state of the parameters they represent.
323 *
324 * @param {Object} [filterGroups] An object defining the filter groups to
325 * translate to parameters. Its structure must follow that of this.groups
326 * see #getFilterGroups
327 * @return {Object} Parameter state object
328 */
329 mw.rcfilters.dm.FiltersViewModel.prototype.getParametersFromFilters = function ( filterGroups ) {
330 var i, filterItems, anySelected, values,
331 result = {},
332 groupItems = filterGroups || this.getFilterGroups();
333
334 $.each( groupItems, function ( group, model ) {
335 filterItems = model.getItems();
336
337 if ( model.getType() === 'send_unselected_if_any' ) {
338 // First, check if any of the items are selected at all.
339 // If none is selected, we're treating it as if they are
340 // all false
341 anySelected = filterItems.some( function ( filterItem ) {
342 return filterItem.isSelected();
343 } );
344
345 // Go over the items and define the correct values
346 for ( i = 0; i < filterItems.length; i++ ) {
347 result[ filterItems[ i ].getName() ] = anySelected ?
348 Number( !filterItems[ i ].isSelected() ) : 0;
349 }
350 } else if ( model.getType() === 'string_options' ) {
351 values = [];
352 for ( i = 0; i < filterItems.length; i++ ) {
353 if ( filterItems[ i ].isSelected() ) {
354 values.push( filterItems[ i ].getName() );
355 }
356 }
357
358 if ( values.length === 0 || values.length === filterItems.length ) {
359 result[ group ] = 'all';
360 } else {
361 result[ group ] = values.join( model.getSeparator() );
362 }
363 }
364 } );
365
366 return result;
367 };
368
369 /**
370 * Sanitize value group of a string_option groups type
371 * Remove duplicates and make sure to only use valid
372 * values.
373 *
374 * @private
375 * @param {string} groupName Group name
376 * @param {string[]} valueArray Array of values
377 * @return {string[]} Array of valid values
378 */
379 mw.rcfilters.dm.FiltersViewModel.prototype.sanitizeStringOptionGroup = function( groupName, valueArray ) {
380 var result = [],
381 validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
382 return filterItem.getName();
383 } );
384
385 if ( valueArray.indexOf( 'all' ) > -1 ) {
386 // If anywhere in the values there's 'all', we
387 // treat it as if only 'all' was selected.
388 // Example: param=valid1,valid2,all
389 // Result: param=all
390 return [ 'all' ];
391 }
392
393 // Get rid of any dupe and invalid parameter, only output
394 // valid ones
395 // Example: param=valid1,valid2,invalid1,valid1
396 // Result: param=valid1,valid2
397 valueArray.forEach( function ( value ) {
398 if (
399 validNames.indexOf( value ) > -1 &&
400 result.indexOf( value ) === -1
401 ) {
402 result.push( value );
403 }
404 } );
405
406 return result;
407 };
408
409 /**
410 * Check whether the current filter state is set to all false.
411 *
412 * @return {boolean} Current filters are all empty
413 */
414 mw.rcfilters.dm.FiltersViewModel.prototype.areCurrentFiltersEmpty = function () {
415 var currFilters = this.getSelectedState();
416
417 return Object.keys( currFilters ).every( function ( filterName ) {
418 return !currFilters[ filterName ];
419 } );
420 };
421
422 /**
423 * Check whether the default values of the filters are all false.
424 *
425 * @return {boolean} Default filters are all false
426 */
427 mw.rcfilters.dm.FiltersViewModel.prototype.areDefaultFiltersEmpty = function () {
428 var defaultFilters;
429
430 if ( this.defaultFiltersEmpty !== null ) {
431 // We only need to do this test once,
432 // because defaults are set once per session
433 defaultFilters = this.getFiltersFromParameters();
434 this.defaultFiltersEmpty = Object.keys( defaultFilters ).every( function ( filterName ) {
435 return !defaultFilters[ filterName ];
436 } );
437 }
438
439 return this.defaultFiltersEmpty;
440 };
441
442 /**
443 * This is the opposite of the #getParametersFromFilters method; this goes over
444 * the given parameters and translates into a selected/unselected value in the filters.
445 *
446 * @param {Object} params Parameters query object
447 * @return {Object} Filter state object
448 */
449 mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
450 var i, filterItem,
451 groupMap = {},
452 model = this,
453 base = this.getDefaultParams(),
454 result = {};
455
456 params = $.extend( {}, base, params );
457
458 $.each( params, function ( paramName, paramValue ) {
459 // Find the filter item
460 filterItem = model.getItemByName( paramName );
461 // Ignore if no filter item exists
462 if ( filterItem ) {
463 groupMap[ filterItem.getGroup() ] = groupMap[ filterItem.getGroup() ] || {};
464
465 // Mark the group if it has any items that are selected
466 groupMap[ filterItem.getGroup() ].hasSelected = (
467 groupMap[ filterItem.getGroup() ].hasSelected ||
468 !!Number( paramValue )
469 );
470
471 // Add the relevant filter into the group map
472 groupMap[ filterItem.getGroup() ].filters = groupMap[ filterItem.getGroup() ].filters || [];
473 groupMap[ filterItem.getGroup() ].filters.push( filterItem );
474 } else if ( model.groups.hasOwnProperty( paramName ) ) {
475 // This parameter represents a group (values are the filters)
476 // this is equivalent to checking if the group is 'string_options'
477 groupMap[ paramName ] = { filters: model.groups[ paramName ].getItems() };
478 }
479 } );
480
481 // Now that we know the groups' selection states, we need to go over
482 // the filters in the groups and mark their selected states appropriately
483 $.each( groupMap, function ( group, data ) {
484 var paramValues, filterItem,
485 allItemsInGroup = data.filters;
486
487 if ( model.groups[ group ].getType() === 'send_unselected_if_any' ) {
488 for ( i = 0; i < allItemsInGroup.length; i++ ) {
489 filterItem = allItemsInGroup[ i ];
490
491 result[ filterItem.getName() ] = data.hasSelected ?
492 // Flip the definition between the parameter
493 // state and the filter state
494 // This is what the 'toggleSelected' value of the filter is
495 !Number( params[ filterItem.getName() ] ) :
496 // Otherwise, there are no selected items in the
497 // group, which means the state is false
498 false;
499 }
500 } else if ( model.groups[ group ].getType() === 'string_options' ) {
501 paramValues = model.sanitizeStringOptionGroup( group, params[ group ].split( model.groups[ group ].getSeparator() ) );
502
503 for ( i = 0; i < allItemsInGroup.length; i++ ) {
504 filterItem = allItemsInGroup[ i ];
505
506 result[ filterItem.getName() ] = (
507 // If it is the word 'all'
508 paramValues.length === 1 && paramValues[ 0 ] === 'all' ||
509 // All values are written
510 paramValues.length === model.groups[ group ].getItemCount()
511 ) ?
512 // All true (either because all values are written or the term 'all' is written)
513 // is the same as all filters set to false
514 false :
515 // Otherwise, the filter is selected only if it appears in the parameter values
516 paramValues.indexOf( filterItem.getName() ) > -1;
517 }
518 }
519 } );
520 return result;
521 };
522
523 /**
524 * Get the item that matches the given name
525 *
526 * @param {string} name Filter name
527 * @return {mw.rcfilters.dm.FilterItem} Filter item
528 */
529 mw.rcfilters.dm.FiltersViewModel.prototype.getItemByName = function ( name ) {
530 return this.getItems().filter( function ( item ) {
531 return name === item.getName();
532 } )[ 0 ];
533 };
534
535 /**
536 * Set all filters to false or empty/all
537 * This is equivalent to display all.
538 */
539 mw.rcfilters.dm.FiltersViewModel.prototype.emptyAllFilters = function () {
540 var filters = {};
541
542 this.getItems().forEach( function ( filterItem ) {
543 filters[ filterItem.getName() ] = false;
544 } );
545
546 // Update filters
547 this.updateFilters( filters );
548 };
549
550 /**
551 * Toggle selected state of items by their names
552 *
553 * @param {Object} filterDef Filter definitions
554 */
555 mw.rcfilters.dm.FiltersViewModel.prototype.updateFilters = function ( filterDef ) {
556 var name, filterItem;
557
558 for ( name in filterDef ) {
559 filterItem = this.getItemByName( name );
560 filterItem.toggleSelected( filterDef[ name ] );
561 }
562 };
563
564 /**
565 * Get a group model from its name
566 *
567 * @param {string} groupName Group name
568 * @return {mw.rcfilters.dm.FilterGroup} Group model
569 */
570 mw.rcfilters.dm.FiltersViewModel.prototype.getGroup = function ( groupName ) {
571 return this.groups[ groupName ];
572 };
573
574 /**
575 * Get all filters within a specified group by its name
576 *
577 * @param {string} groupName Group name
578 * @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
579 */
580 mw.rcfilters.dm.FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
581 return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
582 };
583
584 /**
585 * Find items whose labels match the given string
586 *
587 * @param {string} query Search string
588 * @return {Object} An object of items to show
589 * arranged by their group names
590 */
591 mw.rcfilters.dm.FiltersViewModel.prototype.findMatches = function ( query ) {
592 var i,
593 groupTitle,
594 result = {},
595 items = this.getItems();
596
597 // Normalize so we can search strings regardless of case
598 query = query.toLowerCase();
599
600 // item label starting with the query string
601 for ( i = 0; i < items.length; i++ ) {
602 if ( items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ) {
603 result[ items[ i ].getGroup() ] = result[ items[ i ].getGroup() ] || [];
604 result[ items[ i ].getGroup() ].push( items[ i ] );
605 }
606 }
607
608 if ( $.isEmptyObject( result ) ) {
609 // item containing the query string in their label, description, or group title
610 for ( i = 0; i < items.length; i++ ) {
611 groupTitle = this.getGroup( items[ i ].getGroup() ).getTitle();
612 if (
613 items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
614 items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
615 groupTitle.toLowerCase().indexOf( query ) > -1
616 ) {
617 result[ items[ i ].getGroup() ] = result[ items[ i ].getGroup() ] || [];
618 result[ items[ i ].getGroup() ].push( items[ i ] );
619 }
620 }
621 }
622
623 return result;
624 };
625
626 }( mediaWiki, jQuery ) );