From 8936e6455a53bd3fd99d51a38d6cb2445eca5cfe Mon Sep 17 00:00:00 2001 From: Moriel Schottlender Date: Tue, 25 Apr 2017 16:59:50 -0700 Subject: [PATCH] RCFilters UI: Add a 'saved queries' quick filters feature Bug: T151994 Bug: T164128 Change-Id: I5cede87633147736d3b4ee5b8ea178ae21bd441f --- includes/Preferences.php | 3 + languages/i18n/en.json | 10 + languages/i18n/qqq.json | 10 + resources/Resources.php | 18 + .../dm/mw.rcfilters.dm.FiltersViewModel.js | 26 +- .../dm/mw.rcfilters.dm.SavedQueriesModel.js | 192 ++++++ .../dm/mw.rcfilters.dm.SavedQueryItemModel.js | 115 ++++ .../src/mediawiki.rcfilters/images/clip.svg | 4 + .../mediawiki.rcfilters/images/pushPin.svg | 4 + .../src/mediawiki.rcfilters/images/unClip.svg | 4 + .../mw.rcfilters.Controller.js | 587 +++++++++++++----- .../mediawiki.rcfilters/mw.rcfilters.init.js | 5 +- ...filters.ui.FilterTagMultiselectWidget.less | 13 +- ...lters.ui.SaveFiltersPopupButtonWidget.less | 22 + ...rcfilters.ui.SavedLinksListItemWidget.less | 51 ++ .../mw.rcfilters.ui.SavedLinksListWidget.less | 7 + .../styles/mw.rcfilters.ui.less | 16 + ...rcfilters.ui.FilterTagMultiselectWidget.js | 51 +- .../ui/mw.rcfilters.ui.FilterWrapperWidget.js | 16 +- ...filters.ui.SaveFiltersPopupButtonWidget.js | 159 +++++ ...w.rcfilters.ui.SavedLinksListItemWidget.js | 290 +++++++++ .../mw.rcfilters.ui.SavedLinksListWidget.js | 137 ++++ .../dm.FiltersViewModel.test.js | 87 --- 23 files changed, 1581 insertions(+), 246 deletions(-) create mode 100644 resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueriesModel.js create mode 100644 resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueryItemModel.js create mode 100644 resources/src/mediawiki.rcfilters/images/clip.svg create mode 100644 resources/src/mediawiki.rcfilters/images/pushPin.svg create mode 100644 resources/src/mediawiki.rcfilters/images/unClip.svg create mode 100644 resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.less create mode 100644 resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SavedLinksListItemWidget.less create mode 100644 resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SavedLinksListWidget.less create mode 100644 resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.js create mode 100644 resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListItemWidget.js create mode 100644 resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListWidget.js diff --git a/includes/Preferences.php b/includes/Preferences.php index b428e87be4..40176197b5 100644 --- a/includes/Preferences.php +++ b/includes/Preferences.php @@ -915,6 +915,9 @@ class Preferences { 'label-message' => 'tog-hideminor', 'section' => 'rc/advancedrc', ]; + $defaultPreferences['rcfilters-saved-queries'] = [ + 'type' => 'api', + ]; if ( $config->get( 'RCWatchCategoryMembership' ) ) { $defaultPreferences['hidecategorization'] = [ diff --git a/languages/i18n/en.json b/languages/i18n/en.json index a704d39cb4..b3e48af56d 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -1365,6 +1365,16 @@ "recentchanges-legend-plusminus": "(±123)", "recentchanges-submit": "Show", "rcfilters-activefilters": "Active filters", + "rcfilters-quickfilters": "Quick links", + "rcfilters-savedqueries-defaultlabel": "Saved filters", + "rcfilters-savedqueries-rename": "Rename", + "rcfilters-savedqueries-setdefault": "Set as default", + "rcfilters-savedqueries-unsetdefault": "Unset as default", + "rcfilters-savedqueries-remove": "Remove", + "rcfilters-savedqueries-new-name-label": "Name", + "rcfilters-savedqueries-apply-label": "Create quick link", + "rcfilters-savedqueries-cancel-label": "Cancel", + "rcfilters-savedqueries-add-new-title": "Save filters as a quick link", "rcfilters-restore-default-filters": "Restore default filters", "rcfilters-clear-all-filters": "Clear all filters", "rcfilters-search-placeholder": "Filter recent changes (browse or start typing)", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index 27dc70a439..929019fab1 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -1553,6 +1553,16 @@ "recentchanges-legend-plusminus": "{{optional}}\nA plus/minus sign with a number for the legend.", "recentchanges-submit": "Label for submit button in [[Special:RecentChanges]]\n{{Identical|Show}}", "rcfilters-activefilters": "Title for the filters selection showing the active filters.", + "rcfilters-quickfilters": "Label for the button that opens the quick filters menu in [[Special:RecentChanges]]", + "rcfilters-savedqueries-defaultlabel": "Default name for saving a new set of quick filters [[Special:RecentChanges]]", + "rcfilters-savedqueries-rename": "Label for the menu option that edits a quick filter in [[Special:RecentChanges]]", + "rcfilters-savedqueries-setdefault": "Label for the menu option that sets a quick filter as default in [[Special:RecentChanges]]", + "rcfilters-savedqueries-unsetdefault": "Label for the menu option that unsets a quick filter as default in [[Special:RecentChanges]]", + "rcfilters-savedqueries-remove": "Label for the menu option that removes a quick filter as default in [[Special:RecentChanges]]", + "rcfilters-savedqueries-new-name-label": "Label for the input that holds the name of the new saved filters in [[Special:RecentChanges]]", + "rcfilters-savedqueries-add-new-title": "Title for the popup to add new quick link in [[Special:RecentChanges]]", + "rcfilters-savedqueries-apply-label": "Label for the button to apply saving a new quick link in [[Special:RecentChanges]]", + "rcfilters-savedqueries-cancel-label": "Label for the button to cancel the saving of a new quick link in [[Special:RecentChanges]]", "rcfilters-restore-default-filters": "Label for the button that resets filters to defaults", "rcfilters-clear-all-filters": "Title for the button that clears all filters", "rcfilters-search-placeholder": "Placeholder for the filter search input.", diff --git a/resources/Resources.php b/resources/Resources.php index eabe42f91d..3890a179cb 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -1743,6 +1743,8 @@ return [ 'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js', 'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js', 'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js', + 'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueryItemModel.js', + 'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueriesModel.js', 'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ChangesListViewModel.js', 'resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js', ], @@ -1764,6 +1766,9 @@ return [ 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FloatingMenuSelectWidget.js', 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js', 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesListWrapperWidget.js', + 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListWidget.js', + 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListItemWidget.js', + 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.js', 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js', 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemHighlightButton.js', 'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightColorPickerWidget.js', @@ -1786,6 +1791,9 @@ return [ 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ChangesListWrapperWidget.less', 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.HighlightColorPickerWidget.less', 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemHighlightButton.less', + 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SavedLinksListWidget.less', + 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SavedLinksListItemWidget.less', + 'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.less', ], 'skinStyles' => [ 'monobook' => [ @@ -1795,6 +1803,16 @@ return [ ], 'messages' => [ 'rcfilters-activefilters', + 'rcfilters-quickfilters', + 'rcfilters-savedqueries-defaultlabel', + 'rcfilters-savedqueries-rename', + 'rcfilters-savedqueries-setdefault', + 'rcfilters-savedqueries-unsetdefault', + 'rcfilters-savedqueries-remove', + 'rcfilters-savedqueries-new-name-label', + 'rcfilters-savedqueries-add-new-title', + 'rcfilters-savedqueries-apply-label', + 'rcfilters-savedqueries-cancel-label', 'rcfilters-restore-default-filters', 'rcfilters-clear-all-filters', 'rcfilters-search-placeholder', diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js index 18a9094770..298361ffbc 100644 --- a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js @@ -335,13 +335,15 @@ items.push( filterItem ); } - if ( data.type === 'string_options' && data.default ) { + if ( data.type === 'string_options' ) { // Store the default parameter group state // For this group, the parameter is group name and value is the names // of selected items model.defaultParams[ group ] = model.sanitizeStringOptionGroup( group, - data.default.split( model.groups[ group ].getSeparator() ) + data.default ? + data.default.split( model.groups[ group ].getSeparator() ) : + [] ).join( model.groups[ group ].getSeparator() ); } } ); @@ -459,15 +461,6 @@ return this.defaultParams; }; - /** - * Set all filter states to default values - */ - mw.rcfilters.dm.FiltersViewModel.prototype.setFiltersToDefaults = function () { - var defaultFilterStates = this.getFiltersFromParameters( this.getDefaultParams() ); - - this.toggleFiltersSelected( defaultFilterStates ); - }; - /** * Analyze the groups and their filters and output an object representing * the state of the parameters they represent. @@ -811,6 +804,17 @@ } ); }; + /** + * Get items that allow highlights even if they're not currently highlighted + * + * @return {mw.rcfilters.dm.FilterItem[]} Items supporting highlights + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getItemsSupportingHighlights = function () { + return this.getItems().filter( function ( filterItem ) { + return filterItem.isHighlightSupported(); + } ); + }; + /** * Toggle the highlight feature on and off. * Propagate the change to filter items. diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueriesModel.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueriesModel.js new file mode 100644 index 0000000000..7131341937 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueriesModel.js @@ -0,0 +1,192 @@ +( function ( mw, $ ) { + /** + * View model for saved queries + * + * @mixins OO.EventEmitter + * @mixins OO.EmitterList + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {string} [default] Default query ID + */ + mw.rcfilters.dm.SavedQueriesModel = function MwRcfiltersDmSavedQueriesModel( config ) { + config = config || {}; + + // Mixin constructor + OO.EventEmitter.call( this ); + OO.EmitterList.call( this ); + + this.default = config.default; + + // Events + this.aggregate( { update: 'itemUpdate' } ); + }; + + /* Initialization */ + + OO.initClass( mw.rcfilters.dm.SavedQueriesModel ); + OO.mixinClass( mw.rcfilters.dm.SavedQueriesModel, OO.EventEmitter ); + OO.mixinClass( mw.rcfilters.dm.SavedQueriesModel, OO.EmitterList ); + + /* Events */ + + /** + * @event initialize + * + * Model is initialized + */ + + /** + * @event itemUpdate + * @param {mw.rcfilters.dm.SavedQueryItemModel} Changed item + * + * An item has changed + */ + + /* Methods */ + + /** + * Initialize the saved queries model by reading it from the user's settings. + * The structure of the saved queries is: + * { + * default: (string) Query ID + * queries:{ + * query_id_1: { + * data:{ + * filters: (Object) Minimal definition of the filters + * highlights: (Object) Definition of the highlights + * }, + * label: (optional) Name of this query + * } + * } + * } + * + * @param {Object} [savedQueries] An object with the saved queries with + * the above structure. + * @param {Object} [baseState] An object representing the base state + * so we can normalize the data + * @fires initialize + */ + mw.rcfilters.dm.SavedQueriesModel.prototype.initialize = function ( savedQueries, baseState ) { + var items = []; + + savedQueries = savedQueries || {}; + + this.baseState = baseState; + + this.clearItems(); + $.each( savedQueries.queries, function ( id, obj ) { + var normalizedData = $.extend( true, {}, baseState, obj.data ); + items.push( + new mw.rcfilters.dm.SavedQueryItemModel( + id, + obj.label, + normalizedData, + { 'default': savedQueries.default === id } + ) + ); + } ); + + this.default = savedQueries.default; + + this.addItems( items ); + + this.emit( 'initialize' ); + }; + + /** + * Add a query item + * + * @param {string} label Label for the new query + * @param {Object} data Data for the new query + */ + mw.rcfilters.dm.SavedQueriesModel.prototype.addNewQuery = function ( label, data ) { + var randomID = ( new Date() ).getTime(), + normalizedData = $.extend( true, {}, this.baseState, data ); + + // Add item + this.addItems( [ + new mw.rcfilters.dm.SavedQueryItemModel( + randomID, + label, + normalizedData + ) + ] ); + }; + + /** + * Get an item that matches the requested query + * + * @param {Object} fullQueryComparison Object representing all filters and highlights to compare + * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model + */ + mw.rcfilters.dm.SavedQueriesModel.prototype.findMatchingQuery = function ( fullQueryComparison ) { + return this.getItems().filter( function ( item ) { + return OO.compare( + item.getData(), + fullQueryComparison + ); + } )[ 0 ]; + }; + + /** + * Get query by its identifier + * + * @param {string} queryID Query identifier + * @return {mw.rcfilters.dm.SavedQueryItemModel|undefined} Item matching + * the search. Undefined if not found. + */ + mw.rcfilters.dm.SavedQueriesModel.prototype.getItemByID = function ( queryID ) { + return this.getItems().filter( function ( item ) { + return item.getID() === queryID; + } )[ 0 ]; + }; + + /** + * Get the object representing the state of the entire model and items + * + * @return {Object} Object representing the state of the model and items + */ + mw.rcfilters.dm.SavedQueriesModel.prototype.getState = function () { + var obj = { queries: {} }; + + // Translate the items to the saved object + this.getItems().forEach( function ( item ) { + var itemState = item.getState(); + + obj.queries[ item.getID() ] = itemState; + } ); + + if ( this.getDefault() ) { + obj.default = this.getDefault(); + } + + return obj; + }; + + /** + * Set a default query. Null to unset default. + * + * @param {string} itemID Query identifier + * @fires default + */ + mw.rcfilters.dm.SavedQueriesModel.prototype.setDefault = function ( itemID ) { + if ( this.default !== itemID ) { + this.default = itemID; + + // Set for individual itens + this.getItems().forEach( function ( item ) { + item.toggleDefault( item.getID() === itemID ); + } ); + } + }; + + /** + * Get the default query ID + * + * @return {string} Default query identifier + */ + mw.rcfilters.dm.SavedQueriesModel.prototype.getDefault = function () { + return this.default; + }; +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueryItemModel.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueryItemModel.js new file mode 100644 index 0000000000..729aee3973 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueryItemModel.js @@ -0,0 +1,115 @@ +( function ( mw ) { + /** + * View model for a single saved query + * + * @mixins OO.EventEmitter + * + * @constructor + * @param {string} id Unique identifier + * @param {string} label Saved query label + * @param {Object} data Saved query data + * @param {Object} [config] Configuration options + * @param {boolean} [default] This item is the default + */ + mw.rcfilters.dm.SavedQueryItemModel = function MwRcfiltersDmSavedQueriesModel( id, label, data, config ) { + config = config || {}; + + // Mixin constructor + OO.EventEmitter.call( this ); + + this.id = id; + this.label = label; + this.data = data; + this.default = !!config.default; + }; + + /* Initialization */ + + OO.initClass( mw.rcfilters.dm.SavedQueryItemModel ); + OO.mixinClass( mw.rcfilters.dm.SavedQueryItemModel, OO.EventEmitter ); + + /* Events */ + + /** + * @update + * + * Model has been updated + */ + + /* Methods */ + + /** + * Get an object representing the state of this item + * + * @returns {Object} Object representing the current data state + * of the object + */ + mw.rcfilters.dm.SavedQueryItemModel.prototype.getState = function () { + return { + data: this.getData(), + label: this.getLabel() + }; + }; + + /** + * Get the query's identifier + * + * @return {string} Query identifier + */ + mw.rcfilters.dm.SavedQueryItemModel.prototype.getID = function () { + return this.id; + }; + + /** + * Get query label + * + * @return {label} Query label + */ + mw.rcfilters.dm.SavedQueryItemModel.prototype.getLabel = function () { + return this.label; + }; + + /** + * Update the query label + * + * @param {string} newLabel New label + */ + mw.rcfilters.dm.SavedQueryItemModel.prototype.updateLabel = function ( newLabel ) { + if ( newLabel && this.label !== newLabel ) { + this.label = newLabel; + this.emit( 'update' ); + } + }; + + /** + * Get query data + * + * @return {Object} Object representing parameter and highlight data + */ + mw.rcfilters.dm.SavedQueryItemModel.prototype.getData = function () { + return this.data; + }; + + /** + * Check whether this item is the default + * + * @return {boolean} Query is set to be default + */ + mw.rcfilters.dm.SavedQueryItemModel.prototype.isDefault = function () { + return this.default; + }; + + /** + * Toggle the default state of this query item + * + * @param {boolean} isDefault Query is default + */ + mw.rcfilters.dm.SavedQueryItemModel.prototype.toggleDefault = function ( isDefault ) { + isDefault = isDefault === undefined ? !this.default : isDefault; + + if ( this.default !== isDefault ) { + this.default = isDefault; + this.emit( 'update' ); + } + }; +}( mediaWiki ) ); diff --git a/resources/src/mediawiki.rcfilters/images/clip.svg b/resources/src/mediawiki.rcfilters/images/clip.svg new file mode 100644 index 0000000000..86d1dbfbbd --- /dev/null +++ b/resources/src/mediawiki.rcfilters/images/clip.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/src/mediawiki.rcfilters/images/pushPin.svg b/resources/src/mediawiki.rcfilters/images/pushPin.svg new file mode 100644 index 0000000000..b852cd0668 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/images/pushPin.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/src/mediawiki.rcfilters/images/unClip.svg b/resources/src/mediawiki.rcfilters/images/unClip.svg new file mode 100644 index 0000000000..68459db6fb --- /dev/null +++ b/resources/src/mediawiki.rcfilters/images/unClip.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js index 669420caf9..35541d136f 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js @@ -1,14 +1,18 @@ ( function ( mw, $ ) { + /* eslint no-underscore-dangle: "off" */ /** * Controller for the filters in Recent Changes * * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel Changes list view model + * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model */ - mw.rcfilters.Controller = function MwRcfiltersController( filtersModel, changesListModel ) { + mw.rcfilters.Controller = function MwRcfiltersController( filtersModel, changesListModel, savedQueriesModel ) { this.filtersModel = filtersModel; this.changesListModel = changesListModel; + this.savedQueriesModel = savedQueriesModel; this.requestCounter = 0; + this.baseState = {}; }; /* Initialization */ @@ -20,9 +24,26 @@ * @param {Array} filterStructure Filter definition and structure for the model */ mw.rcfilters.Controller.prototype.initialize = function ( filterStructure ) { - var $changesList = $( '.mw-changeslist' ).first().contents(); + var parsedSavedQueries, + $changesList = $( '.mw-changeslist' ).first().contents(); // Initialize the model this.filtersModel.initializeFilters( filterStructure ); + + this._buildBaseFilterState(); + + try { + parsedSavedQueries = JSON.parse( mw.user.options.get( 'rcfilters-saved-queries' ) || '{}' ); + } catch ( err ) { + parsedSavedQueries = {}; + } + + // The queries are saved in a minimized state, so we need + // to send over the base state so the saved queries model + // can normalize them per each query item + this.savedQueriesModel.initialize( + parsedSavedQueries, + this._getBaseState() + ); this.updateStateBasedOnUrl(); // Update the changes list with the existing data @@ -31,54 +52,13 @@ $changesList.length ? $changesList : 'NO_RESULTS', $( 'fieldset.rcoptions' ).first() ); - - }; - - /** - * Update filter state (selection and highlighting) based - * on current URL and default values. - */ - mw.rcfilters.Controller.prototype.updateStateBasedOnUrl = function () { - var uri = new mw.Uri(); - - // Set filter states based on defaults and URL params - this.filtersModel.toggleFiltersSelected( - this.filtersModel.getFiltersFromParameters( - // Merge defaults with URL params for initialization - $.extend( - true, - {}, - this.filtersModel.getDefaultParams(), - // URI query overrides defaults - uri.query - ) - ) - ); - - // Initialize highlights - this.filtersModel.toggleHighlight( !!uri.query.highlight ); - this.filtersModel.getItems().forEach( function ( filterItem ) { - var color = uri.query[ filterItem.getName() + '_color' ]; - if ( color ) { - filterItem.setHighlightColor( color ); - } else { - filterItem.clearHighlightColor(); - } - } ); - - // Check all filter interactions - this.filtersModel.reassessFilterInteractions(); }; /** * Reset to default filters */ mw.rcfilters.Controller.prototype.resetToDefaults = function () { - this.filtersModel.setFiltersToDefaults(); - this.filtersModel.clearAllHighlightColors(); - // Check all filter interactions - this.filtersModel.reassessFilterInteractions(); - + this._updateModelState( this._getDefaultParams() ); this.updateChangesList(); }; @@ -98,7 +78,7 @@ this.updateChangesList(); if ( highlightedFilterNames ) { - this.trackHighlight( 'clearAll', highlightedFilterNames ); + this._trackHighlight( 'clearAll', highlightedFilterNames ); } }; @@ -128,6 +108,421 @@ } }; + /** + * Clear both highlight and selection of a filter + * + * @param {string} filterName Name of the filter item + */ + mw.rcfilters.Controller.prototype.clearFilter = function ( filterName ) { + var filterItem = this.filtersModel.getItemByName( filterName ), + isHighlighted = filterItem.isHighlighted(); + + if ( filterItem.isSelected() || isHighlighted ) { + this.filtersModel.clearHighlightColor( filterName ); + this.filtersModel.toggleFilterSelected( filterName, false ); + this.updateChangesList(); + this.filtersModel.reassessFilterInteractions( filterItem ); + } + + if ( isHighlighted ) { + this._trackHighlight( 'clear', filterName ); + } + }; + + /** + * Toggle the highlight feature on and off + */ + mw.rcfilters.Controller.prototype.toggleHighlight = function () { + this.filtersModel.toggleHighlight(); + this._updateURL(); + + if ( this.filtersModel.isHighlightEnabled() ) { + mw.hook( 'RcFilters.highlight.enable' ).fire(); + } + }; + + /** + * Set the highlight color for a filter item + * + * @param {string} filterName Name of the filter item + * @param {string} color Selected color + */ + mw.rcfilters.Controller.prototype.setHighlightColor = function ( filterName, color ) { + this.filtersModel.setHighlightColor( filterName, color ); + this._updateURL(); + this._trackHighlight( 'set', { name: filterName, color: color } ); + }; + + /** + * Clear highlight for a filter item + * + * @param {string} filterName Name of the filter item + */ + mw.rcfilters.Controller.prototype.clearHighlightColor = function ( filterName ) { + this.filtersModel.clearHighlightColor( filterName ); + this._updateURL(); + this._trackHighlight( 'clear', filterName ); + }; + + /** + * Save the current model state as a saved query + * + * @param {string} [label] Label of the saved query + */ + mw.rcfilters.Controller.prototype.saveCurrentQuery = function ( label ) { + var highlightedItems = {}, + highlightEnabled = this.filtersModel.isHighlightEnabled(); + + // Prepare highlights + this.filtersModel.getHighlightedItems().forEach( function ( item ) { + highlightedItems[ item.getName() ] = highlightEnabled ? + item.getHighlightColor() : null; + } ); + highlightedItems.highlights = this.filtersModel.isHighlightEnabled(); + + // Add item + this.savedQueriesModel.addNewQuery( + label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ), + { + filters: this.filtersModel.getSelectedState(), + highlights: highlightedItems + } + ); + + // Save item + this._saveSavedQueries(); + }; + + /** + * Remove a saved query + * + * @param {string} queryID Query id + */ + mw.rcfilters.Controller.prototype.removeSavedQuery = function ( queryID ) { + var query = this.savedQueriesModel.getItemByID( queryID ); + + this.savedQueriesModel.removeItems( [ query ] ); + + // Check if this item was the default + if ( this.savedQueriesModel.getDefault() === queryID ) { + // Nulify the default + this.savedQueriesModel.setDefault( null ); + } + this._saveSavedQueries(); + }; + + /** + * Rename a saved query + * + * @param {string} queryID Query id + * @param {string} newLabel New label for the query + */ + mw.rcfilters.Controller.prototype.renameSavedQuery = function ( queryID, newLabel ) { + var queryItem = this.savedQueriesModel.getItemByID( queryID ); + + if ( queryItem ) { + queryItem.updateLabel( newLabel ); + } + this._saveSavedQueries(); + }; + + /** + * Set a saved query as default + * + * @param {string} queryID Query Id. If null is given, default + * query is reset. + */ + mw.rcfilters.Controller.prototype.setDefaultSavedQuery = function ( queryID ) { + this.savedQueriesModel.setDefault( queryID ); + this._saveSavedQueries(); + }; + + /** + * Load a saved query + * + * @param {string} queryID Query id + */ + mw.rcfilters.Controller.prototype.applySavedQuery = function ( queryID ) { + var data, highlights, + queryItem = this.savedQueriesModel.getItemByID( queryID ); + + if ( queryItem ) { + data = queryItem.getData(); + highlights = data.highlights; + + // Update model state from filters + this.filtersModel.toggleFiltersSelected( data.filters ); + + // Update highlight state + this.filtersModel.toggleHighlight( !!highlights.highlights ); + this.filtersModel.getItems().forEach( function ( filterItem ) { + var color = highlights[ filterItem.getName() ]; + if ( color ) { + filterItem.setHighlightColor( color ); + } else { + filterItem.clearHighlightColor(); + } + } ); + + // Check all filter interactions + this.filtersModel.reassessFilterInteractions(); + + this.updateChangesList(); + } + }; + + /** + * Check whether the current filter and highlight state exists + * in the saved queries model. + * + * @return {boolean} Query exists + */ + mw.rcfilters.Controller.prototype.findQueryMatchingCurrentState = function () { + var highlightedItems = {}; + + // Prepare highlights of the current query + this.filtersModel.getItemsSupportingHighlights().forEach( function ( item ) { + highlightedItems[ item.getName() ] = item.getHighlightColor(); + } ); + highlightedItems.highlights = this.filtersModel.isHighlightEnabled(); + + return this.savedQueriesModel.findMatchingQuery( + { + filters: this.filtersModel.getSelectedState(), + highlights: highlightedItems + } + ); + }; + + /** + * Get an object representing the base state of parameters + * and highlights. + * + * This is meant to make sure that the saved queries that are + * in memory are always the same structure as what we would get + * by calling the current model's "getSelectedState" and by checking + * highlight items. + * + * In cases where a user saved a query when the system had a certain + * set of filters, and then a filter was added to the system, we want + * to make sure that the stored queries can still be comparable to + * the current state, which means that we need the base state for + * two operations: + * + * - Saved queries are stored in "minimal" view (only changed filters + * are stored); When we initialize the system, we merge each minimal + * query with the base state (using 'getNormalizedFilters') so all + * saved queries have the exact same structure as what we would get + * by checking the getSelectedState of the filter. + * - When we save the queries, we minimize the object to only represent + * whatever has actually changed, rather than store the entire + * object. To check what actually is different so we can store it, + * we need to obtain a base state to compare against, this is + * what #_getMinimalFilterList does + */ + mw.rcfilters.Controller.prototype._buildBaseFilterState = function () { + var defaultParams = this.filtersModel.getDefaultParams(), + highlightedItems = {}; + + // Prepare highlights + this.filtersModel.getItemsSupportingHighlights().forEach( function ( item ) { + highlightedItems[ item.getName() ] = null; + } ); + highlightedItems.highlights = false; + + this.baseState = { + filters: this.filtersModel.getFiltersFromParameters( defaultParams ), + highlights: highlightedItems + }; + }; + + /** + * Get an object representing the base state of parameters + * and highlights. The structure is similar to what we use + * to store each query in the saved queries object: + * { + * filters: { + * filterName: (bool) + * }, + * highlights: { + * filterName: (string|null) + * } + * } + * + * @return {Object} Object representing the base state of + * parameters and highlights + */ + mw.rcfilters.Controller.prototype._getBaseState = function () { + return this.baseState; + }; + + /** + * Get an object that holds only the parameters and highlights that have + * values different than the base default value. + * + * This is the reverse of the normalization we do initially on loading and + * initializing the saved queries model. + * + * @param {Object} valuesObject Object representing the state of both + * filters and highlights in its normalized version, to be minimized. + * @return {Object} Minimal filters and highlights list + */ + mw.rcfilters.Controller.prototype._getMinimalFilterList = function ( valuesObject ) { + var result = { filters: {}, highlights: {} }, + baseState = this._getBaseState(); + + // XOR results + $.each( valuesObject.filters, function ( name, value ) { + if ( baseState.filters !== undefined && baseState.filters[ name ] !== value ) { + result.filters[ name ] = value; + } + } ); + + $.each( valuesObject.highlights, function ( name, value ) { + if ( baseState.highlights !== undefined && baseState.highlights[ name ] !== value ) { + result.highlights[ name ] = value; + } + } ); + + return result; + }; + + /** + * Save the current state of the saved queries model with all + * query item representation in the user settings. + */ + mw.rcfilters.Controller.prototype._saveSavedQueries = function () { + var stringified, + state = this.savedQueriesModel.getState(), + controller = this; + + // Minimize before save + $.each( state.queries, function ( queryID, info ) { + state.queries[ queryID ].data = controller._getMinimalFilterList( info.data ); + } ); + + // Stringify state + stringified = JSON.stringify( state ); + + if ( stringified.length > 65535 ) { + // Sanity check, since the preference can only hold that. + return; + } + + // Save the preference + new mw.Api().saveOption( 'rcfilters-saved-queries', stringified ); + // Update the preference for this session + mw.user.options.set( 'rcfilters-saved-queries', stringified ); + }; + + /** + * Synchronize the URL with the current state of the filters + * without adding an history entry. + */ + mw.rcfilters.Controller.prototype.replaceUrl = function () { + window.history.replaceState( + { tag: 'rcfilters' }, + document.title, + this._getUpdatedUri().toString() + ); + }; + + /** + * Update filter state (selection and highlighting) based + * on current URL and default values. + */ + mw.rcfilters.Controller.prototype.updateStateBasedOnUrl = function () { + var uri = new mw.Uri(), + defaultParams = this._getDefaultParams(); + + this._updateModelState( $.extend( {}, defaultParams, uri.query ) ); + this.updateChangesList(); + }; + + /** + * Update the list of changes and notify the model + * + * @param {Object} [params] Extra parameters to add to the API call + */ + mw.rcfilters.Controller.prototype.updateChangesList = function ( params ) { + this._updateURL( params ); + this.changesListModel.invalidate(); + this._fetchChangesList() + .then( + // Success + function ( pieces ) { + var $changesListContent = pieces.changes, + $fieldset = pieces.fieldset; + this.changesListModel.update( $changesListContent, $fieldset ); + }.bind( this ) + // Do nothing for failure + ); + }; + + /** + * Update the model state from given the given parameters. + * + * This is an internal method, and should only be used from inside + * the controller. + * + * @param {Object} parameters Object representing the parameters for + * filters and highlights + */ + mw.rcfilters.Controller.prototype._updateModelState = function ( parameters ) { + // Update filter states + this.filtersModel.toggleFiltersSelected( + this.filtersModel.getFiltersFromParameters( + parameters + ) + ); + + // Update highlight state + this.filtersModel.toggleHighlight( !!parameters.highlights ); + this.filtersModel.getItems().forEach( function ( filterItem ) { + var color = parameters[ filterItem.getName() + '_color' ]; + if ( color ) { + filterItem.setHighlightColor( color ); + } else { + filterItem.clearHighlightColor(); + } + } ); + + // Check all filter interactions + this.filtersModel.reassessFilterInteractions(); + }; + + /** + * Get an object representing the default parameter state, whether + * it is from the model defaults or from the saved queries. + * + * @return {Object} Default parameters + */ + mw.rcfilters.Controller.prototype._getDefaultParams = function () { + var data, queryHighlights, + savedParams = {}, + savedHighlights = {}, + defaultSavedQueryItem = this.savedQueriesModel.getItemByID( this.savedQueriesModel.getDefault() ); + + if ( defaultSavedQueryItem ) { + data = defaultSavedQueryItem.getData(); + + queryHighlights = data.highlights || {}; + savedParams = this.filtersModel.getParametersFromFilters( data.filters || {} ); + + // Translate highlights to parameters + savedHighlights.highlights = queryHighlights.highlights; + $.each( queryHighlights, function ( filterName, color ) { + if ( filterName !== 'highlights' ) { + savedHighlights[ filterName + '_color' ] = color; + } + } ); + + return $.extend( true, {}, savedParams, savedHighlights ); + } + + return this.filtersModel.getDefaultParams(); + }; + /** * Update the URL of the page to reflect current filters * @@ -136,10 +531,9 @@ * highlighting actions below, or call #updateChangesList which does * the uri corrections already. * - * @private * @param {Object} [params] Extra parameters to add to the API call */ - mw.rcfilters.Controller.prototype.updateURL = function ( params ) { + mw.rcfilters.Controller.prototype._updateURL = function ( params ) { var updatedUri, notEquivalent = function ( obj1, obj2 ) { var keys = Object.keys( obj1 ).concat( Object.keys( obj2 ) ); @@ -150,7 +544,7 @@ params = params || {}; - updatedUri = this.getUpdatedUri(); + updatedUri = this._getUpdatedUri(); updatedUri.extend( params ); if ( notEquivalent( updatedUri.query, new mw.Uri().query ) ) { @@ -163,7 +557,7 @@ * * @return {mw.Uri} Updated Uri */ - mw.rcfilters.Controller.prototype.getUpdatedUri = function () { + mw.rcfilters.Controller.prototype._getUpdatedUri = function () { var uri = new mw.Uri(), highlightParams = this.filtersModel.getHighlightParameters(); @@ -191,8 +585,8 @@ * @return {jQuery.Promise} Promise object that will resolve with the changes list * or with a string denoting no results. */ - mw.rcfilters.Controller.prototype.fetchChangesList = function () { - var uri = this.getUpdatedUri(), + mw.rcfilters.Controller.prototype._fetchChangesList = function () { + var uri = this._getUpdatedUri(), requestId = ++this.requestCounter, latestRequest = function () { return requestId === this.requestCounter; @@ -235,101 +629,13 @@ ); }; - /** - * Update the list of changes and notify the model - * - * @param {Object} [params] Extra parameters to add to the API call - */ - mw.rcfilters.Controller.prototype.updateChangesList = function ( params ) { - this.updateURL( params ); - this.changesListModel.invalidate(); - this.fetchChangesList() - .then( - // Success - function ( pieces ) { - var $changesListContent = pieces.changes, - $fieldset = pieces.fieldset; - this.changesListModel.update( $changesListContent, $fieldset ); - }.bind( this ) - // Do nothing for failure - ); - }; - - /** - * Toggle the highlight feature on and off - */ - mw.rcfilters.Controller.prototype.toggleHighlight = function () { - this.filtersModel.toggleHighlight(); - this.updateURL(); - - if ( this.filtersModel.isHighlightEnabled() ) { - mw.hook( 'RcFilters.highlight.enable' ).fire(); - } - }; - - /** - * Set the highlight color for a filter item - * - * @param {string} filterName Name of the filter item - * @param {string} color Selected color - */ - mw.rcfilters.Controller.prototype.setHighlightColor = function ( filterName, color ) { - this.filtersModel.setHighlightColor( filterName, color ); - this.updateURL(); - this.trackHighlight( 'set', { name: filterName, color: color } ); - }; - - /** - * Clear highlight for a filter item - * - * @param {string} filterName Name of the filter item - */ - mw.rcfilters.Controller.prototype.clearHighlightColor = function ( filterName ) { - this.filtersModel.clearHighlightColor( filterName ); - this.updateURL(); - this.trackHighlight( 'clear', filterName ); - }; - - /** - * Clear both highlight and selection of a filter - * - * @param {string} filterName Name of the filter item - */ - mw.rcfilters.Controller.prototype.clearFilter = function ( filterName ) { - var filterItem = this.filtersModel.getItemByName( filterName ), - isHighlighted = filterItem.isHighlighted(); - - if ( filterItem.isSelected() || isHighlighted ) { - this.filtersModel.clearHighlightColor( filterName ); - this.filtersModel.toggleFilterSelected( filterName, false ); - this.updateChangesList(); - this.filtersModel.reassessFilterInteractions( filterItem ); - } - - if ( isHighlighted ) { - this.trackHighlight( 'clear', filterName ); - } - }; - - /** - * Synchronize the URL with the current state of the filters - * without adding an history entry. - */ - mw.rcfilters.Controller.prototype.replaceUrl = function () { - window.history.replaceState( - { tag: 'rcfilters' }, - document.title, - this.getUpdatedUri().toString() - ); - }; - /** * Track usage of highlight feature * * @param {string} action * @param {array|object|string} filters */ - mw.rcfilters.Controller.prototype.trackHighlight = function ( action, filters ) { + mw.rcfilters.Controller.prototype._trackHighlight = function ( action, filters ) { filters = typeof filters === 'string' ? { name: filters } : filters; filters = !Array.isArray( filters ) ? [ filters ] : filters; mw.track( @@ -341,4 +647,5 @@ } ); }; + }( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js index 4a586e484d..dd8fae05e9 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js @@ -11,11 +11,12 @@ init: function () { var filtersModel = new mw.rcfilters.dm.FiltersViewModel(), changesListModel = new mw.rcfilters.dm.ChangesListViewModel(), - controller = new mw.rcfilters.Controller( filtersModel, changesListModel ), + savedQueriesModel = new mw.rcfilters.dm.SavedQueriesModel(), + controller = new mw.rcfilters.Controller( filtersModel, changesListModel, savedQueriesModel ), $overlay = $( '
' ) .addClass( 'mw-rcfilters-ui-overlay' ), filtersWidget = new mw.rcfilters.ui.FilterWrapperWidget( - controller, filtersModel, { $overlay: $overlay } ); + controller, filtersModel, savedQueriesModel, { $overlay: $overlay } ); // TODO: The changesListWrapperWidget should be able to initialize // after the model is ready. diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterTagMultiselectWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterTagMultiselectWidget.less index f1b68710a3..66e6d960e7 100644 --- a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterTagMultiselectWidget.less +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterTagMultiselectWidget.less @@ -19,9 +19,16 @@ margin-top: 0.3em; } - &-wrapper-content-title { - font-weight: bold; - color: #54595d; + &-wrapper-content { + &-title { + font-weight: bold; + color: #54595d; + } + + &-savedQueryTitle { + color: #72777d; + margin-left: 1em; + } } &-emptyFilters { diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.less new file mode 100644 index 0000000000..e19c24680d --- /dev/null +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.less @@ -0,0 +1,22 @@ +.mw-rcfilters-ui-saveFiltersPopupButtonWidget { + &-popup { + &-layout { + padding-bottom: 1.5em; + } + + > .oo-ui-popupWidget-popup > .oo-ui-popupWidget-head { + > .oo-ui-iconWidget { + margin: 0.75em 0.5em; + float: left; + } + + > .oo-ui-labelElement-label { + font-size: 1.2em; + padding: 0.3em; + margin-left: 0; + font-weight: bold; + } + } + } + +} diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SavedLinksListItemWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SavedLinksListItemWidget.less new file mode 100644 index 0000000000..76e1c48fcf --- /dev/null +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SavedLinksListItemWidget.less @@ -0,0 +1,51 @@ +.mw-rcfilters-ui-savedLinksListItemWidget { + padding: 0.5em; + + &:hover { + // Mimicking optionWidget styles + background-color: #eaecf0; + color: #000; + } + + .mw-rcfilters-ui-cell { + vertical-align: middle; + } + + &:not( .oo-ui-iconElement ) .oo-ui-iconElement-icon { + // The iconElement-icon class still appears when we + // have an empty icon, and we need it to pretend to + // be there so the text has the same alignment as + // text next to a visible icon. #ThanksOOUI + width: 1.875em; + height: 1.875em; + } + + &-icon span { + display: inline-block; + } + + &-input { + display: inline-block; + width: 12em; + } + + &-label { + max-width: 12em; + display: inline-block; + vertical-align: middle; + text-overflow: ellipsis; + overflow: hidden; + cursor: pointer; + margin-left: 0.5px; + } + + &-icon, + &-button { + width: 2em; + } + + &-content { + width: 100%; + } + +} diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SavedLinksListWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SavedLinksListWidget.less new file mode 100644 index 0000000000..e1e55a77f5 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SavedLinksListWidget.less @@ -0,0 +1,7 @@ +.mw-rcfilters-ui-savedLinksListWidget { + float: right; + + &-menu { + width: 100%; + } +} diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.less index 957e9e9009..c0f24c6cbe 100644 --- a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.less +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.less @@ -14,3 +14,19 @@ } } +// Temporary icon classes, until these icons +// are merged into OOUI properly +.oo-ui-iconElement-icon.oo-ui-icon-clip { + /* @embed */ + background-image: url( ../images/clip.svg ); +} + +.oo-ui-iconElement-icon.oo-ui-icon-unClip { + /* @embed */ + background-image: url( ../images/unClip.svg ); +} + +.oo-ui-iconElement-icon.oo-ui-icon-pushPin { + /* @embed */ + background-image: url( ../images/pushPin.svg ); +} diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js index c52ca1fc22..ea1d1c37e3 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js @@ -8,10 +8,11 @@ * @constructor * @param {mw.rcfilters.Controller} controller Controller * @param {mw.rcfilters.dm.FiltersViewModel} model View model + * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model * @param {Object} config Configuration object * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups */ - mw.rcfilters.ui.FilterTagMultiselectWidget = function MwRcfiltersUiFilterTagMultiselectWidget( controller, model, config ) { + mw.rcfilters.ui.FilterTagMultiselectWidget = function MwRcfiltersUiFilterTagMultiselectWidget( controller, model, savedQueriesModel, config ) { var title = new OO.ui.LabelWidget( { label: mw.msg( 'rcfilters-activefilters' ), classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-title' ] @@ -23,6 +24,7 @@ this.controller = controller; this.model = model; + this.queriesModel = savedQueriesModel; this.$overlay = config.$overlay || this.$element; // Parent @@ -55,11 +57,21 @@ } }, config ) ); + this.savedQueryTitle = new OO.ui.LabelWidget( { + label: '', + classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-savedQueryTitle' ] + } ); + this.resetButton = new OO.ui.ButtonWidget( { framed: false, classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-resetButton' ] } ); + this.saveQueryButton = new mw.rcfilters.ui.SaveFiltersPopupButtonWidget( + this.controller, + this.queriesModel + ); + this.emptyFilterMessage = new OO.ui.LabelWidget( { label: mw.msg( 'rcfilters-empty-filter' ), classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-emptyFilters' ] @@ -71,15 +83,21 @@ // Stop propagation for mousedown, so that the widget doesn't // trigger the focus on the input and scrolls up when we click the reset button this.resetButton.$element.on( 'mousedown', function ( e ) { e.stopPropagation(); } ); + this.saveQueryButton.$element.on( 'mousedown', function ( e ) { e.stopPropagation(); } ); this.model.connect( this, { initialize: 'onModelInitialize', itemUpdate: 'onModelItemUpdate', highlightChange: 'onModelHighlightChange' } ); + this.saveQueryButton.connect( this, { + click: 'onSaveQueryButtonClick', + saveCurrent: 'setSavedQueryVisibility' + } ); // Build the content $contentWrapper.append( title.$element, + this.savedQueryTitle.$element, $( '
' ) .addClass( 'mw-rcfilters-ui-table' ) .append( @@ -93,6 +111,10 @@ this.$content .addClass( 'mw-rcfilters-ui-cell' ) .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-filters' ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-save' ) + .append( this.saveQueryButton.$element ), $( '
' ) .addClass( 'mw-rcfilters-ui-cell' ) .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-reset' ) @@ -104,6 +126,7 @@ // Initialize this.$handle.append( $contentWrapper ); this.emptyFilterMessage.toggle( this.isEmpty() ); + this.savedQueryTitle.toggle( false ); this.$element .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget' ); @@ -118,6 +141,13 @@ /* Methods */ + /** + * Respond to query button click + */ + mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onSaveQueryButtonClick = function () { + this.getMenu().toggle( false ); + }; + /** * Respond to menu toggle * @@ -168,8 +198,25 @@ */ mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelInitialize = function () { this.populateFromModel(); + + this.setSavedQueryVisibility(); }; + /** + * Set the visibility of the saved query button + */ + mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.setSavedQueryVisibility = function () { + var matchingQuery = this.controller.findQueryMatchingCurrentState(); + + this.savedQueryTitle.setLabel( + matchingQuery ? matchingQuery.getLabel() : '' + ); + this.savedQueryTitle.toggle( !!matchingQuery ); + this.saveQueryButton.toggle( + !this.isEmpty() && + !matchingQuery + ); + }; /** * Respond to model itemUpdate event * @@ -189,6 +236,8 @@ this.removeTagByData( item.getName() ); } + this.setSavedQueryVisibility(); + // Re-evaluate reset state this.reevaluateResetRestoreState(); }; diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js index b7ebf3423a..738a981d08 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js @@ -8,11 +8,12 @@ * @constructor * @param {mw.rcfilters.Controller} controller Controller * @param {mw.rcfilters.dm.FiltersViewModel} model View model + * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model * @param {Object} [config] Configuration object * @cfg {Object} [filters] A definition of the filter groups in this list * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups */ - mw.rcfilters.ui.FilterWrapperWidget = function MwRcfiltersUiFilterWrapperWidget( controller, model, config ) { + mw.rcfilters.ui.FilterWrapperWidget = function MwRcfiltersUiFilterWrapperWidget( controller, model, savedQueriesModel, config ) { config = config || {}; // Parent @@ -22,18 +23,29 @@ this.controller = controller; this.model = model; + this.queriesModel = savedQueriesModel; this.$overlay = config.$overlay || this.$element; this.filterTagWidget = new mw.rcfilters.ui.FilterTagMultiselectWidget( this.controller, this.model, + this.queriesModel, + { $overlay: this.$overlay } + ); + + this.savedLinksListWidget = new mw.rcfilters.ui.SavedLinksListWidget( + this.controller, + this.queriesModel, { $overlay: this.$overlay } ); // Initialize this.$element .addClass( 'mw-rcfilters-ui-filterWrapperWidget' ) - .append( this.filterTagWidget.$element ); + .append( + this.savedLinksListWidget.$element, + this.filterTagWidget.$element + ); }; /* Initialization */ diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.js new file mode 100644 index 0000000000..9b7a2fba8b --- /dev/null +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.js @@ -0,0 +1,159 @@ +( function ( mw ) { + /** + * Save filters widget. This widget is displayed in the tag area + * and allows the user to save the current state of the system + * as a new saved filter query they can later load or set as + * default. + * + * @extends OO.ui.PopupButtonWidget + * + * @constructor + * @param {mw.rcfilters.Controller} controller Controller + * @param {mw.rcfilters.dm.SavedQueriesModel} model View model + * @param {Object} [config] Configuration object + */ + mw.rcfilters.ui.SaveFiltersPopupButtonWidget = function MwRcfiltersUiSaveFiltersPopupButtonWidget( controller, model, config ) { + var layout, + $popupContent = $( '
' ); + + config = config || {}; + + this.controller = controller; + this.model = model; + + // Parent + mw.rcfilters.ui.SaveFiltersPopupButtonWidget.parent.call( this, $.extend( { + framed: false, + icon: 'clip', + $overlay: this.$overlay, + title: mw.msg( 'rcfilters-savedqueries-add-new-title' ), + popup: { + classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup' ], + padded: true, + head: true, + label: mw.msg( 'rcfilters-savedqueries-add-new-title' ), + $content: $popupContent + } + }, config ) ); + // // HACK: Add an icon to the popup head label + this.popup.$head.prepend( ( new OO.ui.IconWidget( { icon: 'clip' } ) ).$element ); + + this.input = new OO.ui.TextInputWidget( { + validate: 'non-empty' + } ); + layout = new OO.ui.FieldLayout( this.input, { + label: mw.msg( 'rcfilters-savedqueries-new-name-label' ), + align: 'top' + } ); + + this.applyButton = new OO.ui.ButtonWidget( { + label: mw.msg( 'rcfilters-savedqueries-apply-label' ), + classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-apply' ], + flags: [ 'primary', 'progressive' ] + } ); + this.cancelButton = new OO.ui.ButtonWidget( { + label: mw.msg( 'rcfilters-savedqueries-cancel-label' ), + classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-cancel' ] + } ); + + $popupContent + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-layout' ) + .append( layout.$element ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons' ) + .append( + this.cancelButton.$element, + this.applyButton.$element + ) + ); + + // Events + this.popup.connect( this, { + ready: 'onPopupReady', + toggle: 'onPopupToggle' + } ); + this.input.connect( this, { enter: 'onInputEnter' } ); + this.input.$input.on( { + keyup: this.onInputKeyup.bind( this ) + } ); + this.cancelButton.connect( this, { click: 'onCancelButtonClick' } ); + this.applyButton.connect( this, { click: 'onApplyButtonClick' } ); + + // Initialize + this.$element + .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget' ); + }; + + /* Initialization */ + OO.inheritClass( mw.rcfilters.ui.SaveFiltersPopupButtonWidget, OO.ui.PopupButtonWidget ); + + /** + * Respond to input enter event + */ + mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onInputEnter = function () { + this.apply(); + }; + + /** + * Respond to input keyup event, this is the way to intercept 'escape' key + * + * @param {jQuery.Event} e Event data + * @returns {boolean} false + */ + mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onInputKeyup = function ( e ) { + if ( e.which === OO.ui.Keys.ESCAPE ) { + this.popup.toggle( false ); + return false; + } + }; + + /** + * Respond to popup toggle event + * + * @param {boolean} isVisible Popup is visible + */ + mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onPopupToggle = function ( isVisible ) { + this.setIcon( isVisible ? 'unClip' : 'clip' ); + }; + + /** + * Respond to popup ready event + */ + mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onPopupReady = function () { + this.input.focus(); + }; + + /** + * Respond to cancel button click event + */ + mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onCancelButtonClick = function () { + this.popup.toggle( false ); + }; + + /** + * Respond to apply button click event + */ + mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onApplyButtonClick = function () { + this.apply(); + }; + + /** + * Apply and add the new quick link + */ + mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.apply = function () { + var widget = this, + label = this.input.getValue(); + + this.input.getValidity() + .done( function () { + widget.controller.saveCurrentQuery( label ); + widget.input.setValue( this.input, '' ); + widget.emit( 'saveCurrent' ); + } ) + .always( function () { + widget.popup.toggle( false ); + } ); + }; +}( mediaWiki ) ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListItemWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListItemWidget.js new file mode 100644 index 0000000000..3e6fb77391 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListItemWidget.js @@ -0,0 +1,290 @@ +( function ( mw ) { + /** + * Quick links menu option widget + * + * @extends OO.ui.Widget + * @mixins OO.ui.mixin.LabelElement + * @mixins OO.ui.mixin.IconElement + * + * @constructor + * @param {mw.rcfilters.dm.SavedQueryItemModel} model View model + * @param {Object} [config] Configuration object + * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups + */ + mw.rcfilters.ui.SavedLinksListItemWidget = function MwRcfiltersUiSavedLinksListWidget( model, config ) { + config = config || {}; + + this.model = model; + + // Parent + mw.rcfilters.ui.SavedLinksListItemWidget.parent.call( this, $.extend( { + data: this.model.getID() + }, config ) ); + + // Mixin constructors + OO.ui.mixin.LabelElement.call( this, $.extend( { + label: this.model.getLabel() + }, config ) ); + OO.ui.mixin.IconElement.call( this, $.extend( { + icon: '' + }, config ) ); + + this.edit = false; + this.$overlay = config.$overlay || this.$element; + + this.popupButton = new OO.ui.ButtonWidget( { + classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-button' ], + icon: 'ellipsis', + framed: false + } ); + this.menu = new OO.ui.FloatingMenuSelectWidget( { + classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-menu' ], + widget: this.popupButton, + width: 200, + horizontalPosition: 'end', + $container: this.popupButton.$element, + items: [ + new OO.ui.MenuOptionWidget( { + data: 'edit', + icon: 'edit', + label: mw.msg( 'rcfilters-savedqueries-rename' ) + } ), + new OO.ui.MenuOptionWidget( { + data: 'delete', + icon: 'close', + label: mw.msg( 'rcfilters-savedqueries-remove' ) + } ), + new OO.ui.MenuOptionWidget( { + data: 'default', + icon: 'pushPin', + label: mw.msg( 'rcfilters-savedqueries-setdefault' ) + } ) + ] + } ); + + this.editInput = new OO.ui.TextInputWidget( { + classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-input' ] + } ); + this.saveButton = new OO.ui.ButtonWidget( { + icon: 'check', + flags: [ 'primary', 'progressive' ] + } ); + this.toggleEdit( false ); + + // Events + this.model.connect( this, { update: 'onModelUpdate' } ); + this.popupButton.connect( this, { click: 'onPopupButtonClick' } ); + this.menu.connect( this, { + choose: 'onMenuChoose' + } ); + this.saveButton.connect( this, { click: 'onSaveButtonClick' } ); + this.editInput.connect( this, { enter: 'onEditInputEnter' } ); + this.editInput.$input.on( { + blur: this.onInputBlur.bind( this ), + keyup: this.onInputKeyup.bind( this ) + } ); + this.$element.on( { click: this.onClick.bind( this ) } ); + this.$label.on( { click: this.onClick.bind( this ) } ); + // Prevent propagation on mousedown for the save button + // so the menu doesn't close + this.saveButton.$element.on( { mousedown: function () { return false; } } ); + + // Initialize + this.toggleDefault( !!this.model.isDefault() ); + this.$overlay.append( this.menu.$element ); + this.$element + .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget' ) + .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-query-' + this.model.getID() ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-table' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-icon' ) + .append( this.$icon ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-content' ) + .append( + this.$label + .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-label' ), + this.editInput.$element, + this.saveButton.$element + ), + this.popupButton.$element + .addClass( 'mw-rcfilters-ui-cell' ) + ) + ) + ); + }; + + /* Initialization */ + OO.inheritClass( mw.rcfilters.ui.SavedLinksListItemWidget, OO.ui.Widget ); + OO.mixinClass( mw.rcfilters.ui.SavedLinksListItemWidget, OO.ui.mixin.LabelElement ); + OO.mixinClass( mw.rcfilters.ui.SavedLinksListItemWidget, OO.ui.mixin.IconElement ); + + /* Events */ + + /** + * @event delete + * + * The delete option was selected for this item + */ + + /** + * @event default + * @param {boolean} default Item is default + * + * The 'make default' option was selected for this item + */ + + /** + * @event edit + * @param {string} newLabel New label for the query + * + * The label has been edited + */ + + /* Methods */ + + /** + * Respond to model update event + */ + mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onModelUpdate = function () { + this.setLabel( this.model.getLabel() ); + this.toggleDefault( this.model.isDefault() ); + }; + + /** + * Respond to click on the element or label + * + * @fires click + */ + mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onClick = function () { + if ( !this.editing ) { + this.emit( 'click' ); + } + }; + /** + * Respond to popup button click event + */ + mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onPopupButtonClick = function () { + this.menu.toggle(); + }; + + /** + * Respond to menu choose event + * + * @param {OO.ui.MenuOptionWidget} item Chosen item + * @fires delete + * @fires default + */ + mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onMenuChoose = function ( item ) { + var action = item.getData(); + + if ( action === 'edit' ) { + this.toggleEdit( true ); + } else if ( action === 'delete' ) { + this.emit( 'delete' ); + } else if ( action === 'default' ) { + this.emit( 'default', !this.default ); + } + this.menu.toggle( false ); + }; + + /** + * Respond to save button click + */ + mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onSaveButtonClick = function () { + this.emit( 'edit', this.editInput.getValue() ); + this.toggleEdit( false ); + }; + + /** + * Respond to input enter event + */ + mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onEditInputEnter = function () { + this.emit( 'edit', this.editInput.getValue() ); + this.toggleEdit( false ); + }; + + /** + * Respond to input keyup event, this is the way to intercept 'escape' key + * + * @param {jQuery.Event} e Event data + * @returns {boolean} false + */ + mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onInputKeyup = function ( e ) { + if ( e.which === OO.ui.Keys.ESCAPE ) { + // Return the input to the original label + this.editInput.setValue( this.getLabel() ); + this.toggleEdit( false ); + return false; + } + }; + + /** + * Respond to blur event on the input + */ + mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onInputBlur = function () { + this.emit( 'edit', this.editInput.getValue() ); + this.toggleEdit( false ); + }; + + /** + * Toggle edit mode on this widget + * + * @param {boolean} isEdit Widget is in edit mode + */ + mw.rcfilters.ui.SavedLinksListItemWidget.prototype.toggleEdit = function ( isEdit ) { + isEdit = isEdit === undefined ? !this.editing : isEdit; + + if ( this.editing !== isEdit ) { + this.$element.toggleClass( 'mw-rcfilters-ui-savedLinksListItemWidget-edit', isEdit ); + this.editInput.setValue( this.getLabel() ); + + this.editInput.toggle( isEdit ); + this.$label.toggleClass( 'oo-ui-element-hidden', isEdit ); + this.popupButton.toggle( !isEdit ); + this.saveButton.toggle( isEdit ); + + if ( isEdit ) { + this.editInput.$input.focus(); + } + this.editing = isEdit; + } + }; + + /** + * Toggle default this widget + * + * @param {boolean} isDefault This item is default + */ + mw.rcfilters.ui.SavedLinksListItemWidget.prototype.toggleDefault = function ( isDefault ) { + isDefault = isDefault === undefined ? !this.default : isDefault; + + if ( this.default !== isDefault ) { + this.default = isDefault; + this.setIcon( this.default ? 'pushPin' : '' ); + this.menu.getItemFromData( 'default' ).setLabel( + this.default ? + mw.msg( 'rcfilters-savedqueries-unsetdefault' ) : + mw.msg( 'rcfilters-savedqueries-setdefault' ) + ); + } + }; + + /** + * Get item ID + * + * @returns {string} Query identifier + */ + mw.rcfilters.ui.SavedLinksListItemWidget.prototype.getID = function () { + return this.model.getID(); + }; + +}( mediaWiki ) ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListWidget.js new file mode 100644 index 0000000000..6be9a7867a --- /dev/null +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListWidget.js @@ -0,0 +1,137 @@ +( function ( mw ) { + /** + * Quick links widget + * + * @extends OO.ui.Widget + * + * @constructor + * @param {mw.rcfilters.Controller} controller Controller + * @param {mw.rcfilters.dm.SavedQueriesModel} model View model + * @param {Object} [config] Configuration object + * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups + */ + mw.rcfilters.ui.SavedLinksListWidget = function MwRcfiltersUiSavedLinksListWidget( controller, model, config ) { + config = config || {}; + + // Parent + mw.rcfilters.ui.SavedLinksListWidget.parent.call( this, config ); + + this.controller = controller; + this.model = model; + this.$overlay = config.$overlay || this.$element; + + // The only reason we're using "ButtonGroupWidget" here is that + // straight-out "GroupWidget" is a mixin and cannot be initialized + // on its own, so we need something to be its widget. + this.menu = new OO.ui.ButtonGroupWidget( { + classes: [ 'mw-rcfilters-ui-savedLinksListWidget-menu' ] + } ); + this.button = new OO.ui.PopupButtonWidget( { + classes: [ 'mw-rcfilters-ui-savedLinksListWidget-button' ], + label: mw.msg( 'rcfilters-quickfilters' ), + icon: 'unClip', + $overlay: this.$overlay, + popup: { + width: 250, + anchor: false, + align: 'forwards', + $autoCloseIgnore: this.$overlay, + $content: this.menu.$element + } + } ); + + this.menu.aggregate( { + click: 'menuItemClick', + 'delete': 'menuItemDelete', + 'default': 'menuItemDefault', + edit: 'menuItemEdit' + } ); + + // Events + this.model.connect( this, { + add: 'onModelAddItem', + remove: 'onModelRemoveItem' + } ); + this.menu.connect( this, { + menuItemClick: 'onMenuItemClick', + menuItemDelete: 'onMenuItemRemove', + menuItemDefault: 'onMenuItemDefault', + menuItemEdit: 'onMenuItemEdit' + } ); + + this.button.toggle( !this.menu.isEmpty() ); + // Initialize + this.$element + .addClass( 'mw-rcfilters-ui-savedLinksListWidget' ) + .append( this.button.$element ); + }; + + /* Initialization */ + OO.inheritClass( mw.rcfilters.ui.SavedLinksListWidget, OO.ui.Widget ); + + /** + * Respond to menu item click event + * + * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item + */ + mw.rcfilters.ui.SavedLinksListWidget.prototype.onMenuItemClick = function ( item ) { + this.controller.applySavedQuery( item.getID() ); + this.button.popup.toggle( false ); + }; + + /** + * Respond to menu item remove event + * + * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item + */ + mw.rcfilters.ui.SavedLinksListWidget.prototype.onMenuItemRemove = function ( item ) { + this.controller.removeSavedQuery( item.getID() ); + this.menu.removeItems( [ item ] ); + }; + + /** + * Respond to menu item default event + * + * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item + * @param {boolean} isDefault Item is default + */ + mw.rcfilters.ui.SavedLinksListWidget.prototype.onMenuItemDefault = function ( item, isDefault ) { + this.controller.setDefaultSavedQuery( isDefault ? item.getID() : null ); + }; + + /** + * Respond to menu item edit event + * + * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item + * @param {string} newLabel New label + */ + mw.rcfilters.ui.SavedLinksListWidget.prototype.onMenuItemEdit = function ( item, newLabel ) { + this.controller.renameSavedQuery( item.getID(), newLabel ); + }; + + /** + * Respond to menu add item event + * + * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item + */ + mw.rcfilters.ui.SavedLinksListWidget.prototype.onModelAddItem = function ( item ) { + if ( this.menu.getItemFromData( item.getID() ) ) { + return; + } + + this.menu.addItems( [ + new mw.rcfilters.ui.SavedLinksListItemWidget( item, { $overlay: this.$overlay } ) + ] ); + this.button.toggle( !this.menu.isEmpty() ); + }; + + /** + * Respond to menu remove item event + * + * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item + */ + mw.rcfilters.ui.SavedLinksListWidget.prototype.onModelRemoveItem = function ( item ) { + this.menu.removeItems( [ this.model.getItemByID( item.getID() ) ] ); + this.button.toggle( !this.menu.isEmpty() ); + }; +}( mediaWiki ) ); diff --git a/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js b/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js index 8071d6e864..bc266fbc5f 100644 --- a/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js +++ b/tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js @@ -835,93 +835,6 @@ ); } ); - QUnit.test( 'setFiltersToDefaults', function ( assert ) { - var definition = [ { - name: 'group1', - title: 'Group 1', - type: 'send_unselected_if_any', - filters: [ - { - name: 'hidefilter1', - label: 'Show filter 1', - description: 'Description of Filter 1 in Group 1', - default: true - }, - { - name: 'hidefilter2', - label: 'Show filter 2', - description: 'Description of Filter 2 in Group 1' - }, - { - name: 'hidefilter3', - label: 'Show filter 3', - description: 'Description of Filter 3 in Group 1', - default: true - } - ] - }, { - name: 'group2', - title: 'Group 2', - type: 'send_unselected_if_any', - filters: [ - { - name: 'hidefilter4', - label: 'Show filter 4', - description: 'Description of Filter 1 in Group 2' - }, - { - name: 'hidefilter5', - label: 'Show filter 5', - description: 'Description of Filter 2 in Group 2', - default: true - }, - { - name: 'hidefilter6', - label: 'Show filter 6', - description: 'Description of Filter 3 in Group 2' - } - ] - } ], - defaultFilterRepresentation = { - // Group 1 and 2, "send_unselected_if_any", the values of the filters are "flipped" from the values of the parameters - group1__hidefilter1: false, - group1__hidefilter2: true, - group1__hidefilter3: false, - group2__hidefilter4: true, - group2__hidefilter5: false, - group2__hidefilter6: true - }, - model = new mw.rcfilters.dm.FiltersViewModel(); - - model.initializeFilters( definition ); - - assert.deepEqual( - model.getSelectedState(), - { - group1__hidefilter1: false, - group1__hidefilter2: false, - group1__hidefilter3: false, - group2__hidefilter4: false, - group2__hidefilter5: false, - group2__hidefilter6: false - }, - 'Initial state: default filters are not selected (controller selects defaults explicitly).' - ); - - model.toggleFiltersSelected( { - group1__hidefilter1: false, - group1__hidefilter3: false - } ); - - model.setFiltersToDefaults(); - - assert.deepEqual( - model.getSelectedState(), - defaultFilterRepresentation, - 'Changing values of filters and then returning to defaults still results in default filters being selected.' - ); - } ); - QUnit.test( 'Filter interaction: subsets', function ( assert ) { var definition = [ { name: 'group1', -- 2.20.1