2 var ViewSwitchWidget
= require( './ViewSwitchWidget.js' ),
3 SaveFiltersPopupButtonWidget
= require( './SaveFiltersPopupButtonWidget.js' ),
4 MenuSelectWidget
= require( './MenuSelectWidget.js' ),
5 FilterTagItemWidget
= require( './FilterTagItemWidget.js' ),
6 FilterTagMultiselectWidget
;
9 * List displaying all filter groups
11 * @class mw.rcfilters.ui.FilterTagMultiselectWidget
12 * @extends OO.ui.MenuTagMultiselectWidget
13 * @mixins OO.ui.mixin.PendingElement
16 * @param {mw.rcfilters.Controller} controller Controller
17 * @param {mw.rcfilters.dm.FiltersViewModel} model View model
18 * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
19 * @param {Object} config Configuration object
20 * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
21 * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
22 * system. If not given, falls back to this widget's $element
23 * @cfg {boolean} [collapsed] Filter area is collapsed
25 FilterTagMultiselectWidget
= function MwRcfiltersUiFilterTagMultiselectWidget( controller
, model
, savedQueriesModel
, config
) {
27 title
= new OO
.ui
.LabelWidget( {
28 label
: mw
.msg( 'rcfilters-activefilters' ),
29 classes
: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-title' ]
31 $contentWrapper
= $( '<div>' )
32 .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper' );
34 config
= config
|| {};
36 this.controller
= controller
;
38 this.queriesModel
= savedQueriesModel
;
39 this.$overlay
= config
.$overlay
|| this.$element
;
40 this.$wrapper
= config
.$wrapper
|| this.$element
;
41 this.matchingQuery
= null;
42 this.currentView
= this.model
.getCurrentView();
43 this.collapsed
= false;
46 FilterTagMultiselectWidget
.parent
.call( this, $.extend( true, {
47 label
: mw
.msg( 'rcfilters-filterlist-title' ),
48 placeholder
: mw
.msg( 'rcfilters-empty-filter' ),
49 inputPosition
: 'outline',
50 allowArbitrary
: false,
51 allowDisplayInvalidTags
: false,
52 allowReordering
: false,
53 $overlay
: this.$overlay
,
55 // Our filtering is done through the model
56 filterFromInput
: false,
57 hideWhenOutOfView
: false,
64 // View select menu, appears on default view only
65 $element
: $( '<div>' )
66 .append( new ViewSwitchWidget( this.controller
, this.model
).$element
),
71 // Feedback footer, appears on all views
72 $element
: $( '<div>' )
74 new OO
.ui
.ButtonWidget( {
77 flags
: [ 'progressive' ],
78 label
: mw
.msg( 'rcfilters-filterlist-feedbacklink' ),
79 href
: 'https://www.mediawiki.org/wiki/Help_talk:New_filters_for_edit_review'
87 placeholder
: mw
.msg( 'rcfilters-search-placeholder' )
91 this.savedQueryTitle
= new OO
.ui
.LabelWidget( {
93 classes
: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-savedQueryTitle' ]
96 this.resetButton
= new OO
.ui
.ButtonWidget( {
98 classes
: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-resetButton' ]
101 this.hideShowButton
= new OO
.ui
.ButtonWidget( {
103 flags
: [ 'progressive' ],
104 classes
: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-hideshowButton' ]
106 this.toggleCollapsed( !!config
.collapsed
);
108 if ( !mw
.user
.isAnon() ) {
109 this.saveQueryButton
= new SaveFiltersPopupButtonWidget(
113 $overlay
: this.$overlay
117 this.saveQueryButton
.$element
.on( 'mousedown', function ( e
) {
121 this.saveQueryButton
.connect( this, {
122 click
: 'onSaveQueryButtonClick',
123 saveCurrent
: 'setSavedQueryVisibility'
125 this.queriesModel
.connect( this, {
126 itemUpdate
: 'onSavedQueriesItemUpdate',
127 initialize
: 'onSavedQueriesInitialize',
128 default: 'reevaluateResetRestoreState'
132 this.emptyFilterMessage
= new OO
.ui
.LabelWidget( {
133 label
: mw
.msg( 'rcfilters-empty-filter' ),
134 classes
: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-emptyFilters' ]
136 this.$content
.append( this.emptyFilterMessage
.$element
);
139 this.resetButton
.connect( this, { click
: 'onResetButtonClick' } );
140 this.hideShowButton
.connect( this, { click
: 'onHideShowButtonClick' } );
141 // Stop propagation for mousedown, so that the widget doesn't
142 // trigger the focus on the input and scrolls up when we click the reset button
143 this.resetButton
.$element
.on( 'mousedown', function ( e
) {
146 this.hideShowButton
.$element
.on( 'mousedown', function ( e
) {
149 this.model
.connect( this, {
150 initialize
: 'onModelInitialize',
151 update
: 'onModelUpdate',
152 searchChange
: 'onModelSearchChange',
153 itemUpdate
: 'onModelItemUpdate',
154 highlightChange
: 'onModelHighlightChange'
156 this.input
.connect( this, { change
: 'onInputChange' } );
158 // The filter list and button should appear side by side regardless of how
159 // wide the button is; the button also changes its width depending
160 // on language and its state, so the safest way to present both side
161 // by side is with a table layout
162 rcFiltersRow
= $( '<div>' )
163 .addClass( 'mw-rcfilters-ui-row' )
166 .addClass( 'mw-rcfilters-ui-cell' )
167 .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-filters' )
170 if ( !mw
.user
.isAnon() ) {
173 .addClass( 'mw-rcfilters-ui-cell' )
174 .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-save' )
175 .append( this.saveQueryButton
.$element
)
179 // Add a selector at the right of the input
180 this.viewsSelectWidget
= new OO
.ui
.ButtonSelectWidget( {
181 classes
: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select-widget' ],
183 new OO
.ui
.ButtonOptionWidget( {
187 label
: mw
.msg( 'namespaces' ),
188 title
: mw
.msg( 'rcfilters-view-namespaces-tooltip' )
190 new OO
.ui
.ButtonOptionWidget( {
194 label
: mw
.msg( 'tags-title' ),
195 title
: mw
.msg( 'rcfilters-view-tags-tooltip' )
200 // Rearrange the UI so the select widget is at the right of the input
201 this.$element
.append(
203 .addClass( 'mw-rcfilters-ui-table' )
206 .addClass( 'mw-rcfilters-ui-row' )
207 .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views' )
210 .addClass( 'mw-rcfilters-ui-cell' )
211 .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-input' )
212 .append( this.input
.$element
),
214 .addClass( 'mw-rcfilters-ui-cell' )
215 .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select' )
216 .append( this.viewsSelectWidget
.$element
)
222 this.viewsSelectWidget
.connect( this, { choose
: 'onViewsSelectWidgetChoose' } );
226 .addClass( 'mw-rcfilters-ui-cell' )
227 .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-reset' )
228 .append( this.resetButton
.$element
)
232 $contentWrapper
.append(
234 .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top' )
237 .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-title' )
238 .append( title
.$element
),
240 .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-queryName' )
241 .append( this.savedQueryTitle
.$element
),
243 .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-hideshow' )
245 this.hideShowButton
.$element
249 .addClass( 'mw-rcfilters-ui-table' )
250 .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-filters' )
251 .append( rcFiltersRow
)
255 this.$handle
.append( $contentWrapper
);
256 this.emptyFilterMessage
.toggle( this.isEmpty() );
257 this.savedQueryTitle
.toggle( false );
260 .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget' );
262 this.reevaluateResetRestoreState();
267 OO
.inheritClass( FilterTagMultiselectWidget
, OO
.ui
.MenuTagMultiselectWidget
);
272 * Override parent method to avoid unnecessary resize events.
274 FilterTagMultiselectWidget
.prototype.updateIfHeightChanged = function () { };
277 * Respond to view select widget choose event
279 * @param {OO.ui.ButtonOptionWidget} buttonOptionWidget Chosen widget
281 FilterTagMultiselectWidget
.prototype.onViewsSelectWidgetChoose = function ( buttonOptionWidget
) {
282 this.controller
.switchView( buttonOptionWidget
.getData() );
283 this.viewsSelectWidget
.selectItem( null );
288 * Respond to model search change event
290 * @param {string} value Search value
292 FilterTagMultiselectWidget
.prototype.onModelSearchChange = function ( value
) {
293 this.input
.setValue( value
);
297 * Respond to input change event
299 * @param {string} value Value of the input
301 FilterTagMultiselectWidget
.prototype.onInputChange = function ( value
) {
302 this.controller
.setSearch( value
);
306 * Respond to query button click
308 FilterTagMultiselectWidget
.prototype.onSaveQueryButtonClick = function () {
309 this.getMenu().toggle( false );
313 * Respond to save query model initialization
315 FilterTagMultiselectWidget
.prototype.onSavedQueriesInitialize = function () {
316 this.setSavedQueryVisibility();
320 * Respond to save query item change. Mainly this is done to update the label in case
321 * a query item has been edited
323 * @param {mw.rcfilters.dm.SavedQueryItemModel} item Saved query item
325 FilterTagMultiselectWidget
.prototype.onSavedQueriesItemUpdate = function ( item
) {
326 if ( this.matchingQuery
=== item
) {
327 // This means we just edited the item that is currently matched
328 this.savedQueryTitle
.setLabel( item
.getLabel() );
333 * Respond to menu toggle
335 * @param {boolean} isVisible Menu is visible
337 FilterTagMultiselectWidget
.prototype.onMenuToggle = function ( isVisible
) {
339 FilterTagMultiselectWidget
.parent
.prototype.onMenuToggle
.call( this );
344 mw
.hook( 'RcFilters.popup.open' ).fire();
346 if ( !this.getMenu().findSelectedItem() ) {
347 // If there are no selected items, scroll menu to top
348 // This has to be in a setTimeout so the menu has time
349 // to be positioned and fixed
352 this.getMenu().scrollToTop();
358 this.selectTag( null );
361 this.controller
.setSearch( '' );
363 // Log filter grouping
364 this.controller
.trackFilterGroupings( 'filtermenu' );
369 this.input
.setIcon( isVisible
? 'search' : 'menu' );
375 FilterTagMultiselectWidget
.prototype.onInputFocus = function () {
377 FilterTagMultiselectWidget
.parent
.prototype.onInputFocus
.call( this );
379 // Only scroll to top of the viewport if:
380 // - The widget is more than 20px from the top
381 // - The widget is not above the top of the viewport (do not scroll downwards)
382 // (This isn't represented because >20 is, anyways and always, bigger than 0)
383 this.scrollToTop( this.$element
, 0, { min
: 20, max
: Infinity
} );
389 FilterTagMultiselectWidget
.prototype.doInputEscape = function () {
391 FilterTagMultiselectWidget
.parent
.prototype.doInputEscape
.call( this );
394 this.input
.$input
.trigger( 'blur' );
400 FilterTagMultiselectWidget
.prototype.onMouseDown = function ( e
) {
401 if ( !this.collapsed
&& !this.isDisabled() && e
.which
=== OO
.ui
.MouseButtons
.LEFT
) {
411 FilterTagMultiselectWidget
.prototype.onChangeTags = function () {
412 // If initialized, call parent method.
413 if ( this.controller
.isInitialized() ) {
414 FilterTagMultiselectWidget
.parent
.prototype.onChangeTags
.call( this );
417 this.emptyFilterMessage
.toggle( this.isEmpty() );
421 * Respond to model initialize event
423 FilterTagMultiselectWidget
.prototype.onModelInitialize = function () {
424 this.setSavedQueryVisibility();
428 * Respond to model update event
430 FilterTagMultiselectWidget
.prototype.onModelUpdate = function () {
431 this.updateElementsForView();
435 * Update the elements in the widget to the current view
437 FilterTagMultiselectWidget
.prototype.updateElementsForView = function () {
438 var view
= this.model
.getCurrentView(),
439 inputValue
= this.input
.getValue().trim(),
440 inputView
= this.model
.getViewByTrigger( inputValue
.substr( 0, 1 ) );
442 if ( inputView
!== 'default' ) {
443 // We have a prefix already, remove it
444 inputValue
= inputValue
.substr( 1 );
447 if ( inputView
!== view
) {
448 // Add the correct prefix
449 inputValue
= this.model
.getViewTrigger( view
) + inputValue
;
453 this.input
.setValue( inputValue
);
455 if ( this.currentView
!== view
) {
456 this.scrollToTop( this.$element
);
457 this.currentView
= view
;
462 * Set the visibility of the saved query button
464 FilterTagMultiselectWidget
.prototype.setSavedQueryVisibility = function () {
465 if ( mw
.user
.isAnon() ) {
469 this.matchingQuery
= this.controller
.findQueryMatchingCurrentState();
471 this.savedQueryTitle
.setLabel(
472 this.matchingQuery
? this.matchingQuery
.getLabel() : ''
474 this.savedQueryTitle
.toggle( !!this.matchingQuery
);
475 this.saveQueryButton
.setDisabled( !!this.matchingQuery
);
476 this.saveQueryButton
.setTitle( !this.matchingQuery
?
477 mw
.msg( 'rcfilters-savedqueries-add-new-title' ) :
478 mw
.msg( 'rcfilters-savedqueries-already-saved' ) );
480 if ( this.matchingQuery
) {
486 * Respond to model itemUpdate event
487 * fixme: when a new state is applied to the model this function is called 60+ times in a row
489 * @param {mw.rcfilters.dm.FilterItem} item Filter item model
491 FilterTagMultiselectWidget
.prototype.onModelItemUpdate = function ( item
) {
492 if ( !item
.getGroupModel().isHidden() ) {
496 this.model
.isHighlightEnabled() &&
497 item
.getHighlightColor()
500 this.addTag( item
.getName(), item
.getLabel() );
502 // Only attempt to remove the tag if we can find an item for it (T198140, T198231)
503 if ( this.findItemFromData( item
.getName() ) !== null ) {
504 this.removeTagByData( item
.getName() );
509 this.setSavedQueryVisibility();
511 // Re-evaluate reset state
512 this.reevaluateResetRestoreState();
518 FilterTagMultiselectWidget
.prototype.isAllowedData = function ( data
) {
520 this.model
.getItemByName( data
) &&
521 !this.isDuplicateData( data
)
528 FilterTagMultiselectWidget
.prototype.onMenuChoose = function ( item
) {
529 this.controller
.toggleFilterSelect( item
.model
.getName() );
531 // Select the tag if it exists, or reset selection otherwise
532 this.selectTag( this.findItemFromData( item
.model
.getName() ) );
538 * Respond to highlightChange event
540 * @param {boolean} isHighlightEnabled Highlight is enabled
542 FilterTagMultiselectWidget
.prototype.onModelHighlightChange = function ( isHighlightEnabled
) {
543 var highlightedItems
= this.model
.getHighlightedItems();
545 if ( isHighlightEnabled
) {
546 // Add capsule widgets
547 highlightedItems
.forEach( function ( filterItem
) {
548 this.addTag( filterItem
.getName(), filterItem
.getLabel() );
551 // Remove capsule widgets if they're not selected
552 highlightedItems
.forEach( function ( filterItem
) {
553 if ( !filterItem
.isSelected() ) {
554 // Only attempt to remove the tag if we can find an item for it (T198140, T198231)
555 if ( this.findItemFromData( filterItem
.getName() ) !== null ) {
556 this.removeTagByData( filterItem
.getName() );
562 this.setSavedQueryVisibility();
568 FilterTagMultiselectWidget
.prototype.onTagSelect = function ( tagItem
) {
569 var menuOption
= this.menu
.getItemFromModel( tagItem
.getModel() );
571 this.menu
.setUserSelecting( true );
573 FilterTagMultiselectWidget
.parent
.prototype.onTagSelect
.call( this, tagItem
);
576 this.controller
.resetSearchForView( tagItem
.getView() );
578 this.selectTag( tagItem
);
579 this.scrollToTop( menuOption
.$element
);
581 this.menu
.setUserSelecting( false );
585 * Select a tag by reference. This is what OO.ui.SelectWidget is doing.
586 * If no items are given, reset selection from all.
588 * @param {mw.rcfilters.ui.FilterTagItemWidget} [item] Tag to select,
589 * omit to deselect all
591 FilterTagMultiselectWidget
.prototype.selectTag = function ( item
) {
592 var i
, len
, selected
;
594 for ( i
= 0, len
= this.items
.length
; i
< len
; i
++ ) {
595 selected
= this.items
[ i
] === item
;
596 if ( this.items
[ i
].isSelected() !== selected
) {
597 this.items
[ i
].toggleSelected( selected
);
604 FilterTagMultiselectWidget
.prototype.onTagRemove = function ( tagItem
) {
606 FilterTagMultiselectWidget
.parent
.prototype.onTagRemove
.call( this, tagItem
);
608 this.controller
.clearFilter( tagItem
.getName() );
614 * Respond to click event on the reset button
616 FilterTagMultiselectWidget
.prototype.onResetButtonClick = function () {
617 if ( this.model
.areVisibleFiltersEmpty() ) {
618 // Reset to default filters
619 this.controller
.resetToDefaults();
621 // Reset to have no filters
622 this.controller
.emptyFilters();
627 * Respond to hide/show button click
629 FilterTagMultiselectWidget
.prototype.onHideShowButtonClick = function () {
630 this.toggleCollapsed();
634 * Toggle the collapsed state of the filters widget
636 * @param {boolean} isCollapsed Widget is collapsed
638 FilterTagMultiselectWidget
.prototype.toggleCollapsed = function ( isCollapsed
) {
639 isCollapsed
= isCollapsed
=== undefined ? !this.collapsed
: !!isCollapsed
;
641 this.collapsed
= isCollapsed
;
644 // If we are collapsing, close the menu, in case it was open
645 // We should make sure the menu closes before the rest of the elements
646 // are hidden, otherwise there is an unknown error in jQuery as ooui
647 // sets and unsets properties on the input (which is hidden at that point)
648 this.menu
.toggle( false );
650 this.input
.setDisabled( isCollapsed
);
651 this.hideShowButton
.setLabel( mw
.msg(
652 isCollapsed
? 'rcfilters-activefilters-show' : 'rcfilters-activefilters-hide'
654 this.hideShowButton
.setTitle( mw
.msg(
655 isCollapsed
? 'rcfilters-activefilters-show-tooltip' : 'rcfilters-activefilters-hide-tooltip'
658 // Toggle the wrapper class, so we have min height values correctly throughout
659 this.$wrapper
.toggleClass( 'mw-rcfilters-collapsed', isCollapsed
);
662 this.controller
.updateCollapsedState( isCollapsed
);
666 * Reevaluate the restore state for the widget between setting to defaults and clearing all filters
668 FilterTagMultiselectWidget
.prototype.reevaluateResetRestoreState = function () {
669 var defaultsAreEmpty
= this.controller
.areDefaultsEmpty(),
670 currFiltersAreEmpty
= this.model
.areVisibleFiltersEmpty(),
671 hideResetButton
= currFiltersAreEmpty
&& defaultsAreEmpty
;
673 this.resetButton
.setIcon(
674 currFiltersAreEmpty
? 'history' : 'trash'
677 this.resetButton
.setLabel(
678 currFiltersAreEmpty
? mw
.msg( 'rcfilters-restore-default-filters' ) : ''
680 this.resetButton
.setTitle(
681 currFiltersAreEmpty
? null : mw
.msg( 'rcfilters-clear-all-filters' )
684 this.resetButton
.toggle( !hideResetButton
);
685 this.emptyFilterMessage
.toggle( currFiltersAreEmpty
);
691 FilterTagMultiselectWidget
.prototype.createMenuWidget = function ( menuConfig
) {
692 return new MenuSelectWidget(
702 FilterTagMultiselectWidget
.prototype.createTagItemWidget = function ( data
) {
703 var filterItem
= this.model
.getItemByName( data
);
706 return new FilterTagItemWidget(
709 this.model
.getInvertModel(),
712 $overlay
: this.$overlay
718 FilterTagMultiselectWidget
.prototype.emphasize = function () {
720 !this.$handle
.hasClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' )
723 .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' )
724 .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
726 setTimeout( function () {
728 .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' );
730 setTimeout( function () {
732 .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
733 }.bind( this ), 1000 );
734 }.bind( this ), 500 );
739 * Scroll the element to top within its container
742 * @param {jQuery} $element Element to position
743 * @param {number} [marginFromTop=0] When scrolling the entire widget to the top, leave this
744 * much space (in pixels) above the widget.
745 * @param {Object} [threshold] Minimum distance from the top of the element to scroll at all
746 * @param {number} [threshold.min] Minimum distance above the element
747 * @param {number} [threshold.max] Minimum distance below the element
749 FilterTagMultiselectWidget
.prototype.scrollToTop = function ( $element
, marginFromTop
, threshold
) {
750 var container
= OO
.ui
.Element
.static.getClosestScrollableContainer( $element
[ 0 ], 'y' ),
751 pos
= OO
.ui
.Element
.static.getRelativePosition( $element
, $( container
) ),
752 containerScrollTop
= $( container
).scrollTop(),
753 effectiveScrollTop
= $( container
).is( 'body, html' ) ? 0 : containerScrollTop
,
754 newScrollTop
= effectiveScrollTop
+ pos
.top
- ( marginFromTop
|| 0 );
758 threshold
=== undefined ||
761 threshold
.min
=== undefined ||
762 newScrollTop
- containerScrollTop
>= threshold
.min
765 threshold
.max
=== undefined ||
766 newScrollTop
- containerScrollTop
<= threshold
.max
770 $( container
).animate( {
771 scrollTop
: newScrollTop
776 module
.exports
= FilterTagMultiselectWidget
;