'label-message' => 'tog-hideminor',
'section' => 'rc/advancedrc',
];
+ $defaultPreferences['rcfilters-saved-queries'] = [
+ 'type' => 'api',
+ ];
if ( $config->get( 'RCWatchCategoryMembership' ) ) {
$defaultPreferences['hidecategorization'] = [
"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)",
"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.",
'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',
],
'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',
'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' => [
],
'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',
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.
--- /dev/null
+( 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 ) );
--- /dev/null
+( 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 ) );
--- /dev/null
+<?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>
--- /dev/null
+<?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>
--- /dev/null
+<?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>
( 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();
};
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 ) );
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.
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 {
--- /dev/null
+.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;
+ }
+ }
+ }
+
+}
--- /dev/null
+.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%;
+ }
+
+}
--- /dev/null
+.mw-rcfilters-ui-savedLinksListWidget {
+ float: right;
+
+ &-menu {
+ width: 100%;
+ }
+}
}
}
+// 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 );
+}
* @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' ]
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();
};
* @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 */
--- /dev/null
+( 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 ) );
--- /dev/null
+( 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 ) );
--- /dev/null
+( 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 ) );
);
} );
- 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',