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