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