From: Moriel Schottlender Date: Fri, 18 Aug 2017 21:06:09 +0000 (-0700) Subject: RCFilters: Convert saved queries from filters to parameters X-Git-Tag: 1.31.0-rc.0~1886^2 X-Git-Url: https://git.heureux-cyclage.org/?p=lhc%2Fweb%2Fwiklou.git;a=commitdiff_plain;h=c7b6b598ca248c520888affdefd429061ebc4302 RCFilters: Convert saved queries from filters to parameters This will allow us to load them in the backend, and to keep consistency between RecentChanges and Watchlist if needed. Added also a 'backup' preference to keep the previous version before the conversion, in case of mangling of the queries. Bug: T166908 Change-Id: I8e26b66e43bd16282b7bdb52abc152f92a9c877d --- diff --git a/includes/Preferences.php b/includes/Preferences.php index a7e6684bb3..792b63e857 100644 --- a/includes/Preferences.php +++ b/includes/Preferences.php @@ -935,6 +935,12 @@ class Preferences { $defaultPreferences['rcfilters-wl-saved-queries'] = [ 'type' => 'api', ]; + $defaultPreferences['rcfilters-saved-queries-versionbackup'] = [ + 'type' => 'api', + ]; + $defaultPreferences['rcfilters-wl-saved-queries-versionbackup'] = [ + 'type' => 'api', + ]; $defaultPreferences['rcfilters-rclimit'] = [ 'type' => 'api', ]; diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js index 4dc86f6f65..309978fa8a 100644 --- a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js @@ -816,6 +816,19 @@ return this.type; }; + /** + * Check whether this group is represented by a single parameter + * or whether each item is its own parameter + * + * @return {boolean} This group is a single parameter + */ + mw.rcfilters.dm.FilterGroup.prototype.isPerGroupRequestParameter = function () { + return ( + this.getType() === 'string_options' || + this.getType() === 'single_option' + ); + }; + /** * Get display group * diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js index 3b882a6ba7..62627e6661 100644 --- a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js @@ -598,6 +598,31 @@ return result; }; + /** + * Get the parameter names that represent filters that are excluded + * from saved queries. + * + * @return {string[]} Parameter names + */ + mw.rcfilters.dm.FiltersViewModel.prototype.getExcludedParams = function () { + var result = []; + + $.each( this.groups, function ( name, model ) { + if ( model.isExcludedFromSavedQueries() ) { + if ( model.isPerGroupRequestParameter() ) { + result.push( name ); + } else { + // Each filter is its own param + result = result.concat( model.getItems().map( function ( filterItem ) { + return filterItem.getParamName(); + } ) ); + } + } + } ); + + return result; + }; + /** * Analyze the groups and their filters and output an object representing * the state of the parameters they represent. diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueriesModel.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueriesModel.js index f87894159f..2b17897b04 100644 --- a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueriesModel.js +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueriesModel.js @@ -7,10 +7,11 @@ * @mixins OO.EmitterList * * @constructor + * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters model * @param {Object} [config] Configuration options * @cfg {string} [default] Default query ID */ - mw.rcfilters.dm.SavedQueriesModel = function MwRcfiltersDmSavedQueriesModel( config ) { + mw.rcfilters.dm.SavedQueriesModel = function MwRcfiltersDmSavedQueriesModel( filtersModel, config ) { config = config || {}; // Mixin constructor @@ -18,7 +19,8 @@ OO.EmitterList.call( this ); this.default = config.default; - this.baseState = {}; + this.filtersModel = filtersModel; + this.converted = false; // Events this.aggregate( { update: 'itemUpdate' } ); @@ -58,6 +60,9 @@ * Initialize the saved queries model by reading it from the user's settings. * The structure of the saved queries is: * { + * version: (string) Version number; if version 2, the query represents + * parameters. Otherwise, the older version represented filters + * and needs to be readjusted, * default: (string) Query ID * queries:{ * query_id_1: { @@ -72,62 +77,175 @@ * * @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 - * @param {string[]} [ignoreFilters] Filters to ignore and remove from - * the data * @fires initialize */ - mw.rcfilters.dm.SavedQueriesModel.prototype.initialize = function ( savedQueries, baseState, ignoreFilters ) { - var items = [], - defaultItem = null; + mw.rcfilters.dm.SavedQueriesModel.prototype.initialize = function ( savedQueries ) { + var model = this, + excludedParams = this.filtersModel.getExcludedParams(); savedQueries = savedQueries || {}; - ignoreFilters = ignoreFilters || {}; - - this.baseState = baseState; this.clearItems(); + this.default = null; + this.converted = false; + + if ( savedQueries.version !== '2' ) { + // Old version dealt with filter names. We need to migrate to the new structure + // The new structure: + // { + // version: (string) '2', + // default: (string) Query ID, + // queries: { + // query_id: { + // label: (string) Name of the query + // data: { + // params: (object) Representing all the parameter states + // highlights: (object) Representing all the filter highlight states + // } + // } + // } + $.each( savedQueries.queries || {}, function ( id, obj ) { + if ( obj.data && obj.data.filters ) { + obj.data = model.convertToParameters( obj.data ); + } + } ); + + this.converted = true; + savedQueries.version = '2'; + } + + // Initialize the query items $.each( savedQueries.queries || {}, function ( id, obj ) { - var item, - normalizedData = $.extend( true, {}, baseState, obj.data ), + var normalizedData = obj.data, isDefault = String( savedQueries.default ) === String( id ); - // Backwards-compat fix: We stored the 'highlight' state with - // "1" and "0" instead of true/false; for already-stored states, - // we need to fix that. - // NOTE: Since this feature is only available in beta, we should - // not need this line when we release this to the general wikis. - // This method will automatically fix all saved queries anyways - // for existing users, who are only betalabs users at the moment. - normalizedData.highlights.highlight = !!Number( normalizedData.highlights.highlight ); - - // Backwards-compat fix: Remove sticky parameters from the 'ignoreFilters' list - ignoreFilters.forEach( function ( name ) { - delete normalizedData.filters[ name ]; - } ); + if ( normalizedData && normalizedData.params ) { + // Backwards-compat fix: Remove excluded parameters from + // the given data, if they exist + excludedParams.forEach( function ( name ) { + delete normalizedData.params[ name ]; + } ); - item = new mw.rcfilters.dm.SavedQueryItemModel( - id, - obj.label, - normalizedData, - { 'default': isDefault } - ); + id = String( id ); + model.addNewQuery( obj.label, normalizedData, isDefault, id ); - if ( isDefault ) { - defaultItem = item; + if ( isDefault ) { + model.default = id; + } } + } ); - items.push( item ); + this.emit( 'initialize' ); + }; + + /** + * Convert from representation of filters to representation of parameters + * + * @param {Object} data Query data + * @return {Object} New converted query data + */ + mw.rcfilters.dm.SavedQueriesModel.prototype.convertToParameters = function ( data ) { + var newData = {}, + defaultFilters = this.filtersModel.getFiltersFromParameters( this.filtersModel.getDefaultParams() ), + fullFilterRepresentation = $.extend( true, {}, defaultFilters, data.filters ), + highlightEnabled = data.highlights.highlight; + + delete data.highlights.highlight; + + // Filters + newData.params = this.filtersModel.getParametersFromFilters( fullFilterRepresentation ); + + // Highlights (taking out 'highlight' itself, appending _color to keys) + newData.highlights = {}; + Object.keys( data.highlights ).forEach( function ( highlightedFilterName ) { + newData.highlights[ highlightedFilterName + '_color' ] = data.highlights[ highlightedFilterName ]; } ); - if ( defaultItem ) { - this.default = defaultItem.getID(); + // Add highlight and invert toggles to params + newData.params.highlight = String( Number( highlightEnabled || 0 ) ); + newData.params.invert = String( Number( data.invert || 0 ) ); + + return newData; + }; + + /** + * 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 params, 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 params + * are stored); When we initialize the system, we merge each minimal + * query with the base state (using 'getMinimalParamList') 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 #getMinimalParamList does + * + * @return {Object} Base parameter state + */ + mw.rcfilters.dm.SavedQueriesModel.prototype.getBaseParamState = function () { + var allParams, + highlightedItems = {}; + + if ( !this.baseParamState ) { + allParams = this.filtersModel.getParametersFromFilters( {} ); + + // Prepare highlights + this.filtersModel.getItemsSupportingHighlights().forEach( function ( item ) { + highlightedItems[ item.getName() + '_color' ] = null; + } ); + + this.baseParamState = { + params: $.extend( true, { invert: '0', highlight: '0' }, allParams ), + highlights: highlightedItems + }; } - this.addItems( items ); + return this.baseParamState; + }; - this.emit( 'initialize' ); + /** + * Get an object that holds only the parameters and highlights that have + * values different than the base 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.dm.SavedQueriesModel.prototype.getMinimalParamList = function ( valuesObject ) { + var result = { params: {}, highlights: {} }, + baseState = this.getBaseParamState(); + + // XOR results + $.each( valuesObject.params, function ( name, value ) { + if ( baseState.params !== undefined && baseState.params[ name ] !== value ) { + result.params[ name ] = value; + } + } ); + + $.each( valuesObject.highlights, function ( name, value ) { + if ( baseState.highlights !== undefined && baseState.highlights[ name ] !== value ) { + result.highlights[ name ] = value; + } + } ); + + return result; }; /** @@ -135,21 +253,29 @@ * * @param {string} label Label for the new query * @param {Object} data Data for the new query + * @param {boolean} isDefault Item is default + * @param {string} [id] Query ID, if exists. If this isn't given, a random + * new ID will be created. * @return {string} ID of the newly added query */ - mw.rcfilters.dm.SavedQueriesModel.prototype.addNewQuery = function ( label, data ) { - var randomID = ( new Date() ).getTime(), - normalizedData = $.extend( true, {}, this.baseState, data ); + mw.rcfilters.dm.SavedQueriesModel.prototype.addNewQuery = function ( label, data, isDefault, id ) { + var randomID = String( id || ( new Date() ).getTime() ), + normalizedData = this.getMinimalParamList( data ); // Add item this.addItems( [ new mw.rcfilters.dm.SavedQueryItemModel( randomID, label, - normalizedData + normalizedData, + { 'default': isDefault } ) ] ); + if ( isDefault ) { + this.setDefault( randomID ); + } + return randomID; }; @@ -179,44 +305,17 @@ * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model */ mw.rcfilters.dm.SavedQueriesModel.prototype.findMatchingQuery = function ( fullQueryComparison ) { - var model = this; - - fullQueryComparison = this.getDifferenceFromBase( fullQueryComparison ); + // Minimize before comparison + fullQueryComparison = this.getMinimalParamList( fullQueryComparison ); return this.getItems().filter( function ( item ) { - var comparedData = model.getDifferenceFromBase( item.getData() ); return OO.compare( - comparedData, + item.getData(), fullQueryComparison ); } )[ 0 ]; }; - /** - * Get a minimal representation of the state for comparison - * - * @param {Object} state Given state - * @return {Object} Minimal state - */ - mw.rcfilters.dm.SavedQueriesModel.prototype.getDifferenceFromBase = function ( state ) { - var result = { filters: {}, highlights: {}, invert: state.invert }, - baseState = this.baseState; - - // XOR results - $.each( state.filters, function ( name, value ) { - if ( baseState.filters !== undefined && baseState.filters[ name ] !== value ) { - result.filters[ name ] = value; - } - } ); - - $.each( state.highlights, function ( name, value ) { - if ( baseState.highlights !== undefined && baseState.highlights[ name ] !== value && name !== 'highlight' ) { - result.highlights[ name ] = value; - } - } ); - - return result; - }; /** * Get query by its identifier * @@ -230,18 +329,34 @@ } )[ 0 ]; }; + /** + * Get an item's full data + * + * @param {string} queryID Query identifier + * @return {Object} Item's full data + */ + mw.rcfilters.dm.SavedQueriesModel.prototype.getItemFullData = function ( queryID ) { + var item = this.getItemByID( queryID ); + + // Fill in the base params + return item ? $.extend( true, {}, this.getBaseParamState(), item.getData() ) : {}; + }; + /** * 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: {} }; + var model = this, + obj = { queries: {}, version: '2' }; // Translate the items to the saved object this.getItems().forEach( function ( item ) { var itemState = item.getState(); + itemState.data = model.getMinimalParamList( itemState.data ); + obj.queries[ item.getID() ] = itemState; } ); @@ -279,4 +394,14 @@ mw.rcfilters.dm.SavedQueriesModel.prototype.getDefault = function () { return this.default; }; + + /** + * Check if the saved queries were converted + * + * @return {boolean} Saved queries were converted from the previous + * version to the new version + */ + mw.rcfilters.dm.SavedQueriesModel.prototype.isConverted = function () { + return this.converted; + }; }( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js index ed2a73f343..685adb6811 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js @@ -21,6 +21,7 @@ this.baseFilterState = {}; this.uriProcessor = null; this.initializing = false; + this.wereSavedQueriesSaved = false; this.prevLoggedItems = []; @@ -201,8 +202,6 @@ // Initialize the model this.filtersModel.initializeFilters( filterStructure, views ); - this._buildBaseFilterState(); - this.uriProcessor = new mw.rcfilters.UriProcessor( this.filtersModel ); @@ -214,15 +213,13 @@ 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._getBaseFilterState(), - // This is for backwards compatibility - delete all excluded filter states - Object.keys( this.filtersModel.getExcludedFiltersState() ) - ); + // Initialize saved queries + this.savedQueriesModel.initialize( parsedSavedQueries ); + if ( this.savedQueriesModel.isConverted() ) { + // Since we know we converted, we're going to re-save + // the queries so they are now migrated to the new format + this._saveSavedQueries(); + } } // Check whether we need to load defaults. @@ -375,10 +372,19 @@ * @return {boolean} Defaults are all false */ mw.rcfilters.Controller.prototype.areDefaultsEmpty = function () { - var defaultFilters = this.filtersModel.getFiltersFromParameters( this._getDefaultParams() ); + var defaultParams = this._getDefaultParams(), + defaultFilters = this.filtersModel.getFiltersFromParameters( defaultParams ); this._deleteExcludedValuesFromFilterState( defaultFilters ); + if ( Object.keys( defaultParams ).some( function ( paramName ) { + return paramName.endsWith( '_color' ) && defaultParams[ paramName ] !== null; + } ) ) { + // There are highlights in the defaults, they're definitely + // not empty + return false; + } + // Defaults can change in a session, so we need to do this every time return Object.keys( defaultFilters ).every( function ( filterName ) { return !defaultFilters[ filterName ]; @@ -606,36 +612,36 @@ * @param {boolean} [setAsDefault=false] This query should be set as the default */ mw.rcfilters.Controller.prototype.saveCurrentQuery = function ( label, setAsDefault ) { - var queryID, - highlightedItems = {}, + var highlightedItems = {}, highlightEnabled = this.filtersModel.isHighlightEnabled(), selectedState = this.filtersModel.getSelectedState(); // Prepare highlights this.filtersModel.getHighlightedItems().forEach( function ( item ) { - highlightedItems[ item.getName() ] = highlightEnabled ? + highlightedItems[ item.getName() + '_color' ] = highlightEnabled ? item.getHighlightColor() : null; } ); - // These are filter states; highlight is stored as boolean - highlightedItems.highlight = this.filtersModel.isHighlightEnabled(); // Delete all excluded filters this._deleteExcludedValuesFromFilterState( selectedState ); // Add item - queryID = this.savedQueriesModel.addNewQuery( + this.savedQueriesModel.addNewQuery( label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ), { - filters: selectedState, - highlights: highlightedItems, - invert: this.filtersModel.areNamespacesInverted() - } + params: $.extend( + true, + { + invert: String( Number( this.filtersModel.areNamespacesInverted() ) ), + highlight: String( Number( this.filtersModel.isHighlightEnabled() ) ) + }, + this.filtersModel.getParametersFromFilters( selectedState ) + ), + highlights: highlightedItems + }, + setAsDefault ); - if ( setAsDefault ) { - this.savedQueriesModel.setDefault( queryID ); - } - // Save item this._saveSavedQueries(); }; @@ -683,8 +689,9 @@ * @param {string} queryID Query id */ mw.rcfilters.Controller.prototype.applySavedQuery = function ( queryID ) { - var data, highlights, + var highlights, queryItem = this.savedQueriesModel.getItemByID( queryID ), + data = this.savedQueriesModel.getItemFullData( queryID ), currentMatchingQuery = this.findQueryMatchingCurrentState(); if ( @@ -696,25 +703,26 @@ currentMatchingQuery.getID() !== queryItem.getID() ) ) { - data = queryItem.getData(); highlights = data.highlights; - // Backwards compatibility; initial version mispelled 'highlight' with 'highlights' - highlights.highlight = highlights.highlights || highlights.highlight; - // Update model state from filters this.filtersModel.toggleFiltersSelected( // Merge filters with excluded values - $.extend( true, {}, data.filters, this.filtersModel.getExcludedFiltersState() ) + $.extend( + true, + {}, + this.filtersModel.getFiltersFromParameters( data.params ), + this.filtersModel.getExcludedFiltersState() + ) ); // Update namespace inverted property - this.filtersModel.toggleInvertedNamespaces( !!Number( data.invert ) ); + this.filtersModel.toggleInvertedNamespaces( !!Number( data.params.invert ) ); // Update highlight state - this.filtersModel.toggleHighlight( !!Number( highlights.highlight ) ); + this.filtersModel.toggleHighlight( !!Number( data.params.highlight ) ); this.filtersModel.getItems().forEach( function ( filterItem ) { - var color = highlights[ filterItem.getName() ]; + var color = highlights[ filterItem.getName() + '_color' ]; if ( color ) { filterItem.setHighlightColor( color ); } else { @@ -744,9 +752,8 @@ // Prepare highlights of the current query this.filtersModel.getItemsSupportingHighlights().forEach( function ( item ) { - highlightedItems[ item.getName() ] = item.getHighlightColor(); + highlightedItems[ item.getName() + '_color' ] = item.getHighlightColor(); } ); - highlightedItems.highlight = this.filtersModel.isHighlightEnabled(); // Remove anything that should be excluded from the saved query // this includes sticky filters and filters marked with 'excludedFromSavedQueries' @@ -754,9 +761,15 @@ return this.savedQueriesModel.findMatchingQuery( { - filters: selectedState, - highlights: highlightedItems, - invert: this.filtersModel.areNamespacesInverted() + params: $.extend( + true, + { + highlight: String( Number( this.filtersModel.isHighlightEnabled() ) ), + invert: String( Number( this.filtersModel.areNamespacesInverted() ) ) + }, + this.filtersModel.getParametersFromFilters( selectedState ) + ), + highlights: highlightedItems } ); }; @@ -773,113 +786,14 @@ } ); }; - /** - * 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.highlight = false; - - this.baseFilterState = { - filters: this.filtersModel.getFiltersFromParameters( defaultParams ), - highlights: highlightedItems, - invert: false - }; - }; - - /** - * Get an object representing the base filter state of both - * filters 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._getBaseFilterState = function () { - return this.baseFilterState; - }; - - /** - * 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: {}, invert: valuesObject.invert }, - baseState = this._getBaseFilterState(); - - // 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 ); - } ); + var stringified, oldPrefValue, + backupPrefName = this.savedQueriesPreferenceName + '-versionbackup', + state = this.savedQueriesModel.getState(); // Stringify state stringified = JSON.stringify( state ); @@ -889,10 +803,24 @@ return; } + if ( !this.wereSavedQueriesSaved && this.savedQueriesModel.isConverted() ) { + // The queries were converted from the previous version + // Keep the old string in the [prefname]-versionbackup + oldPrefValue = mw.user.options.get( this.savedQueriesPreferenceName ); + + // Save the old preference in the backup preference + new mw.Api().saveOption( backupPrefName, oldPrefValue ); + // Update the preference for this session + mw.user.options.set( backupPrefName, oldPrefValue ); + } + // Save the preference new mw.Api().saveOption( this.savedQueriesPreferenceName, stringified ); // Update the preference for this session mw.user.options.set( this.savedQueriesPreferenceName, stringified ); + + // Tag as already saved so we don't do this again + this.wereSavedQueriesSaved = true; }; /** @@ -1056,30 +984,24 @@ * @return {Object} Default parameters */ mw.rcfilters.Controller.prototype._getDefaultParams = function () { - var data, queryHighlights, - savedParams = {}, - savedHighlights = {}, - defaultSavedQueryItem = !mw.user.isAnon() && this.savedQueriesModel.getItemByID( this.savedQueriesModel.getDefault() ); - - if ( defaultSavedQueryItem ) { - data = defaultSavedQueryItem.getData(); - - queryHighlights = data.highlights || {}; - savedParams = this.filtersModel.getParametersFromFilters( - $.extend( true, {}, data.filters, this.filtersModel.getStickyFiltersState() ) + var savedFilters, + data = ( !mw.user.isAnon() && this.savedQueriesModel.getItemFullData( this.savedQueriesModel.getDefault() ) ) || {}; + + if ( !$.isEmptyObject( data ) ) { + // Merge saved filter state with sticky filter values + savedFilters = $.extend( + true, {}, + this.filtersModel.getFiltersFromParameters( data.params ), + this.filtersModel.getStickyFiltersState() ); - // Translate highlights to parameters - savedHighlights.highlight = String( Number( queryHighlights.highlight ) ); - $.each( queryHighlights, function ( filterName, color ) { - if ( filterName !== 'highlights' ) { - savedHighlights[ filterName + '_color' ] = color; - } - } ); - - return $.extend( true, {}, savedParams, savedHighlights, { invert: String( Number( data.invert || 0 ) ) } ); + // Return parameter representation + return $.extend( true, {}, + this.filtersModel.getParametersFromFilters( savedFilters ), + data.highlights, + { highlight: data.params.highlight, invert: data.params.invert } + ); } - return this.filtersModel.getDefaultParams(); }; diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js index 5ab32eaf85..40d8ee86f6 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js @@ -15,7 +15,7 @@ savedQueriesPreferenceName = mw.config.get( 'wgStructuredChangeFiltersSavedQueriesPreferenceName' ), filtersModel = new mw.rcfilters.dm.FiltersViewModel(), changesListModel = new mw.rcfilters.dm.ChangesListViewModel(), - savedQueriesModel = new mw.rcfilters.dm.SavedQueriesModel(), + savedQueriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ), controller = new mw.rcfilters.Controller( filtersModel, changesListModel, savedQueriesModel, { diff --git a/tests/qunit/QUnitTestResources.php b/tests/qunit/QUnitTestResources.php index 7367560e90..cd0ac15cef 100644 --- a/tests/qunit/QUnitTestResources.php +++ b/tests/qunit/QUnitTestResources.php @@ -93,6 +93,8 @@ return [ 'tests/qunit/suites/resources/mediawiki.special/mediawiki.special.recentchanges.test.js', 'tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js', 'tests/qunit/suites/resources/mediawiki.rcfilters/dm.FilterItem.test.js', + 'tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueryItemModel.test.js', + 'tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueriesModel.test.js', 'tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.cldr.test.js', diff --git a/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueriesModel.test.js b/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueriesModel.test.js new file mode 100644 index 0000000000..324a652ce4 --- /dev/null +++ b/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueriesModel.test.js @@ -0,0 +1,304 @@ +/* eslint-disable camelcase */ +( function ( mw ) { + var filterDefinition = [ { + name: 'group1', + type: 'send_unselected_if_any', + filters: [ + // Note: The fact filter2 is default means that in the + // filter representation, filter1 and filter3 are 'true' + { name: 'filter1' }, + { name: 'filter2', default: true }, + { name: 'filter3' } + ] + }, { + name: 'group2', + type: 'string_options', + separator: ',', + filters: [ + { name: 'filter4' }, + { name: 'filter5' }, + { name: 'filter6' } + ] + }, { + name: 'group3', + type: 'boolean', + isSticky: true, + filters: [ + { name: 'group3option1' }, + { name: 'group3option2' }, + { name: 'group3option3' } + ] + } ], + queriesFilterRepresentation = { + queries: { + 1234: { + label: 'Item converted', + data: { + filters: { + // - This value is true, but the original filter-representation + // of the saved queries ran against defaults. Since filter1 was + // set as default in the definition, the value would actually + // not appear in the representation itself. + // It is considered 'true', though, and should appear in the + // converted result in its parameter representation. + // >> group1__filter1: true, + // - The reverse is true for filter3. Filter3 is set as default + // but we don't want it in this representation of the saved query. + // Since the filter representation ran against default values, + // it will appear as 'false' value in this representation explicitly + // and the resulting parameter representation should have that + // as the result as well + group1__filter3: false, + group2__filter4: true, + group3__group3option1: true + }, + highlights: { + highlight: true, + filter1: 'c5', + group3option1: 'c1' + }, + invert: true + } + } + } + }, + queriesParamRepresentation = { + version: '2', + queries: { + 1234: { + label: 'Item converted', + data: { + params: { + // filter1 is 'true' so filter2 and filter3 are both '1' + // in param representation + filter2: '1', filter3: '1', + // Group type string_options + group2: 'filter4', + // Note - Group3 is sticky, so it won't show in output + // Invert/highlight toggles + invert: '1', + highlight: '1' + }, + highlights: { + filter1_color: 'c5', + group3option1_color: 'c1' + } + } + } + } + }; + + QUnit.module( 'mediawiki.rcfilters - SavedQueriesModel' ); + + QUnit.test( 'Initializing queries', function ( assert ) { + var filtersModel = new mw.rcfilters.dm.FiltersViewModel(), + queriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ), + exampleQueryStructure = { + version: '2', + default: '1234', + queries: { + 1234: { + label: 'Query 1234', + data: { + params: { + filter2: '1' + }, + highlights: { + filter5_color: 'c2' + } + } + } + } + }, + cases = [ + { + input: {}, + finalState: { version: '2', queries: {} }, + msg: 'Empty initial query structure results in base saved queries structure.' + }, + { + input: $.extend( true, {}, exampleQueryStructure ), + finalState: $.extend( true, {}, exampleQueryStructure ), + msg: 'Initialization of given query structure does not corrupt the structure.' + }, + { + // Converting from old structure + input: $.extend( true, {}, queriesFilterRepresentation ), + finalState: $.extend( true, {}, queriesParamRepresentation ), + msg: 'Conversion from filter representation to parameters retains data.' + }, + { + // Converting from old structure with default + input: $.extend( true, { default: '1234' }, queriesFilterRepresentation ), + finalState: $.extend( true, { default: '1234' }, queriesParamRepresentation ), + msg: 'Conversion from filter representation to parameters, with default set up, retains data.' + }, + { + // New structure + input: $.extend( true, {}, queriesParamRepresentation ), + finalState: $.extend( true, {}, queriesParamRepresentation ), + msg: 'Parameter representation retains its queries structure' + } + ]; + + filtersModel.initializeFilters( filterDefinition ); + + cases.forEach( function ( testCase ) { + queriesModel.initialize( testCase.input ); + assert.deepEqual( + queriesModel.getState(), + testCase.finalState, + testCase.msg + ); + } ); + } ); + + QUnit.test( 'Manipulating queries', function ( assert ) { + var id1, id2, item1, matchingItem, + queriesStructure = {}, + filtersModel = new mw.rcfilters.dm.FiltersViewModel(), + queriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ); + + filtersModel.initializeFilters( filterDefinition ); + + // Start with an empty saved queries model + queriesModel.initialize( {} ); + + // Add items + id1 = queriesModel.addNewQuery( + 'New query 1', + { + params: { + group2: 'filter5', + highlight: '1' + }, + highlights: { + filter1_color: 'c5', + group3option1_color: 'c1' + } + } + ); + id2 = queriesModel.addNewQuery( + 'New query 2', + { + params: { + filter1: '1', + filter2: '1', + invert: '1' + }, + highlights: {} + } + ); + item1 = queriesModel.getItemByID( id1 ); + + assert.equal( + item1.getID(), + id1, + 'Item created and its data retained successfully' + ); + + // NOTE: All other methods that the item itself returns are + // tested in the dm.SavedQueryItemModel.test.js file + + // Build the query structure we expect per item + queriesStructure[ id1 ] = { + label: 'New query 1', + data: { + params: { + group2: 'filter5', + highlight: '1' + }, + highlights: { + filter1_color: 'c5', + group3option1_color: 'c1' + } + } + }; + queriesStructure[ id2 ] = { + label: 'New query 2', + data: { + params: { + filter1: '1', + filter2: '1', + invert: '1' + }, + highlights: {} + } + }; + + assert.deepEqual( + queriesModel.getState(), + { + version: '2', + queries: queriesStructure + }, + 'Full query represents current state of items' + ); + + // Add default + queriesModel.setDefault( id2 ); + + assert.deepEqual( + queriesModel.getState(), + { + version: '2', + default: id2, + queries: queriesStructure + }, + 'Setting default is reflected in queries state' + ); + + // Remove default + queriesModel.setDefault( null ); + + assert.deepEqual( + queriesModel.getState(), + { + version: '2', + queries: queriesStructure + }, + 'Removing default is reflected in queries state' + ); + + // Find matching query + matchingItem = queriesModel.findMatchingQuery( + { + params: { + group2: 'filter5', + highlight: '1' + }, + highlights: { + filter1_color: 'c5', + group3option1_color: 'c1' + } + } + ); + assert.deepEqual( + matchingItem.getID(), + id1, + 'Finding matching item by identical state' + ); + + // Find matching query with 0-values (base state) + matchingItem = queriesModel.findMatchingQuery( + { + params: { + group2: 'filter5', + filter1: '0', + filter2: '0', + highlight: '1', + invert: '0' + }, + highlights: { + filter1_color: 'c5', + group3option1_color: 'c1' + } + } + ); + assert.deepEqual( + matchingItem.getID(), + id1, + 'Finding matching item by "dirty" state with 0-base values' + ); + } ); +}( mediaWiki ) ); diff --git a/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueryItemModel.test.js b/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueryItemModel.test.js new file mode 100644 index 0000000000..a91dff99a1 --- /dev/null +++ b/tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueryItemModel.test.js @@ -0,0 +1,90 @@ +/* eslint-disable camelcase */ +( function ( mw ) { + var itemData = { + params: { + param1: '1', + param2: 'foo|bar', + highlight: '1', + invert: '0' + }, + highlights: { + param1_color: 'c1', + param2_color: 'c2' + } + }; + + QUnit.module( 'mediawiki.rcfilters - SavedQueryItemModel' ); + + QUnit.test( 'Initializing and getters', function ( assert ) { + var model; + + model = new mw.rcfilters.dm.SavedQueryItemModel( + 'randomID', + 'Some label', + $.extend( true, {}, itemData ) + ); + + assert.equal( + model.getID(), + 'randomID', + 'Item ID is retained' + ); + + assert.equal( + model.getLabel(), + 'Some label', + 'Item label is retained' + ); + + assert.deepEqual( + model.getData(), + itemData, + 'Item data is retained' + ); + + assert.ok( + !model.isDefault(), + 'Item default state is retained.' + ); + } ); + + QUnit.test( 'Default', function ( assert ) { + var model; + + model = new mw.rcfilters.dm.SavedQueryItemModel( + 'randomID', + 'Some label', + $.extend( true, {}, itemData ) + ); + + assert.ok( + !model.isDefault(), + 'Default state represented when item initialized with default:false.' + ); + + model.toggleDefault( true ); + assert.ok( + model.isDefault(), + 'Default state toggles to true successfully' + ); + + model.toggleDefault( false ); + assert.ok( + !model.isDefault(), + 'Default state toggles to false successfully' + ); + + // Reset + model = new mw.rcfilters.dm.SavedQueryItemModel( + 'randomID', + 'Some label', + $.extend( true, {}, itemData ), + { default: true } + ); + + assert.ok( + model.isDefault(), + 'Default state represented when item initialized with default:true.' + ); + } ); +}( mediaWiki ) );