Merge "RCFilters: Blur input on 'escape' key"
[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 * @inheritdoc
220 */
221 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.doInputEscape = function () {
222 // Parent
223 mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.doInputEscape.call( this );
224
225 // Blur the input
226 this.input.$input.blur();
227 };
228
229 /**
230 * @inheridoc
231 */
232 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onChangeTags = function () {
233 // Parent method
234 mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onChangeTags.call( this );
235
236 this.emptyFilterMessage.toggle( this.isEmpty() );
237 };
238
239 /**
240 * Respond to model initialize event
241 */
242 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelInitialize = function () {
243 this.populateFromModel();
244
245 this.setSavedQueryVisibility();
246 };
247
248 /**
249 * Set the visibility of the saved query button
250 */
251 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.setSavedQueryVisibility = function () {
252 if ( this.areSavedQueriesEnabled ) {
253 this.matchingQuery = this.controller.findQueryMatchingCurrentState();
254
255 this.savedQueryTitle.setLabel(
256 this.matchingQuery ? this.matchingQuery.getLabel() : ''
257 );
258 this.savedQueryTitle.toggle( !!this.matchingQuery );
259 this.saveQueryButton.toggle(
260 !this.isEmpty() &&
261 !this.matchingQuery
262 );
263 }
264 };
265 /**
266 * Respond to model itemUpdate event
267 *
268 * @param {mw.rcfilters.dm.FilterItem} item Filter item model
269 */
270 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelItemUpdate = function ( item ) {
271 if (
272 item.isSelected() ||
273 (
274 this.model.isHighlightEnabled() &&
275 item.isHighlightSupported() &&
276 item.getHighlightColor()
277 )
278 ) {
279 this.addTag( item.getName(), item.getLabel() );
280 } else {
281 this.removeTagByData( item.getName() );
282 }
283
284 this.setSavedQueryVisibility();
285
286 // Re-evaluate reset state
287 this.reevaluateResetRestoreState();
288 };
289
290 /**
291 * @inheritdoc
292 */
293 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.isAllowedData = function ( data ) {
294 return (
295 this.menu.getItemFromData( data ) &&
296 !this.isDuplicateData( data )
297 );
298 };
299
300 /**
301 * @inheritdoc
302 */
303 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onMenuChoose = function ( item ) {
304 this.controller.toggleFilterSelect( item.model.getName() );
305
306 // Select the tag if it exists, or reset selection otherwise
307 this.selectTag( this.getItemFromData( item.model.getName() ) );
308
309 this.focus();
310 };
311
312 /**
313 * Respond to highlightChange event
314 *
315 * @param {boolean} isHighlightEnabled Highlight is enabled
316 */
317 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelHighlightChange = function ( isHighlightEnabled ) {
318 var highlightedItems = this.model.getHighlightedItems();
319
320 if ( isHighlightEnabled ) {
321 // Add capsule widgets
322 highlightedItems.forEach( function ( filterItem ) {
323 this.addTag( filterItem.getName(), filterItem.getLabel() );
324 }.bind( this ) );
325 } else {
326 // Remove capsule widgets if they're not selected
327 highlightedItems.forEach( function ( filterItem ) {
328 if ( !filterItem.isSelected() ) {
329 this.removeTagByData( filterItem.getName() );
330 }
331 }.bind( this ) );
332 }
333 };
334
335 /**
336 * @inheritdoc
337 */
338 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onTagSelect = function ( tagItem ) {
339 var widget = this,
340 menuOption = this.menu.getItemFromData( tagItem.getData() ),
341 oldInputValue = this.input.getValue();
342
343 // Reset input
344 this.input.setValue( '' );
345
346 // Parent method
347 mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onTagSelect.call( this, tagItem );
348
349 this.menu.selectItem( menuOption );
350 this.selectTag( tagItem );
351
352 // Scroll to the item
353 if ( oldInputValue ) {
354 // We're binding a 'once' to the itemVisibilityChange event
355 // so this happens when the menu is ready after the items
356 // are visible again, in case this is done right after the
357 // user filtered the results
358 this.getMenu().once(
359 'itemVisibilityChange',
360 function () { widget.scrollToTop( menuOption.$element ); }
361 );
362 } else {
363 this.scrollToTop( menuOption.$element );
364 }
365 };
366
367 /**
368 * Select a tag by reference. This is what OO.ui.SelectWidget is doing.
369 * If no items are given, reset selection from all.
370 *
371 * @param {mw.rcfilters.ui.FilterTagItemWidget} [item] Tag to select,
372 * omit to deselect all
373 */
374 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.selectTag = function ( item ) {
375 var i, len, selected;
376
377 for ( i = 0, len = this.items.length; i < len; i++ ) {
378 selected = this.items[ i ] === item;
379 if ( this.items[ i ].isSelected() !== selected ) {
380 this.items[ i ].toggleSelected( selected );
381 }
382 }
383 };
384 /**
385 * @inheritdoc
386 */
387 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onTagRemove = function ( tagItem ) {
388 // Parent method
389 mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onTagRemove.call( this, tagItem );
390
391 this.controller.clearFilter( tagItem.getName() );
392
393 tagItem.destroy();
394 };
395
396 /**
397 * Respond to click event on the reset button
398 */
399 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onResetButtonClick = function () {
400 if ( this.model.areCurrentFiltersEmpty() ) {
401 // Reset to default filters
402 this.controller.resetToDefaults();
403 } else {
404 // Reset to have no filters
405 this.controller.emptyFilters();
406 }
407 };
408
409 /**
410 * Reevaluate the restore state for the widget between setting to defaults and clearing all filters
411 */
412 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.reevaluateResetRestoreState = function () {
413 var defaultsAreEmpty = this.model.areDefaultFiltersEmpty(),
414 currFiltersAreEmpty = this.model.areCurrentFiltersEmpty(),
415 hideResetButton = currFiltersAreEmpty && defaultsAreEmpty;
416
417 this.resetButton.setIcon(
418 currFiltersAreEmpty ? 'history' : 'trash'
419 );
420
421 this.resetButton.setLabel(
422 currFiltersAreEmpty ? mw.msg( 'rcfilters-restore-default-filters' ) : ''
423 );
424 this.resetButton.setTitle(
425 currFiltersAreEmpty ? null : mw.msg( 'rcfilters-clear-all-filters' )
426 );
427
428 this.resetButton.toggle( !hideResetButton );
429 this.emptyFilterMessage.toggle( currFiltersAreEmpty );
430 };
431
432 /**
433 * @inheritdoc
434 */
435 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.createMenuWidget = function ( menuConfig ) {
436 return new mw.rcfilters.ui.MenuSelectWidget(
437 this.controller,
438 this.model,
439 $.extend( {
440 filterFromInput: true
441 }, menuConfig )
442 );
443 };
444
445 /**
446 * Populate the menu from the model
447 */
448 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.populateFromModel = function () {
449 var widget = this,
450 items = [];
451
452 // Reset
453 this.getMenu().clearItems();
454
455 $.each( this.model.getFilterGroups(), function ( groupName, groupModel ) {
456 items.push(
457 // Group section
458 new mw.rcfilters.ui.FilterMenuSectionOptionWidget(
459 widget.controller,
460 groupModel,
461 {
462 $overlay: widget.$overlay
463 }
464 )
465 );
466
467 // Add items
468 widget.model.getGroupFilters( groupName ).forEach( function ( filterItem ) {
469 items.push(
470 new mw.rcfilters.ui.FilterMenuOptionWidget(
471 widget.controller,
472 filterItem,
473 {
474 $overlay: widget.$overlay
475 }
476 )
477 );
478 } );
479 } );
480
481 // Add all items to the menu
482 this.getMenu().addItems( items );
483 };
484
485 /**
486 * @inheritdoc
487 */
488 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.createTagItemWidget = function ( data ) {
489 var filterItem = this.model.getItemByName( data );
490
491 if ( filterItem ) {
492 return new mw.rcfilters.ui.FilterTagItemWidget(
493 this.controller,
494 filterItem,
495 {
496 $overlay: this.$overlay
497 }
498 );
499 }
500 };
501
502 /**
503 * Scroll the element to top within its container
504 *
505 * @private
506 * @param {jQuery} $element Element to position
507 * @param {number} [marginFromTop] When scrolling the entire widget to the top, leave this
508 * much space (in pixels) above the widget.
509 */
510 mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.scrollToTop = function ( $element, marginFromTop ) {
511 var container = OO.ui.Element.static.getClosestScrollableContainer( $element[ 0 ], 'y' ),
512 pos = OO.ui.Element.static.getRelativePosition( $element, $( container ) ),
513 containerScrollTop = $( container ).is( 'body, html' ) ? 0 : $( container ).scrollTop();
514
515 // Scroll to item
516 $( container ).animate( {
517 scrollTop: containerScrollTop + pos.top - ( marginFromTop || 0 )
518 } );
519 };
520 }( mediaWiki ) );