Merge "Match Parsoid's attribute sanitization for video elements"
[lhc/web/wiklou.git] / resources / src / mediawiki.rcfilters / ui / mw.rcfilters.ui.FilterTagMultiselectWidget.js
1 ( function ( mw ) {
2 /**
3 * List displaying all filter groups
4 *
5 * @extends OO.ui.MenuTagMultiselectWidget
6 * @mixins OO.ui.mixin.PendingElement
7 *
8 * @constructor
9 * @param {mw.rcfilters.Controller} controller Controller
10 * @param {mw.rcfilters.dm.FiltersViewModel} model View model
11 * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
12 * @param {Object} config Configuration object
13 * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
14 */
15 mw.rcfilters.ui.FilterTagMultiselectWidget = function MwRcfiltersUiFilterTagMultiselectWidget( controller, model, savedQueriesModel, config ) {
16 var rcFiltersRow,
17 areSavedQueriesEnabled = mw.config.get( 'wgStructuredChangeFiltersEnableSaving' ),
18 title = new OO.ui.LabelWidget( {
19 label: mw.msg( 'rcfilters-activefilters' ),
20 classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-title' ]
21 } ),
22 $contentWrapper = $( '<div>' )
23 .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper' );
24
25 config = config || {};
26
27 this.controller = controller;
28 this.model = model;
29 this.queriesModel = savedQueriesModel;
30 this.$overlay = config.$overlay || this.$element;
31 this.matchingQuery = null;
32 this.areSavedQueriesEnabled = areSavedQueriesEnabled;
33
34 // Parent
35 mw.rcfilters.ui.FilterTagMultiselectWidget.parent.call( this, $.extend( true, {
36 label: mw.msg( 'rcfilters-filterlist-title' ),
37 placeholder: mw.msg( 'rcfilters-empty-filter' ),
38 inputPosition: 'outline',
39 allowArbitrary: false,
40 allowDisplayInvalidTags: false,
41 allowReordering: false,
42 $overlay: this.$overlay,
43 menu: {
44 hideWhenOutOfView: false,
45 hideOnChoose: false,
46 width: 650,
47 $footer: $( '<div>' )
48 .append(
49 new OO.ui.ButtonWidget( {
50 framed: false,
51 icon: 'feedback',
52 flags: [ 'progressive' ],
53 label: mw.msg( 'rcfilters-filterlist-feedbacklink' ),
54 href: 'https://www.mediawiki.org/wiki/Help_talk:New_filters_for_edit_review'
55 } ).$element
56 )
57 },
58 input: {
59 icon: 'search',
60 placeholder: mw.msg( 'rcfilters-search-placeholder' )
61 }
62 }, config ) );
63
64 this.savedQueryTitle = new OO.ui.LabelWidget( {
65 label: '',
66 classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-savedQueryTitle' ]
67 } );
68
69 this.resetButton = new OO.ui.ButtonWidget( {
70 framed: false,
71 classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-resetButton' ]
72 } );
73
74 if ( areSavedQueriesEnabled ) {
75 this.saveQueryButton = new mw.rcfilters.ui.SaveFiltersPopupButtonWidget(
76 this.controller,
77 this.queriesModel
78 );
79
80 this.saveQueryButton.$element.on( 'mousedown', function ( e ) { e.stopPropagation(); } );
81
82 this.saveQueryButton.connect( this, {
83 click: 'onSaveQueryButtonClick',
84 saveCurrent: 'setSavedQueryVisibility'
85 } );
86 }
87
88 this.emptyFilterMessage = new OO.ui.LabelWidget( {
89 label: mw.msg( 'rcfilters-empty-filter' ),
90 classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-emptyFilters' ]
91 } );
92 this.$content.append( this.emptyFilterMessage.$element );
93
94 // Events
95 this.resetButton.connect( this, { click: 'onResetButtonClick' } );
96 // Stop propagation for mousedown, so that the widget doesn't
97 // trigger the focus on the input and scrolls up when we click the reset button
98 this.resetButton.$element.on( 'mousedown', function ( e ) { e.stopPropagation(); } );
99 this.model.connect( this, {
100 initialize: 'onModelInitialize',
101 itemUpdate: 'onModelItemUpdate',
102 highlightChange: 'onModelHighlightChange'
103 } );
104 this.queriesModel.connect( this, { itemUpdate: 'onSavedQueriesItemUpdate' } );
105
106 // The filter list and button should appear side by side regardless of how
107 // wide the button is; the button also changes its width depending
108 // on language and its state, so the safest way to present both side
109 // by side is with a table layout
110 rcFiltersRow = $( '<div>' )
111 .addClass( 'mw-rcfilters-ui-row' )
112 .append(
113 this.$content
114 .addClass( 'mw-rcfilters-ui-cell' )
115 .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-filters' )
116 );
117
118 if ( areSavedQueriesEnabled ) {
119 rcFiltersRow.append(
120 $( '<div>' )
121 .addClass( 'mw-rcfilters-ui-cell' )
122 .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-save' )
123 .append( this.saveQueryButton.$element )
124 );
125 }
126
127 rcFiltersRow.append(
128 $( '<div>' )
129 .addClass( 'mw-rcfilters-ui-cell' )
130 .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-reset' )
131 .append( this.resetButton.$element )
132 );
133
134 // Build the content
135 $contentWrapper.append(
136 title.$element,
137 this.savedQueryTitle.$element,
138 $( '<div>' )
139 .addClass( 'mw-rcfilters-ui-table' )
140 .append(
141 rcFiltersRow
142 )
143 );
144
145 // Initialize
146 this.$handle.append( $contentWrapper );
147 this.emptyFilterMessage.toggle( this.isEmpty() );
148 this.savedQueryTitle.toggle( false );
149
150 this.$element
151 .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget' );
152
153 this.populateFromModel();
154 this.reevaluateResetRestoreState();
155 };
156
157 /* Initialization */
158
159 OO.inheritClass( mw.rcfilters.ui.FilterTagMultiselectWidget, OO.ui.MenuTagMultiselectWidget );
160
161 /* Methods */
162
163 /**
164 * Respond to query button click
165 */
166 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onSaveQueryButtonClick = function () {
167 this.getMenu().toggle( false );
168 };
169
170 /**
171 * Respond to save query item change. Mainly this is done to update the label in case
172 * a query item has been edited
173 *
174 * @param {mw.rcfilters.dm.SavedQueryItemModel} item Saved query item
175 */
176 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onSavedQueriesItemUpdate = function ( item ) {
177 if ( this.matchingQuery === item ) {
178 // This means we just edited the item that is currently matched
179 this.savedQueryTitle.setLabel( item.getLabel() );
180 }
181 };
182
183 /**
184 * Respond to menu toggle
185 *
186 * @param {boolean} isVisible Menu is visible
187 */
188 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onMenuToggle = function ( isVisible ) {
189 // Parent
190 mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onMenuToggle.call( this );
191
192 if ( isVisible ) {
193 mw.hook( 'RcFilters.popup.open' ).fire();
194
195 if ( !this.getMenu().getSelectedItem() ) {
196 // If there are no selected items, scroll menu to top
197 // This has to be in a setTimeout so the menu has time
198 // to be positioned and fixed
199 setTimeout( function () { this.getMenu().scrollToTop(); }.bind( this ), 0 );
200 }
201 } else {
202 // Clear selection
203 this.selectTag( null );
204 }
205 };
206
207 /**
208 * @inheritdoc
209 */
210 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onInputFocus = function () {
211 // Parent
212 mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onInputFocus.call( this );
213
214 // Scroll to top
215 this.scrollToTop( this.$element );
216 };
217
218 /**
219 * @inheridoc
220 */
221 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onChangeTags = function () {
222 // Parent method
223 mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onChangeTags.call( this );
224
225 this.emptyFilterMessage.toggle( this.isEmpty() );
226 };
227
228 /**
229 * Respond to model initialize event
230 */
231 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelInitialize = function () {
232 this.populateFromModel();
233
234 this.setSavedQueryVisibility();
235 };
236
237 /**
238 * Set the visibility of the saved query button
239 */
240 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.setSavedQueryVisibility = function () {
241 if ( this.areSavedQueriesEnabled ) {
242 this.matchingQuery = this.controller.findQueryMatchingCurrentState();
243
244 this.savedQueryTitle.setLabel(
245 this.matchingQuery ? this.matchingQuery.getLabel() : ''
246 );
247 this.savedQueryTitle.toggle( !!this.matchingQuery );
248 this.saveQueryButton.toggle(
249 !this.isEmpty() &&
250 !this.matchingQuery
251 );
252 }
253 };
254 /**
255 * Respond to model itemUpdate event
256 *
257 * @param {mw.rcfilters.dm.FilterItem} item Filter item model
258 */
259 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelItemUpdate = function ( item ) {
260 if (
261 item.isSelected() ||
262 (
263 this.model.isHighlightEnabled() &&
264 item.isHighlightSupported() &&
265 item.getHighlightColor()
266 )
267 ) {
268 this.addTag( item.getName(), item.getLabel() );
269 } else {
270 this.removeTagByData( item.getName() );
271 }
272
273 this.setSavedQueryVisibility();
274
275 // Re-evaluate reset state
276 this.reevaluateResetRestoreState();
277 };
278
279 /**
280 * @inheritdoc
281 */
282 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.isAllowedData = function ( data ) {
283 return (
284 this.menu.getItemFromData( data ) &&
285 !this.isDuplicateData( data )
286 );
287 };
288
289 /**
290 * @inheritdoc
291 */
292 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onMenuChoose = function ( item ) {
293 this.controller.toggleFilterSelect( item.model.getName() );
294
295 // Select the tag if it exists, or reset selection otherwise
296 this.selectTag( this.getItemFromData( item.model.getName() ) );
297
298 this.focus();
299 };
300
301 /**
302 * Respond to highlightChange event
303 *
304 * @param {boolean} isHighlightEnabled Highlight is enabled
305 */
306 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelHighlightChange = function ( isHighlightEnabled ) {
307 var highlightedItems = this.model.getHighlightedItems();
308
309 if ( isHighlightEnabled ) {
310 // Add capsule widgets
311 highlightedItems.forEach( function ( filterItem ) {
312 this.addTag( filterItem.getName(), filterItem.getLabel() );
313 }.bind( this ) );
314 } else {
315 // Remove capsule widgets if they're not selected
316 highlightedItems.forEach( function ( filterItem ) {
317 if ( !filterItem.isSelected() ) {
318 this.removeTagByData( filterItem.getName() );
319 }
320 }.bind( this ) );
321 }
322 };
323
324 /**
325 * @inheritdoc
326 */
327 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onTagSelect = function ( tagItem ) {
328 var widget = this,
329 menuOption = this.menu.getItemFromData( tagItem.getData() ),
330 oldInputValue = this.input.getValue();
331
332 // Reset input
333 this.input.setValue( '' );
334
335 // Parent method
336 mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onTagSelect.call( this, tagItem );
337
338 this.menu.selectItem( menuOption );
339 this.selectTag( tagItem );
340
341 // Scroll to the item
342 if ( oldInputValue ) {
343 // We're binding a 'once' to the itemVisibilityChange event
344 // so this happens when the menu is ready after the items
345 // are visible again, in case this is done right after the
346 // user filtered the results
347 this.getMenu().once(
348 'itemVisibilityChange',
349 function () { widget.scrollToTop( menuOption.$element ); }
350 );
351 } else {
352 this.scrollToTop( menuOption.$element );
353 }
354 };
355
356 /**
357 * Select a tag by reference. This is what OO.ui.SelectWidget is doing.
358 * If no items are given, reset selection from all.
359 *
360 * @param {mw.rcfilters.ui.FilterTagItemWidget} [item] Tag to select,
361 * omit to deselect all
362 */
363 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.selectTag = function ( item ) {
364 var i, len, selected;
365
366 for ( i = 0, len = this.items.length; i < len; i++ ) {
367 selected = this.items[ i ] === item;
368 if ( this.items[ i ].isSelected() !== selected ) {
369 this.items[ i ].toggleSelected( selected );
370 }
371 }
372 };
373 /**
374 * @inheritdoc
375 */
376 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onTagRemove = function ( tagItem ) {
377 // Parent method
378 mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onTagRemove.call( this, tagItem );
379
380 this.controller.clearFilter( tagItem.getName() );
381
382 tagItem.destroy();
383 };
384
385 /**
386 * Respond to click event on the reset button
387 */
388 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onResetButtonClick = function () {
389 if ( this.model.areCurrentFiltersEmpty() ) {
390 // Reset to default filters
391 this.controller.resetToDefaults();
392 } else {
393 // Reset to have no filters
394 this.controller.emptyFilters();
395 }
396 };
397
398 /**
399 * Reevaluate the restore state for the widget between setting to defaults and clearing all filters
400 */
401 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.reevaluateResetRestoreState = function () {
402 var defaultsAreEmpty = this.model.areDefaultFiltersEmpty(),
403 currFiltersAreEmpty = this.model.areCurrentFiltersEmpty(),
404 hideResetButton = currFiltersAreEmpty && defaultsAreEmpty;
405
406 this.resetButton.setIcon(
407 currFiltersAreEmpty ? 'history' : 'trash'
408 );
409
410 this.resetButton.setLabel(
411 currFiltersAreEmpty ? mw.msg( 'rcfilters-restore-default-filters' ) : ''
412 );
413 this.resetButton.setTitle(
414 currFiltersAreEmpty ? null : mw.msg( 'rcfilters-clear-all-filters' )
415 );
416
417 this.resetButton.toggle( !hideResetButton );
418 this.emptyFilterMessage.toggle( currFiltersAreEmpty );
419 };
420
421 /**
422 * @inheritdoc
423 */
424 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.createMenuWidget = function ( menuConfig ) {
425 return new mw.rcfilters.ui.FloatingMenuSelectWidget(
426 this.controller,
427 this.model,
428 $.extend( {
429 filterFromInput: true
430 }, menuConfig )
431 );
432 };
433
434 /**
435 * Populate the menu from the model
436 */
437 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.populateFromModel = function () {
438 var widget = this,
439 items = [];
440
441 // Reset
442 this.getMenu().clearItems();
443
444 $.each( this.model.getFilterGroups(), function ( groupName, groupModel ) {
445 items.push(
446 // Group section
447 new mw.rcfilters.ui.FilterMenuSectionOptionWidget(
448 widget.controller,
449 groupModel,
450 {
451 $overlay: widget.$overlay
452 }
453 )
454 );
455
456 // Add items
457 widget.model.getGroupFilters( groupName ).forEach( function ( filterItem ) {
458 items.push(
459 new mw.rcfilters.ui.FilterMenuOptionWidget(
460 widget.controller,
461 filterItem,
462 {
463 $overlay: widget.$overlay
464 }
465 )
466 );
467 } );
468 } );
469
470 // Add all items to the menu
471 this.getMenu().addItems( items );
472 };
473
474 /**
475 * @inheritdoc
476 */
477 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.createTagItemWidget = function ( data ) {
478 var filterItem = this.model.getItemByName( data );
479
480 if ( filterItem ) {
481 return new mw.rcfilters.ui.FilterTagItemWidget(
482 this.controller,
483 filterItem,
484 {
485 $overlay: this.$overlay
486 }
487 );
488 }
489 };
490
491 /**
492 * Scroll the element to top within its container
493 *
494 * @private
495 * @param {jQuery} $element Element to position
496 * @param {number} [marginFromTop] When scrolling the entire widget to the top, leave this
497 * much space (in pixels) above the widget.
498 */
499 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.scrollToTop = function ( $element, marginFromTop ) {
500 var container = OO.ui.Element.static.getClosestScrollableContainer( $element[ 0 ], 'y' ),
501 pos = OO.ui.Element.static.getRelativePosition( $element, $( container ) ),
502 containerScrollTop = $( container ).is( 'body, html' ) ? 0 : $( container ).scrollTop();
503
504 // Scroll to item
505 $( container ).animate( {
506 scrollTop: containerScrollTop + pos.top - ( marginFromTop || 0 )
507 } );
508 };
509 }( mediaWiki ) );