RCFilters UI: Add a 'saved queries' quick filters feature
authorMoriel Schottlender <moriel@gmail.com>
Tue, 25 Apr 2017 23:59:50 +0000 (16:59 -0700)
committerMoriel Schottlender <moriel@gmail.com>
Mon, 8 May 2017 23:20:44 +0000 (16:20 -0700)
Bug: T151994
Bug: T164128
Change-Id: I5cede87633147736d3b4ee5b8ea178ae21bd441f

23 files changed:
includes/Preferences.php
languages/i18n/en.json
languages/i18n/qqq.json
resources/Resources.php
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueriesModel.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueryItemModel.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/images/clip.svg [new file with mode: 0644]
resources/src/mediawiki.rcfilters/images/pushPin.svg [new file with mode: 0644]
resources/src/mediawiki.rcfilters/images/unClip.svg [new file with mode: 0644]
resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js
resources/src/mediawiki.rcfilters/mw.rcfilters.init.js
resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterTagMultiselectWidget.less
resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.less [new file with mode: 0644]
resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SavedLinksListItemWidget.less [new file with mode: 0644]
resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SavedLinksListWidget.less [new file with mode: 0644]
resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.less
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListItemWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListWidget.js [new file with mode: 0644]
tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js

index b428e87..4017619 100644 (file)
@@ -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'] = [
index a704d39..b3e48af 100644 (file)
        "recentchanges-legend-plusminus": "(<em>±123</em>)",
        "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)",
index 27dc70a..929019f 100644 (file)
        "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.",
index eabe42f..3890a17 100644 (file)
@@ -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',
index 18a9094..298361f 100644 (file)
                                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() );
                        }
                } );
                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.
                } );
        };
 
+       /**
+        * 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 (file)
index 0000000..7131341
--- /dev/null
@@ -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 (file)
index 0000000..729aee3
--- /dev/null
@@ -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 (file)
index 0000000..86d1dbf
--- /dev/null
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+    <path d="M17.5 4.5v13.2L12 13.5l-5.5 4.2V4.5zM5 21l7-6 7 6V3H5z" fill-rule="evenodd"/>
+</svg>
diff --git a/resources/src/mediawiki.rcfilters/images/pushPin.svg b/resources/src/mediawiki.rcfilters/images/pushPin.svg
new file mode 100644 (file)
index 0000000..b852cd0
--- /dev/null
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+    <path d="M17.445 12.225c-.813-.935-1.775-.739-2.883-1.768-.55-.511-.498-2.36-.498-2.36s-.041-1.836.524-2.401c.39-.39 1.076-.49 1.475-.883a.973.973 0 0 0 .217-.317c.007-.013.014-.023.018-.035.035-.092.054-.2.064-.316.003-.03.017-.055.017-.085 0-.005-.003-.01-.004-.015.001-.008.004-.014.004-.022 0-.02-.015-.03-.017-.048a1.052 1.052 0 0 0-1.043-.974H8.681c-.555 0-.997.43-1.043.974-.002.018-.017.028-.017.048 0 .008.003.014.003.022 0 .006-.003.01-.003.015 0 .03.014.055.017.085.01.116.029.224.064.316.004.012.012.022.018.035a.965.965 0 0 0 .217.317c.399.393 1.084.493 1.475.883.565.565.523 2.401.523 2.401s.053 1.849-.497 2.36c-1.108 1.03-2.07.833-2.883 1.768C5.979 12.887 6 14 6 14h5.333v4.578L12 21l.668-2.422V14H18s.02-1.113-.555-1.775z"/>
+</svg>
diff --git a/resources/src/mediawiki.rcfilters/images/unClip.svg b/resources/src/mediawiki.rcfilters/images/unClip.svg
new file mode 100644 (file)
index 0000000..68459db
--- /dev/null
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+    <path d="M5 21l7-6 7 6V3H5z" fill-rule="evenodd"/>
+</svg>
index 669420c..35541d1 100644 (file)
@@ -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 */
         * @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
                        $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 );
                }
        };
 
                }
        };
 
+       /**
+        * 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
         *
         * 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 ) );
 
                params = params || {};
 
-               updatedUri = this.getUpdatedUri();
+               updatedUri = this._getUpdatedUri();
                updatedUri.extend( params );
 
                if ( notEquivalent( updatedUri.query, new mw.Uri().query ) ) {
         *
         * @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();
 
         * @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;
                        );
        };
 
-       /**
-        * 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(
                        }
                );
        };
+
 }( mediaWiki, jQuery ) );
index 4a586e4..dd8fae0 100644 (file)
                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 = $( '<div>' )
                                        .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.
index f1b6871..66e6d96 100644 (file)
                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 (file)
index 0000000..e19c246
--- /dev/null
@@ -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 (file)
index 0000000..76e1c48
--- /dev/null
@@ -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 (file)
index 0000000..e1e55a7
--- /dev/null
@@ -0,0 +1,7 @@
+.mw-rcfilters-ui-savedLinksListWidget {
+       float: right;
+
+       &-menu {
+               width: 100%;
+       }
+}
index 957e9e9..c0f24c6 100644 (file)
        }
 }
 
+// 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 );
+}
index c52ca1f..ea1d1c3 100644 (file)
@@ -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
                        }
                }, 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' ]
                // 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,
                        $( '<div>' )
                                .addClass( 'mw-rcfilters-ui-table' )
                                .append(
                                                        this.$content
                                                                .addClass( 'mw-rcfilters-ui-cell' )
                                                                .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-filters' ),
+                                                       $( '<div>' )
+                                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-save' )
+                                                               .append( this.saveQueryButton.$element ),
                                                        $( '<div>' )
                                                                .addClass( 'mw-rcfilters-ui-cell' )
                                                                .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-reset' )
                // Initialize
                this.$handle.append( $contentWrapper );
                this.emptyFilterMessage.toggle( this.isEmpty() );
+               this.savedQueryTitle.toggle( false );
 
                this.$element
                        .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget' );
 
        /* Methods */
 
+       /**
+        * Respond to query button click
+        */
+       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onSaveQueryButtonClick = function () {
+               this.getMenu().toggle( false );
+       };
+
        /**
         * Respond to menu toggle
         *
         */
        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
         *
                        this.removeTagByData( item.getName() );
                }
 
+               this.setSavedQueryVisibility();
+
                // Re-evaluate reset state
                this.reevaluateResetRestoreState();
        };
index b7ebf34..738a981 100644 (file)
@@ -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
 
                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 (file)
index 0000000..9b7a2fb
--- /dev/null
@@ -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 = $( '<div>' );
+
+               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(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-layout' )
+                                       .append( layout.$element ),
+                               $( '<div>' )
+                                       .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 (file)
index 0000000..3e6fb77
--- /dev/null
@@ -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(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-table' )
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-row' )
+                                                       .append(
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-icon' )
+                                                                       .append( this.$icon ),
+                                                               $( '<div>' )
+                                                                       .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 (file)
index 0000000..6be9a78
--- /dev/null
@@ -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 ) );
index 8071d6e..bc266fb 100644 (file)
                );
        } );
 
-       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',