From 85876bbc718520d6e43c0e86762e1f2ebd28d371 Mon Sep 17 00:00:00 2001 From: Moriel Schottlender Date: Sun, 4 Jun 2017 11:13:47 +0300 Subject: [PATCH] RCFilters: Make frontend URL follow backend rules and add 'urlversion=2' The backend always merges the query with wiki/user defaults before it gives us data. The frontend, though, initially assumed that the state is given strictly by the URL parameters (especially after the URL shorening commit). This made it so that the frontend state is incompatible with backend state. However, always merging frontend state with user/wiki defaults can produce inconsistencies between URLs in the same wiki, preventing users from sharing them -- and making it potentially break if ever a wiki default changes. The solution is to add 'urlversion=2' to all RCFilters-generated URLs and have the backend recognize this parameter as 'do not merge with defaults'. When RCFilters frontend loads, it checks whether the parameter exists; if it doesn't, it merges whatever it sees with the defaults just like the backend, then it transforms the URL to represent the correct full state, and adds 'urlversion=2' to the URL parameters, making it consistent across accounts and through time for the next time it will load. This means several new behaviors over the 'short url' commit: - Accessing Special:RecentChanges directly (no query) will result in one of two things: -- If there is a saved query that's set to default: The system will load that saved query "straight forward" (as if the user clicked that option from the menu) causing, also, an ajax re-request from the server (since the server does not yet know about saved queries or their potential for being the default state.) -- If there is no saved query default: The system will load user/wiki defaults (like the backend does) and then fix the url to represent this state fully (with parameters showing the actual state of the filters. -- Both cases will also result in adding 'urlversion=2' to the end result URL. - Accessing Special:RecentChanges?urlversion=2 (without any other parameters) will result in loading a completely empty filter set in RCFilters. We assume that 'urlversion=2' does not load defaults even if it is the only parameter in the URL. - Accessing Special:RecentChanges?hideX=1 (parameter set without urlversion=2) will result in the front end taking the requested parameters, merging them with user/wiki default (reproducing what the backend does) and then adding urlversion=2 to the URL. In all cases except for the default-saved-query-load case, the initial load will **not** re-request data from the backend. The backend needs to adjust to respect urlversion=2 as well (will come in an upcoming commit) so the state and expectation of both the front- and back-end are the same. This commit also factors out URL handing to a separate class (UriProcessor) and adds unit tests for it. Bug: T166907 Bug: T166972 Bug: T166974 Change-Id: I0eed3bc0d4fa4810b6301b535c75b6bfbc8b4a5b --- resources/Resources.php | 1 + .../dm/mw.rcfilters.dm.FilterGroup.js | 4 +- .../mw.rcfilters.Controller.js | 273 ++++++------------ .../mw.rcfilters.UriProcessor.js | 267 +++++++++++++++++ .../mediawiki.rcfilters/mw.rcfilters.init.js | 5 +- tests/qunit/QUnitTestResources.php | 1 + .../mediawiki.rcfilters/UriProcessor.test.js | 252 ++++++++++++++++ 7 files changed, 621 insertions(+), 182 deletions(-) create mode 100644 resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js create mode 100644 tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js diff --git a/resources/Resources.php b/resources/Resources.php index 77c8af8410..cc524383d9 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -1751,6 +1751,7 @@ return [ '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/mw.rcfilters.UriProcessor.js', ], 'dependencies' => [ 'oojs', 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 59c0a19e6c..dd698cd231 100644 --- a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js @@ -75,8 +75,8 @@ var subsetNames = [], filterItem = new mw.rcfilters.dm.FilterItem( filter.name, model, { group: model.getName(), - label: mw.msg( filter.label ), - description: mw.msg( filter.description ), + label: filter.label ? mw.msg( filter.label ) : filter.name, + description: filter.description ? mw.msg( filter.description ) : '', cssClass: filter.cssClass } ); diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js index 375b68b034..c5672ae499 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js @@ -13,7 +13,7 @@ this.savedQueriesModel = savedQueriesModel; this.requestCounter = 0; this.baseFilterState = {}; - this.emptyParameterState = {}; + this.uriProcessor = null; this.initializing = false; }; @@ -26,7 +26,7 @@ * @param {Array} filterStructure Filter definition and structure for the model */ mw.rcfilters.Controller.prototype.initialize = function ( filterStructure ) { - var parsedSavedQueries, validParameterNames, + var parsedSavedQueries, uri = new mw.Uri(), $changesList = $( '.mw-changeslist' ).first().contents(); @@ -34,14 +34,9 @@ this.filtersModel.initializeFilters( filterStructure ); this._buildBaseFilterState(); - this._buildEmptyParameterState(); - validParameterNames = Object.keys( this._getEmptyParameterState() ) - .filter( function ( param ) { - // Remove 'highlight' parameter from this check; - // if it's the only parameter in the URL we still - // want to consider the URL 'empty' for defaults to load - return param !== 'highlight'; - } ); + this.uriProcessor = new mw.rcfilters.UriProcessor( + this.filtersModel + ); try { parsedSavedQueries = JSON.parse( mw.user.options.get( 'rcfilters-saved-queries' ) || '{}' ); @@ -65,45 +60,39 @@ // the user loads the base-page and we load defaults. // Defaults should only be applied on load (if necessary) // or on request + this.initializing = true; if ( - Object.keys( uri.query ).some( function ( parameter ) { - return validParameterNames.indexOf( parameter ) > -1; - } ) + this.savedQueriesModel.getDefault() && + !this.uriProcessor.doesQueryContainRecognizedParams( uri.query ) ) { - // There are parameters in the url, update model state - this.updateStateBasedOnUrl(); + // We have defaults from a saved query. + // We will load them straight-forward (as if + // they were clicked in the menu) so we trigger + // a full ajax request and change of URL + this.applySavedQuery( this.savedQueriesModel.getDefault() ); } else { - this.initializing = true; - // No valid parameters are given, load defaults - this._updateModelState( - $.extend( - true, - // We've ignored the highlight parameter for the sake - // of seeing whether we need to apply defaults - but - // while we do load the defaults, we still want to retain - // the actual value given in the URL for it on top of the - // defaults - { highlight: String( Number( uri.query.highlight ) ) }, - this._getDefaultParams() - ) + // There are either recognized parameters in the URL + // or there are none, but there is also no default + // saved query (so defaults are from the backend) + // We want to update the state but not fetch results + // again + this.updateStateFromUrl( false ); + + // Update the changes list with the existing data + // so it gets processed + this.changesListModel.update( + $changesList.length ? $changesList : 'NO_RESULTS', + $( 'fieldset.rcoptions' ).first() ); - this.updateChangesList(); - this.initializing = false; } - - // Update the changes list with the existing data - // so it gets processed - this.changesListModel.update( - $changesList.length ? $changesList : 'NO_RESULTS', - $( 'fieldset.rcoptions' ).first() - ); + this.initializing = false; }; /** * Reset to default filters */ mw.rcfilters.Controller.prototype.resetToDefaults = function () { - this._updateModelState( $.extend( true, { highlight: '0' }, this._getDefaultParams() ) ); + this.uriProcessor.updateModelBasedOnQuery( this._getDefaultParams() ); this.updateChangesList(); }; @@ -385,24 +374,6 @@ }; }; - /** - * Build an empty representation of the parameters, where all parameters - * are either set to '0' or '' depending on their type. - * This must run during initialization, before highlights are set. - */ - mw.rcfilters.Controller.prototype._buildEmptyParameterState = function () { - var emptyParams = this.filtersModel.getParametersFromFilters( {} ), - emptyHighlights = this.filtersModel.getHighlightParameters(); - - this.emptyParameterState = $.extend( - true, - {}, - emptyParams, - emptyHighlights, - { highlight: '0' } - ); - }; - /** * Get an object representing the base filter state of both * filters and highlights. The structure is similar to what we use @@ -423,22 +394,6 @@ return this.baseFilterState; }; - /** - * 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: - * { - * param1: "value", - * param2: "value1|value2" - * } - * - * @return {Object} Object representing the base state of - * parameters and highlights - */ - mw.rcfilters.Controller.prototype._getEmptyParameterState = function () { - return this.emptyParameterState; - }; - /** * Get an object that holds only the parameters and highlights that have * values different than the base default value. @@ -503,22 +458,25 @@ * without adding an history entry. */ mw.rcfilters.Controller.prototype.replaceUrl = function () { - window.history.replaceState( - { tag: 'rcfilters' }, - document.title, - this._getUpdatedUri().toString() - ); + mw.rcfilters.UriProcessor.static.replaceState( this._getUpdatedUri() ); }; /** * Update filter state (selection and highlighting) based * on current URL values. + * + * @param {boolean} [fetchChangesList=true] Fetch new results into the changes + * list based on the updated model. */ - mw.rcfilters.Controller.prototype.updateStateBasedOnUrl = function () { - var uri = new mw.Uri(); + mw.rcfilters.Controller.prototype.updateStateFromUrl = function ( fetchChangesList ) { + fetchChangesList = fetchChangesList === undefined ? true : !!fetchChangesList; - this._updateModelState( uri.query ); - this.updateChangesList(); + this.uriProcessor.updateModelBasedOnQuery( new mw.Uri().query ); + + // Only update and fetch new results if it is requested + if ( fetchChangesList ) { + this.updateChangesList(); + } }; /** @@ -542,35 +500,40 @@ }; /** - * Update the model state from given the given parameters. - * - * This is an internal method, and should only be used from inside - * the controller. + * Get an object representing the default parameter state, whether + * it is from the model defaults or from the saved queries. * - * @param {Object} parameters Object representing the parameters for - * filters and highlights + * @return {Object} Default parameters */ - mw.rcfilters.Controller.prototype._updateModelState = function ( parameters ) { - // Update filter states - this.filtersModel.toggleFiltersSelected( - this.filtersModel.getFiltersFromParameters( - parameters - ) - ); + mw.rcfilters.Controller.prototype._getDefaultParams = function () { + var data, queryHighlights, + savedParams = {}, + savedHighlights = {}, + defaultSavedQueryItem = this.savedQueriesModel.getItemByID( this.savedQueriesModel.getDefault() ); - // Update highlight state - this.filtersModel.toggleHighlight( !!Number( parameters.highlight ) ); - this.filtersModel.getItems().forEach( function ( filterItem ) { - var color = parameters[ filterItem.getName() + '_color' ]; - if ( color ) { - filterItem.setHighlightColor( color ); - } else { - filterItem.clearHighlightColor(); - } - } ); + if ( mw.config.get( 'wgStructuredChangeFiltersEnableSaving' ) && + defaultSavedQueryItem ) { - // Check all filter interactions - this.filtersModel.reassessFilterInteractions(); + data = defaultSavedQueryItem.getData(); + + queryHighlights = data.highlights || {}; + savedParams = this.filtersModel.getParametersFromFilters( data.filters || {} ); + + // 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 ); + } + + return $.extend( + { highlight: '0' }, + this.filtersModel.getDefaultParams() + ); }; /** @@ -618,53 +581,21 @@ * @param {Object} [params] Extra parameters to add to the API call */ mw.rcfilters.Controller.prototype._updateURL = function ( params ) { - var currentFilterState, updatedFilterState, updatedUri, - uri = new mw.Uri(), - notEquivalent = function ( obj1, obj2 ) { - var keys = Object.keys( obj1 ).concat( Object.keys( obj2 ) ); - return keys.some( function ( key ) { - return obj1[ key ] != obj2[ key ]; // eslint-disable-line eqeqeq - } ); - }; - - params = params || {}; - - updatedUri = this._getUpdatedUri(); - updatedUri.extend( params ); - - // Compare states instead of parameters - // This will allow us to always have a proper check of whether - // the requested new url is one to change or not, regardless of - // actual parameter visibility/representation in the URL - currentFilterState = this.filtersModel.getFiltersFromParameters( uri.query ); - updatedFilterState = this.filtersModel.getFiltersFromParameters( updatedUri.query ); - // HACK: Re-merge extra parameters in - // This is a hack and a quickfix; a better, more sustainable - // fix is being worked on with a UriProcessor, but for now - // we need to make sure the **comparison** of whether currentFilterState - // and updatedFilterState differ **includes** the extra parameters in the URL - currentFilterState = $.extend( true, {}, uri.query, currentFilterState ); - updatedFilterState = $.extend( true, {}, updatedUri.query, updatedFilterState ); - - // Include highlight states - $.extend( true, - currentFilterState, - this.filtersModel.extractHighlightValues( uri.query ), - { highlight: !!Number( uri.query.highlight ) } - ); - $.extend( true, - updatedFilterState, - this.filtersModel.extractHighlightValues( updatedUri.query ), - { highlight: !!Number( updatedUri.query.highlight ) } - ); + var currentUri = new mw.Uri(), + updatedUri = this._getUpdatedUri(); - if ( notEquivalent( currentFilterState, updatedFilterState ) ) { + updatedUri.extend( params || {} ); + + if ( + this.uriProcessor.getVersion( currentUri.query ) !== 2 || + this.uriProcessor.isNewState( currentUri.query, updatedUri.query ) + ) { if ( this.initializing ) { // Initially, when we just build the first page load // out of defaults, we want to replace the history - window.history.replaceState( { tag: 'rcfilters' }, document.title, updatedUri.toString() ); + mw.rcfilters.UriProcessor.static.replaceState( updatedUri ); } else { - window.history.pushState( { tag: 'rcfilters' }, document.title, updatedUri.toString() ); + mw.rcfilters.UriProcessor.static.pushState( updatedUri ); } } }; @@ -675,37 +606,23 @@ * @return {mw.Uri} Updated Uri */ mw.rcfilters.Controller.prototype._getUpdatedUri = function () { - var uri = new mw.Uri(), - highlightParams = this.filtersModel.getHighlightParameters(), - modelParameters = this.filtersModel.getParametersFromFilters(), - baseParams = this._getEmptyParameterState(); - - // Minimize values of the model parameters; show only the values that - // are non-zero. We assume that all parameters that are not literally - // showing in the URL are set to zero or empty - $.each( modelParameters, function ( paramName, value ) { - if ( baseParams[ paramName ] !== value ) { - uri.query[ paramName ] = value; - } else { - // We need to remove this value from the url - delete uri.query[ paramName ]; - } - } ); + var uri = new mw.Uri(); - // highlight params - if ( this.filtersModel.isHighlightEnabled() ) { - uri.query.highlight = Number( this.filtersModel.isHighlightEnabled() ); - } else { - delete uri.query.highlight; - } - $.each( highlightParams, function ( paramName, value ) { - // Only output if it is different than the base parameters - if ( baseParams[ paramName ] !== value ) { - uri.query[ paramName ] = value; - } else { - delete uri.query[ paramName ]; - } - } ); + // Minimize url + uri.query = this.uriProcessor.minimizeQuery( + $.extend( + true, + {}, + // We want to retain unrecognized params + // The uri params from model will override + // any recognized value in the current uri + // query, retain unrecognized params, and + // the result will then be minimized + uri.query, + this.uriProcessor.getUriParametersFromModel(), + { urlversion: '2' } + ) + ); return uri; }; diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js new file mode 100644 index 0000000000..a691c11f59 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js @@ -0,0 +1,267 @@ +( function ( mw, $ ) { + /* eslint no-underscore-dangle: "off" */ + /** + * URI Processor for RCFilters + * + * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model + */ + mw.rcfilters.UriProcessor = function MwRcfiltersController( filtersModel ) { + this.emptyParameterState = {}; + this.filtersModel = filtersModel; + + // Initialize + this._buildEmptyParameterState(); + }; + + /* Initialization */ + OO.initClass( mw.rcfilters.UriProcessor ); + + /* Static methods */ + + /** + * Replace the url history through replaceState + * + * @param {mw.Uri} newUri New URI to replace + */ + mw.rcfilters.UriProcessor.static.replaceState = function ( newUri ) { + window.history.replaceState( + { tag: 'rcfilters' }, + document.title, + newUri.toString() + ); + }; + + /** + * Push the url to history through pushState + * + * @param {mw.Uri} newUri New URI to push + */ + mw.rcfilters.UriProcessor.static.pushState = function ( newUri ) { + window.history.pushState( + { tag: 'rcfilters' }, + document.title, + newUri.toString() + ); + }; + + /* Methods */ + + /** + * Get the version that this URL query is tagged with. + * + * @param {Object} [uriQuery] URI query + * @return {number} URL version + */ + mw.rcfilters.UriProcessor.prototype.getVersion = function ( uriQuery ) { + uriQuery = uriQuery || new mw.Uri().query; + + return Number( uriQuery.urlversion || 1 ); + }; + + /** + * Update the filters model based on the URI query + * This happens on initialization, and from this moment on, + * we consider the system synchronized, and the model serves + * as the source of truth for the URL. + * + * This methods should only be called once on initialiation. + * After initialization, the model updates the URL, not the + * other way around. + * + * @param {Object} [uriQuery] URI query + */ + mw.rcfilters.UriProcessor.prototype.updateModelBasedOnQuery = function ( uriQuery ) { + var parameters = this._getNormalizedQueryParams( uriQuery || new mw.Uri().query ); + + // Update filter states + this.filtersModel.toggleFiltersSelected( + this.filtersModel.getFiltersFromParameters( + parameters + ) + ); + + // Update highlight state + this.filtersModel.toggleHighlight( !!Number( parameters.highlight ) ); + 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 parameters representing the current state of the model + * + * @return {Object} Uri query parameters + */ + mw.rcfilters.UriProcessor.prototype.getUriParametersFromModel = function () { + return $.extend( + true, + {}, + this.filtersModel.getParametersFromFilters(), + this.filtersModel.getHighlightParameters(), + { highlight: String( Number( this.filtersModel.isHighlightEnabled() ) ) } + ); + }; + + /** + * Build the full parameter representation based on given query parameters + * + * @private + * @param {Object} uriQuery Given URI query + * @return {Object} Full parameter state representing the URI query + */ + mw.rcfilters.UriProcessor.prototype._expandModelParameters = function ( uriQuery ) { + var filterRepresentation = this.filtersModel.getFiltersFromParameters( uriQuery ); + + return $.extend( true, + {}, + uriQuery, + this.filtersModel.getParametersFromFilters( filterRepresentation ), + this.filtersModel.extractHighlightValues( uriQuery ), + { highlight: String( Number( uriQuery.highlight ) ) } + ); + }; + + /** + * Compare two URI queries to decide whether they are different + * enough to represent a new state. + * + * @param {Object} currentUriQuery Current Uri query + * @param {Object} updatedUriQuery Updated Uri query + * @return {boolean} This is a new state + */ + mw.rcfilters.UriProcessor.prototype.isNewState = function ( currentUriQuery, updatedUriQuery ) { + var currentParamState, updatedParamState, + notEquivalent = function ( obj1, obj2 ) { + var keys = Object.keys( obj1 ).concat( Object.keys( obj2 ) ); + return keys.some( function ( key ) { + return obj1[ key ] != obj2[ key ]; // eslint-disable-line eqeqeq + } ); + }; + + // Compare states instead of parameters + // This will allow us to always have a proper check of whether + // the requested new url is one to change or not, regardless of + // actual parameter visibility/representation in the URL + currentParamState = this._expandModelParameters( currentUriQuery ); + updatedParamState = this._expandModelParameters( updatedUriQuery ); + + return notEquivalent( currentParamState, updatedParamState ); + }; + + /** + * Check whether the given query has parameters that are + * recognized as parameters we should load the system with + * + * @param {mw.Uri} [uriQuery] Given URI query + * @return {boolean} Query contains valid recognized parameters + */ + mw.rcfilters.UriProcessor.prototype.doesQueryContainRecognizedParams = function ( uriQuery ) { + var anyValidInUrl, + validParameterNames = Object.keys( this._getEmptyParameterState() ) + .filter( function ( param ) { + // Remove 'highlight' parameter from this check; + // if it's the only parameter in the URL we still + // want to consider the URL 'empty' for defaults to load + return param !== 'highlight'; + } ); + + uriQuery = uriQuery || new mw.Uri().query; + + anyValidInUrl = Object.keys( uriQuery ).some( function ( parameter ) { + return validParameterNames.indexOf( parameter ) > -1; + } ); + + // URL version 2 is allowed to be empty or within nonrecognized params + return anyValidInUrl || this.getVersion( uriQuery ) === 2; + }; + + /** + * Remove all parameters that have the same value as the base state + * This method expects uri queries of the urlversion=2 format + * + * @private + * @param {Object} uriQuery Current uri query + * @return {Object} Minimized query + */ + mw.rcfilters.UriProcessor.prototype.minimizeQuery = function ( uriQuery ) { + var baseParams = this._getEmptyParameterState(), + uriResult = $.extend( true, {}, uriQuery ); + + $.each( uriResult, function ( paramName, paramValue ) { + if ( + baseParams[ paramName ] !== undefined && + baseParams[ paramName ] === paramValue + ) { + // Remove parameter from query + delete uriResult[ paramName ]; + } + } ); + + return uriResult; + }; + + /** + * Get the adjusted URI params based on the url version + * If the urlversion is not 2, the parameters are merged with + * the model's defaults. + * + * @private + * @param {Object} uriQuery Current URI query + * @return {Object} Normalized parameters + */ + mw.rcfilters.UriProcessor.prototype._getNormalizedQueryParams = function ( uriQuery ) { + // Check whether we are dealing with urlversion=2 + // If we are, we do not merge the initial request with + // defaults. Not having urlversion=2 means we need to + // reproduce the server-side request and merge the + // requested parameters (or starting state) with the + // wiki default. + // Any subsequent change of the URL through the RCFilters + // system will receive 'urlversion=2' + var base = this.getVersion( uriQuery ) === 2 ? + {} : + this.filtersModel.getDefaultParams(); + + return this.minimizeQuery( + $.extend( true, {}, base, uriQuery, { urlversion: '2' } ) + ); + }; + + /** + * Get the representation of an empty parameter state + * + * @private + * @return {Object} Empty parameter state + */ + mw.rcfilters.UriProcessor.prototype._getEmptyParameterState = function () { + return this.emptyParameterState; + }; + + /** + * Build an empty representation of the parameters, where all parameters + * are either set to '0' or '' depending on their type. + * This must run during initialization, before highlights are set. + * + * @private + */ + mw.rcfilters.UriProcessor.prototype._buildEmptyParameterState = function () { + var emptyParams = this.filtersModel.getParametersFromFilters( {} ), + emptyHighlights = this.filtersModel.getHighlightParameters(); + + this.emptyParameterState = $.extend( + true, + {}, + emptyParams, + emptyHighlights, + { highlight: '0' } + ); + }; +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js index dd8fae05e9..6e62436267 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js @@ -37,8 +37,9 @@ $( '.rcfilters-head' ).addClass( 'mw-rcfilters-ui-ready' ); window.addEventListener( 'popstate', function () { - controller.updateStateBasedOnUrl(); - controller.updateChangesList(); + // Update the state of the model from the URL + // and re-fetch results into the changes list + controller.updateStateFromUrl(); } ); $( 'a.mw-helplink' ).attr( diff --git a/tests/qunit/QUnitTestResources.php b/tests/qunit/QUnitTestResources.php index 53362c4665..ee3cd5bbfb 100644 --- a/tests/qunit/QUnitTestResources.php +++ b/tests/qunit/QUnitTestResources.php @@ -94,6 +94,7 @@ 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/UriProcessor.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.language.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.cldr.test.js', 'tests/qunit/suites/resources/mediawiki/mediawiki.cookie.test.js', diff --git a/tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js b/tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js new file mode 100644 index 0000000000..38ade4ddcd --- /dev/null +++ b/tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js @@ -0,0 +1,252 @@ +/* eslint-disable camelcase */ +/* eslint no-underscore-dangle: "off" */ +( function ( mw, $ ) { + var mockFilterStructure = [ { + name: 'group1', + title: 'Group 1', + type: 'send_unselected_if_any', + filters: [ + { name: 'filter1', default: true }, + { name: 'filter2' } + ] + }, { + name: 'group2', + title: 'Group 2', + type: 'send_unselected_if_any', + filters: [ + { name: 'filter3' }, + { name: 'filter4', default: true } + ] + }, { + name: 'group3', + title: 'Group 3', + type: 'string_options', + filters: [ + { name: 'filter5' }, + { name: 'filter6' } + ] + } ], + minimalDefaultParams = { + filter1: '1', + filter4: '1' + }; + + QUnit.module( 'mediawiki.rcfilters - UriProcessor' ); + + QUnit.test( 'getVersion', function ( assert ) { + var uriProcessor = new mw.rcfilters.UriProcessor( new mw.rcfilters.dm.FiltersViewModel() ); + + assert.ok( + uriProcessor.getVersion( { param1: 'foo', urlversion: '2' } ), + 2, + 'Retrieving the version from the URI query' + ); + + assert.ok( + uriProcessor.getVersion( { param1: 'foo' } ), + 1, + 'Getting version 1 if no version is specified' + ); + } ); + + QUnit.test( 'updateModelBasedOnQuery & getUriParametersFromModel', function ( assert ) { + var uriProcessor, + filtersModel = new mw.rcfilters.dm.FiltersViewModel(), + baseParams = { + filter1: '0', + filter2: '0', + filter3: '0', + filter4: '0', + group3: '', + highlight: '0', + group1__filter1_color: null, + group1__filter2_color: null, + group2__filter3_color: null, + group2__filter4_color: null, + group3__filter5_color: null, + group3__filter6_color: null + }; + + filtersModel.initializeFilters( mockFilterStructure ); + uriProcessor = new mw.rcfilters.UriProcessor( filtersModel ); + + uriProcessor.updateModelBasedOnQuery( {} ); + assert.deepEqual( + uriProcessor.getUriParametersFromModel(), + $.extend( true, {}, baseParams, minimalDefaultParams ), + 'Version 1: Empty url query sets model to defaults' + ); + + uriProcessor.updateModelBasedOnQuery( { urlversion: '2' } ); + assert.deepEqual( + uriProcessor.getUriParametersFromModel(), + baseParams, + 'Version 2: Empty url query sets model to all-false' + ); + + uriProcessor.updateModelBasedOnQuery( { filter1: '1', urlversion: '2' } ); + assert.deepEqual( + uriProcessor.getUriParametersFromModel(), + $.extend( true, {}, baseParams, { filter1: '1' } ), + 'Parameters in Uri query set parameter value in the model' + ); + + uriProcessor.updateModelBasedOnQuery( { highlight: '1', group1__filter1_color: 'c1', urlversion: '2' } ); + assert.deepEqual( + uriProcessor.getUriParametersFromModel(), + $.extend( true, {}, baseParams, { + highlight: '1', + group1__filter1_color: 'c1' + } ), + 'Highlight parameters in Uri query set highlight state in the model' + ); + } ); + + QUnit.test( 'isNewState', function ( assert ) { + var uriProcessor, + filtersModel = new mw.rcfilters.dm.FiltersViewModel(), + cases = [ + { + states: { + curr: {}, + new: {} + }, + result: false, + message: 'Empty objects are not new state.' + }, + { + states: { + curr: { filter1: '1' }, + new: { filter1: '0' } + }, + result: true, + message: 'Nulified parameter is a new state' + }, + { + states: { + curr: { filter1: '1' }, + new: { filter1: '1', filter2: '1' } + }, + result: true, + message: 'Added parameters are a new state' + }, + { + states: { + curr: { filter1: '1' }, + new: { filter1: '1', filter2: '0' } + }, + result: false, + message: 'Added null parameters are not a new state (normalizing equals old state)' + }, + { + states: { + curr: { filter1: '1' }, + new: { filter1: '1', foo: 'bar' } + }, + result: true, + message: 'Added unrecognized parameters are a new state' + }, + { + states: { + curr: { filter1: '1', foo: 'bar' }, + new: { filter1: '1', foo: 'baz' } + }, + result: true, + message: 'Changed unrecognized parameters are a new state' + } + ]; + + filtersModel.initializeFilters( mockFilterStructure ); + uriProcessor = new mw.rcfilters.UriProcessor( filtersModel ); + + cases.forEach( function ( testCase ) { + assert.equal( + uriProcessor.isNewState( testCase.states.curr, testCase.states.new ), + testCase.result, + testCase.message + ); + } ); + } ); + + QUnit.test( 'doesQueryContainRecognizedParams', function ( assert ) { + var uriProcessor, + filtersModel = new mw.rcfilters.dm.FiltersViewModel(), + cases = [ + { + query: {}, + result: false, + message: 'Empty query is not valid for load.' + }, + { + query: { highlight: '1' }, + result: false, + message: 'Highlight state alone is not valid for load' + }, + { + query: { urlversion: '2' }, + result: true, + message: 'urlversion=2 state alone is valid for load as an empty state' + }, + { + query: { filter1: '1', foo: 'bar' }, + result: true, + message: 'Existence of recognized parameters makes the query valid for load' + }, + { + query: { foo: 'bar', debug: true }, + result: false, + message: 'Only unrecognized parameters makes the query invalid for load' + } + ]; + + filtersModel.initializeFilters( mockFilterStructure ); + uriProcessor = new mw.rcfilters.UriProcessor( filtersModel ); + + cases.forEach( function ( testCase ) { + assert.equal( + uriProcessor.doesQueryContainRecognizedParams( testCase.query ), + testCase.result, + testCase.message + ); + } ); + } ); + + QUnit.test( '_getNormalizedQueryParams', function ( assert ) { + var uriProcessor, + filtersModel = new mw.rcfilters.dm.FiltersViewModel(), + cases = [ + { + query: {}, + result: $.extend( true, { urlversion: '2' }, minimalDefaultParams ), + message: 'Empty query returns defaults (urlversion 1).' + }, + { + query: { urlversion: '2' }, + result: { urlversion: '2' }, + message: 'Empty query returns empty (urlversion 2)' + }, + { + query: { filter1: '0' }, + result: { urlversion: '2', filter4: '1' }, + message: 'urlversion 1 returns query that overrides defaults' + }, + { + query: { filter3: '1' }, + result: { urlversion: '2', filter1: '1', filter4: '1', filter3: '1' }, + message: 'urlversion 1 with an extra param value returns query that is joined with defaults' + } + ]; + + filtersModel.initializeFilters( mockFilterStructure ); + uriProcessor = new mw.rcfilters.UriProcessor( filtersModel ); + + cases.forEach( function ( testCase ) { + assert.deepEqual( + uriProcessor._getNormalizedQueryParams( testCase.query ), + testCase.result, + testCase.message + ); + } ); + } ); + +}( mediaWiki, jQuery ) ); -- 2.20.1