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