RCFilters: Make frontend URL follow backend rules and add 'urlversion=2'
[lhc/web/wiklou.git] / resources / src / mediawiki.rcfilters / dm / mw.rcfilters.dm.FilterGroup.js
1 ( function ( mw ) {
2 /**
3 * View model for a filter group
4 *
5 * @mixins OO.EventEmitter
6 * @mixins OO.EmitterList
7 *
8 * @constructor
9 * @param {string} name Group name
10 * @param {Object} [config] Configuration options
11 * @cfg {string} [type='send_unselected_if_any'] Group type
12 * @cfg {string} [title] Group title
13 * @cfg {string} [separator='|'] Value separator for 'string_options' groups
14 * @cfg {boolean} [active] Group is active
15 * @cfg {boolean} [fullCoverage] This filters in this group collectively cover all results
16 * @cfg {Object} [conflicts] Defines the conflicts for this filter group
17 * @cfg {Object} [whatsThis] Defines the messages that should appear for the 'what's this' popup
18 * @cfg {string} [whatsThis.header] The header of the whatsThis popup message
19 * @cfg {string} [whatsThis.body] The body of the whatsThis popup message
20 * @cfg {string} [whatsThis.url] The url for the link in the whatsThis popup message
21 * @cfg {string} [whatsThis.linkMessage] The text for the link in the whatsThis popup message
22 */
23 mw.rcfilters.dm.FilterGroup = function MwRcfiltersDmFilterGroup( name, config ) {
24 config = config || {};
25
26 // Mixin constructor
27 OO.EventEmitter.call( this );
28 OO.EmitterList.call( this );
29
30 this.name = name;
31 this.type = config.type || 'send_unselected_if_any';
32 this.title = config.title;
33 this.separator = config.separator || '|';
34
35 this.active = !!config.active;
36 this.fullCoverage = !!config.fullCoverage;
37
38 this.whatsThis = config.whatsThis || {};
39
40 this.conflicts = config.conflicts || {};
41 this.defaultParams = {};
42
43 this.aggregate( { update: 'filterItemUpdate' } );
44 this.connect( this, { filterItemUpdate: 'onFilterItemUpdate' } );
45 };
46
47 /* Initialization */
48 OO.initClass( mw.rcfilters.dm.FilterGroup );
49 OO.mixinClass( mw.rcfilters.dm.FilterGroup, OO.EventEmitter );
50 OO.mixinClass( mw.rcfilters.dm.FilterGroup, OO.EmitterList );
51
52 /* Events */
53
54 /**
55 * @event update
56 *
57 * Group state has been updated
58 */
59
60 /* Methods */
61
62 /**
63 * Initialize the group and create its filter items
64 *
65 * @param {Object} filterDefinition Filter definition for this group
66 * @param {string|Object} [groupDefault] Definition of the group default
67 */
68 mw.rcfilters.dm.FilterGroup.prototype.initializeFilters = function ( filterDefinition, groupDefault ) {
69 var supersetMap = {},
70 model = this,
71 items = [];
72
73 filterDefinition.forEach( function ( filter ) {
74 // Instantiate an item
75 var subsetNames = [],
76 filterItem = new mw.rcfilters.dm.FilterItem( filter.name, model, {
77 group: model.getName(),
78 label: filter.label ? mw.msg( filter.label ) : filter.name,
79 description: filter.description ? mw.msg( filter.description ) : '',
80 cssClass: filter.cssClass
81 } );
82
83 filter.subset = filter.subset || [];
84 filter.subset = filter.subset.map( function ( el ) {
85 return el.filter;
86 } );
87
88 if ( filter.subset ) {
89 subsetNames = [];
90 filter.subset.forEach( function ( subsetFilterName ) { // eslint-disable-line no-loop-func
91 // Subsets (unlike conflicts) are always inside the same group
92 // We can re-map the names of the filters we are getting from
93 // the subsets with the group prefix
94 var subsetName = model.getPrefixedName( subsetFilterName );
95 // For convenience, we should store each filter's "supersets" -- these are
96 // the filters that have that item in their subset list. This will just
97 // make it easier to go through whether the item has any other items
98 // that affect it (and are selected) at any given time
99 supersetMap[ subsetName ] = supersetMap[ subsetName ] || [];
100 mw.rcfilters.utils.addArrayElementsUnique(
101 supersetMap[ subsetName ],
102 filterItem.getName()
103 );
104
105 // Translate subset param name to add the group name, so we
106 // get consistent naming. We know that subsets are only within
107 // the same group
108 subsetNames.push( subsetName );
109 } );
110
111 // Set translated subset
112 filterItem.setSubset( subsetNames );
113 }
114
115 items.push( filterItem );
116
117 // Store default parameter state; in this case, default is defined per filter
118 if ( model.getType() === 'send_unselected_if_any' ) {
119 // Store the default parameter state
120 // For this group type, parameter values are direct
121 // We need to convert from a boolean to a string ('1' and '0')
122 model.defaultParams[ filter.name ] = String( Number( !!filter.default ) );
123 }
124 } );
125
126 // Add items
127 this.addItems( items );
128
129 // Now that we have all items, we can apply the superset map
130 this.getItems().forEach( function ( filterItem ) {
131 filterItem.setSuperset( supersetMap[ filterItem.getName() ] );
132 } );
133
134 // Store default parameter state; in this case, default is defined per the
135 // entire group, given by groupDefault method parameter
136 if ( this.getType() === 'string_options' ) {
137 // Store the default parameter group state
138 // For this group, the parameter is group name and value is the names
139 // of selected items
140 this.defaultParams[ this.getName() ] = mw.rcfilters.utils.normalizeParamOptions(
141 // Current values
142 groupDefault ?
143 groupDefault.split( this.getSeparator() ) :
144 [],
145 // Legal values
146 this.getItems().map( function ( item ) {
147 return item.getParamName();
148 } )
149 ).join( this.getSeparator() );
150 }
151 };
152
153 /**
154 * Respond to filterItem update event
155 *
156 * @fires update
157 */
158 mw.rcfilters.dm.FilterGroup.prototype.onFilterItemUpdate = function () {
159 // Update state
160 var active = this.areAnySelected();
161
162 if ( this.active !== active ) {
163 this.active = active;
164 this.emit( 'update' );
165 }
166 };
167
168 /**
169 * Get group active state
170 *
171 * @return {boolean} Active state
172 */
173 mw.rcfilters.dm.FilterGroup.prototype.isActive = function () {
174 return this.active;
175 };
176
177 /**
178 * Get group name
179 *
180 * @return {string} Group name
181 */
182 mw.rcfilters.dm.FilterGroup.prototype.getName = function () {
183 return this.name;
184 };
185
186 /**
187 * Get the default param state of this group
188 *
189 * @return {Object} Default param state
190 */
191 mw.rcfilters.dm.FilterGroup.prototype.getDefaultParams = function () {
192 return this.defaultParams;
193 };
194
195 /**
196 * Get the messags defining the 'whats this' popup for this group
197 *
198 * @return {Object} What's this messages
199 */
200 mw.rcfilters.dm.FilterGroup.prototype.getWhatsThis = function () {
201 return this.whatsThis;
202 };
203
204 /**
205 * Check whether this group has a 'what's this' message
206 *
207 * @return {boolean} This group has a what's this message
208 */
209 mw.rcfilters.dm.FilterGroup.prototype.hasWhatsThis = function () {
210 return !!this.whatsThis.body;
211 };
212
213 /**
214 * Get the conflicts associated with the entire group.
215 * Conflict object is set up by filter name keys and conflict
216 * definition. For example:
217 * [
218 * {
219 * filterName: {
220 * filter: filterName,
221 * group: group1
222 * }
223 * },
224 * {
225 * filterName2: {
226 * filter: filterName2,
227 * group: group2
228 * }
229 * }
230 * ]
231 * @return {Object} Conflict definition
232 */
233 mw.rcfilters.dm.FilterGroup.prototype.getConflicts = function () {
234 return this.conflicts;
235 };
236
237 /**
238 * Set conflicts for this group. See #getConflicts for the expected
239 * structure of the definition.
240 *
241 * @param {Object} conflicts Conflicts for this group
242 */
243 mw.rcfilters.dm.FilterGroup.prototype.setConflicts = function ( conflicts ) {
244 this.conflicts = conflicts;
245 };
246
247 /**
248 * Set conflicts for each filter item in the group based on the
249 * given conflict map
250 *
251 * @param {Object} conflicts Object representing the conflict map,
252 * keyed by the item name, where its value is an object for all its conflicts
253 */
254 mw.rcfilters.dm.FilterGroup.prototype.setFilterConflicts = function ( conflicts ) {
255 this.getItems().forEach( function ( filterItem ) {
256 if ( conflicts[ filterItem.getName() ] ) {
257 filterItem.setConflicts( conflicts[ filterItem.getName() ] );
258 }
259 } );
260 };
261
262 /**
263 * Check whether this item has a potential conflict with the given item
264 *
265 * This checks whether the given item is in the list of conflicts of
266 * the current item, but makes no judgment about whether the conflict
267 * is currently at play (either one of the items may not be selected)
268 *
269 * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
270 * @return {boolean} This item has a conflict with the given item
271 */
272 mw.rcfilters.dm.FilterGroup.prototype.existsInConflicts = function ( filterItem ) {
273 return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
274 };
275
276 /**
277 * Check whether there are any items selected
278 *
279 * @return {boolean} Any items in the group are selected
280 */
281 mw.rcfilters.dm.FilterGroup.prototype.areAnySelected = function () {
282 return this.getItems().some( function ( filterItem ) {
283 return filterItem.isSelected();
284 } );
285 };
286
287 /**
288 * Check whether all items selected
289 *
290 * @return {boolean} All items are selected
291 */
292 mw.rcfilters.dm.FilterGroup.prototype.areAllSelected = function () {
293 var selected = [],
294 unselected = [];
295
296 this.getItems().forEach( function ( filterItem ) {
297 if ( filterItem.isSelected() ) {
298 selected.push( filterItem );
299 } else {
300 unselected.push( filterItem );
301 }
302 } );
303
304 if ( unselected.length === 0 ) {
305 return true;
306 }
307
308 // check if every unselected is a subset of a selected
309 return unselected.every( function ( unselectedFilterItem ) {
310 return selected.some( function ( selectedFilterItem ) {
311 return selectedFilterItem.existsInSubset( unselectedFilterItem.getName() );
312 } );
313 } );
314 };
315
316 /**
317 * Get all selected items in this group
318 *
319 * @param {mw.rcfilters.dm.FilterItem} [excludeItem] Item to exclude from the list
320 * @return {mw.rcfilters.dm.FilterItem[]} Selected items
321 */
322 mw.rcfilters.dm.FilterGroup.prototype.getSelectedItems = function ( excludeItem ) {
323 var excludeName = ( excludeItem && excludeItem.getName() ) || '';
324
325 return this.getItems().filter( function ( item ) {
326 return item.getName() !== excludeName && item.isSelected();
327 } );
328 };
329
330 /**
331 * Check whether all selected items are in conflict with the given item
332 *
333 * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
334 * @return {boolean} All selected items are in conflict with this item
335 */
336 mw.rcfilters.dm.FilterGroup.prototype.areAllSelectedInConflictWith = function ( filterItem ) {
337 var selectedItems = this.getSelectedItems( filterItem );
338
339 return selectedItems.length > 0 &&
340 (
341 // The group as a whole is in conflict with this item
342 this.existsInConflicts( filterItem ) ||
343 // All selected items are in conflict individually
344 selectedItems.every( function ( selectedFilter ) {
345 return selectedFilter.existsInConflicts( filterItem );
346 } )
347 );
348 };
349
350 /**
351 * Check whether any of the selected items are in conflict with the given item
352 *
353 * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
354 * @return {boolean} Any of the selected items are in conflict with this item
355 */
356 mw.rcfilters.dm.FilterGroup.prototype.areAnySelectedInConflictWith = function ( filterItem ) {
357 var selectedItems = this.getSelectedItems( filterItem );
358
359 return selectedItems.length > 0 && (
360 // The group as a whole is in conflict with this item
361 this.existsInConflicts( filterItem ) ||
362 // Any selected items are in conflict individually
363 selectedItems.some( function ( selectedFilter ) {
364 return selectedFilter.existsInConflicts( filterItem );
365 } )
366 );
367 };
368
369 /**
370 * Get the parameter representation from this group
371 *
372 * @param {Object} [filterRepresentation] An object defining the state
373 * of the filters in this group, keyed by their name and current selected
374 * state value.
375 * @return {Object} Parameter representation
376 */
377 mw.rcfilters.dm.FilterGroup.prototype.getParamRepresentation = function ( filterRepresentation ) {
378 var values,
379 areAnySelected = false,
380 buildFromCurrentState = !filterRepresentation,
381 result = {},
382 filterParamNames = {};
383
384 filterRepresentation = filterRepresentation || {};
385
386 // Create or complete the filterRepresentation definition
387 this.getItems().forEach( function ( item ) {
388 // Map filter names to their parameter names
389 filterParamNames[ item.getName() ] = item.getParamName();
390
391 if ( buildFromCurrentState ) {
392 // This means we have not been given a filter representation
393 // so we are building one based on current state
394 filterRepresentation[ item.getName() ] = item.isSelected();
395 } else if ( !filterRepresentation[ item.getName() ] ) {
396 // We are given a filter representation, but we have to make
397 // sure that we fill in the missing filters if there are any
398 // we will assume they are all falsey
399 filterRepresentation[ item.getName() ] = false;
400 }
401
402 if ( filterRepresentation[ item.getName() ] ) {
403 areAnySelected = true;
404 }
405 } );
406
407 // Build result
408 if ( this.getType() === 'send_unselected_if_any' ) {
409 // First, check if any of the items are selected at all.
410 // If none is selected, we're treating it as if they are
411 // all false
412
413 // Go over the items and define the correct values
414 $.each( filterRepresentation, function ( name, value ) {
415 result[ filterParamNames[ name ] ] = areAnySelected ?
416 // We must store all parameter values as strings '0' or '1'
417 String( Number( !value ) ) :
418 '0';
419 } );
420 } else if ( this.getType() === 'string_options' ) {
421 values = [];
422
423 $.each( filterRepresentation, function ( name, value ) {
424 // Collect values
425 if ( value ) {
426 values.push( filterParamNames[ name ] );
427 }
428 } );
429
430 result[ this.getName() ] = ( values.length === Object.keys( filterRepresentation ).length ) ?
431 'all' : values.join( this.getSeparator() );
432 }
433
434 return result;
435 };
436
437 /**
438 * Get the filter representation this group would provide
439 * based on given parameter states.
440 *
441 * @param {Object|string} [paramRepresentation] An object defining a parameter
442 * state to translate the filter state from. If not given, an object
443 * representing all filters as falsey is returned; same as if the parameter
444 * given were an empty object, or had some of the filters missing.
445 * @return {Object} Filter representation
446 */
447 mw.rcfilters.dm.FilterGroup.prototype.getFilterRepresentation = function ( paramRepresentation ) {
448 var areAnySelected, paramValues,
449 model = this,
450 paramToFilterMap = {},
451 result = {};
452
453 if ( this.getType() === 'send_unselected_if_any' ) {
454 paramRepresentation = paramRepresentation || {};
455 // Expand param representation to include all filters in the group
456 this.getItems().forEach( function ( filterItem ) {
457 var paramName = filterItem.getParamName();
458
459 paramRepresentation[ paramName ] = paramRepresentation[ paramName ] || '0';
460 paramToFilterMap[ paramName ] = filterItem;
461
462 if ( Number( paramRepresentation[ filterItem.getParamName() ] ) ) {
463 areAnySelected = true;
464 }
465 } );
466
467 $.each( paramRepresentation, function ( paramName, paramValue ) {
468 var filterItem = paramToFilterMap[ paramName ];
469
470 result[ filterItem.getName() ] = areAnySelected ?
471 // Flip the definition between the parameter
472 // state and the filter state
473 // This is what the 'toggleSelected' value of the filter is
474 !Number( paramValue ) :
475 // Otherwise, there are no selected items in the
476 // group, which means the state is false
477 false;
478 } );
479 } else if ( this.getType() === 'string_options' ) {
480 paramRepresentation = paramRepresentation || '';
481
482 // Normalize the given parameter values
483 paramValues = mw.rcfilters.utils.normalizeParamOptions(
484 // Given
485 paramRepresentation.split(
486 this.getSeparator()
487 ),
488 // Allowed values
489 this.getItems().map( function ( filterItem ) {
490 return filterItem.getParamName();
491 } )
492 );
493 // Translate the parameter values into a filter selection state
494 this.getItems().forEach( function ( filterItem ) {
495 result[ filterItem.getName() ] = (
496 // If it is the word 'all'
497 paramValues.length === 1 && paramValues[ 0 ] === 'all' ||
498 // All values are written
499 paramValues.length === model.getItemCount()
500 ) ?
501 // All true (either because all values are written or the term 'all' is written)
502 // is the same as all filters set to true
503 true :
504 // Otherwise, the filter is selected only if it appears in the parameter values
505 paramValues.indexOf( filterItem.getParamName() ) > -1;
506 } );
507 }
508
509 // Go over result and make sure all filters are represented.
510 // If any filters are missing, they will get a falsey value
511 this.getItems().forEach( function ( filterItem ) {
512 result[ filterItem.getName() ] = !!result[ filterItem.getName() ];
513 } );
514
515 return result;
516 };
517
518 /**
519 * Get item by its parameter name
520 *
521 * @param {string} paramName Parameter name
522 * @return {mw.rcfilters.dm.FilterItem} Filter item
523 */
524 mw.rcfilters.dm.FilterGroup.prototype.getItemByParamName = function ( paramName ) {
525 return this.getItems().filter( function ( item ) {
526 return item.getParamName() === paramName;
527 } )[ 0 ];
528 };
529
530 /**
531 * Get group type
532 *
533 * @return {string} Group type
534 */
535 mw.rcfilters.dm.FilterGroup.prototype.getType = function () {
536 return this.type;
537 };
538
539 /**
540 * Get the prefix used for the filter names inside this group.
541 *
542 * @param {string} [name] Filter name to prefix
543 * @return {string} Group prefix
544 */
545 mw.rcfilters.dm.FilterGroup.prototype.getNamePrefix = function () {
546 return this.getName() + '__';
547 };
548
549 /**
550 * Get a filter name with the prefix used for the filter names inside this group.
551 *
552 * @param {string} name Filter name to prefix
553 * @return {string} Group prefix
554 */
555 mw.rcfilters.dm.FilterGroup.prototype.getPrefixedName = function ( name ) {
556 return this.getNamePrefix() + name;
557 };
558
559 /**
560 * Get group's title
561 *
562 * @return {string} Title
563 */
564 mw.rcfilters.dm.FilterGroup.prototype.getTitle = function () {
565 return this.title;
566 };
567
568 /**
569 * Get group's values separator
570 *
571 * @return {string} Values separator
572 */
573 mw.rcfilters.dm.FilterGroup.prototype.getSeparator = function () {
574 return this.separator;
575 };
576
577 /**
578 * Check whether the group is defined as full coverage
579 *
580 * @return {boolean} Group is full coverage
581 */
582 mw.rcfilters.dm.FilterGroup.prototype.isFullCoverage = function () {
583 return this.fullCoverage;
584 };
585 }( mediaWiki ) );