Merge "RCFilters: Move parameter operations to ViewModel"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Tue, 17 Oct 2017 19:22:57 +0000 (19:22 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Tue, 17 Oct 2017 19:22:57 +0000 (19:22 +0000)
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.SavedQueriesModel.js
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueryItemModel.js
resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js
resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js
tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js
tests/qunit/suites/resources/mediawiki.rcfilters/dm.FiltersViewModel.test.js
tests/qunit/suites/resources/mediawiki.rcfilters/dm.SavedQueriesModel.test.js

index 57d1b41..b17355f 100644 (file)
        mw.rcfilters.dm.FilterGroup.prototype.isExcludedFromSavedQueries = function () {
                return this.excludedFromSavedQueries;
        };
+
+       /**
+        * Normalize a value given to this group. This is mostly for correcting
+        * arbitrary values for 'single option' groups, given by the user settings
+        * or the URL that can go outside the limits that are allowed.
+        *
+        * @param  {string} value Given value
+        * @return {string} Corrected value
+        */
+       mw.rcfilters.dm.FilterGroup.prototype.normalizeArbitraryValue = function ( value ) {
+               if (
+                       this.getType() === 'single_option' &&
+                       this.isAllowArbitrary()
+               ) {
+                       if (
+                               this.getMaxValue() !== null &&
+                               value > this.getMaxValue()
+                       ) {
+                               // Change the value to the actual max value
+                               return String( this.getMaxValue() );
+                       } else if (
+                               this.getMinValue() !== null &&
+                               value < this.getMinValue()
+                       ) {
+                               // Change the value to the actual min value
+                               return String( this.getMinValue() );
+                       }
+               }
+
+               return value;
+       };
 }( mediaWiki ) );
index 0d65466..b8e1129 100644 (file)
@@ -17,6 +17,7 @@
                this.defaultFiltersEmpty = null;
                this.highlightEnabled = false;
                this.parameterMap = {};
+               this.emptyParameterState = null;
 
                this.views = {};
                this.currentView = 'default';
                this.emit( 'initialize' );
        };
 
+       /**
+        * Update filter view model state based on a parameter object
+        *
+        * @param {Object} params Parameters object
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.updateStateFromParams = function ( params ) {
+               // For arbitrary numeric single_option values make sure the values
+               // are normalized to fit within the limits
+               $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
+                       params[ groupName ] = groupModel.normalizeArbitraryValue( params[ groupName ] );
+               } );
+
+               // Update filter states
+               this.toggleFiltersSelected(
+                       this.getFiltersFromParameters(
+                               params
+                       )
+               );
+
+               // Update highlight state
+               this.getItemsSupportingHighlights().forEach( function ( filterItem ) {
+                       var color = params[ filterItem.getName() + '_color' ];
+                       if ( color ) {
+                               filterItem.setHighlightColor( color );
+                       } else {
+                               filterItem.clearHighlightColor();
+                       }
+               } );
+               this.toggleHighlight( !!Number( params.highlight ) );
+
+               // Check all filter interactions
+               this.reassessFilterInteractions();
+       };
+
+       /**
+        * Get a representation of an empty (falsey) parameter state
+        *
+        * @return {Object} Empty parameter state
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.getEmptyParameterState = function () {
+               if ( !this.emptyParameterState ) {
+                       this.emptyParameterState = $.extend(
+                               true,
+                               {},
+                               this.getParametersFromFilters( {} ),
+                               this.getEmptyHighlightParameters(),
+                               { highlight: '0' }
+                       );
+               }
+               return this.emptyParameterState;
+       };
+
+       /**
+        * Get a representation of only the non-falsey parameters
+        *
+        * @param {Object} [parameters] A given parameter state to minimize. If not given the current
+        *  state of the system will be used.
+        * @return {Object} Empty parameter state
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.getMinimizedParamRepresentation = function ( parameters ) {
+               var result = {};
+
+               parameters = parameters ? $.extend( true, {}, parameters ) : this.getCurrentParameterState();
+
+               // Params
+               $.each( this.getEmptyParameterState(), function ( param, value ) {
+                       if ( parameters[ param ] !== undefined && parameters[ param ] !== value ) {
+                               result[ param ] = parameters[ param ];
+                       }
+               } );
+
+               // Highlights
+               Object.keys( this.getEmptyHighlightParameters() ).forEach( function ( param ) {
+                       if ( param !== 'highlight' && parameters[ param ] ) {
+                               // If a highlight parameter is not undefined and not null
+                               // add it to the result
+                               // Ignore "highlight" parameter because that, we checked already with
+                               // the empty parameter state (and this soon changes to an implicit value)
+                               result[ param ] = parameters[ param ];
+                       }
+               } );
+
+               return result;
+       };
+
+       /**
+        * Get a representation of the full parameter list, including all base values
+        *
+        * @param {Object} [parameters] A given parameter state to minimize. If not given the current
+        *  state of the system will be used.
+        * @param {boolean} [removeExcluded] Remove excluded and sticky parameters
+        * @return {Object} Full parameter representation
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.getExpandedParamRepresentation = function ( parameters, removeExcluded ) {
+               var result = {};
+
+               parameters = parameters ? $.extend( true, {}, parameters ) : this.getCurrentParameterState();
+
+               result = $.extend(
+                       true,
+                       {},
+                       this.getEmptyParameterState(),
+                       parameters
+               );
+
+               if ( removeExcluded ) {
+                       result = this.removeExcludedParams( result );
+               }
+
+               return result;
+       };
+
+       /**
+        * Get a parameter representation of the current state of the model
+        *
+        * @param {boolean} [removeExcludedParams] Remove excluded filters from final result
+        * @return {Object} Parameter representation of the current state of the model
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentParameterState = function ( removeExcludedParams ) {
+               var excludedParams,
+                       state = this.getMinimizedParamRepresentation( $.extend(
+                               true,
+                               {},
+                               this.getParametersFromFilters( this.getSelectedState() ),
+                               this.getHighlightParameters(),
+                               {
+                                       // HACK: Add highlight. This is only needed while it's
+                                       // stored as an outside state
+                                       highlight: String( Number( this.isHighlightEnabled() ) )
+                               }
+                       ) );
+
+               if ( removeExcludedParams ) {
+                       excludedParams = this.getExcludedParams();
+                       // Delete all excluded filters
+                       $.each( state, function ( param ) {
+                               if ( excludedParams.indexOf( param ) > -1 ) {
+                                       delete state[ param ];
+                               }
+                       } );
+               }
+
+               return state;
+       };
+
+       /**
+        * Delete excluded and sticky filters from given object. If object isn't given, output
+        * the current filter state without the excluded values
+        *
+        * @param {Object} [filterState] Filter state
+        * @return {Object} Filter state without excluded filters
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.removeExcludedFilters = function ( filterState ) {
+               filterState = filterState !== undefined ?
+                       $.extend( true, {}, filterState ) :
+                       this.getFiltersFromParameters();
+
+               // Remove excluded filters
+               Object.keys( this.getExcludedFiltersState() ).forEach( function ( filterName ) {
+                       delete filterState[ filterName ];
+               } );
+
+               // Remove sticky filters
+               Object.keys( this.getStickyFiltersState() ).forEach( function ( filterName ) {
+                       delete filterState[ filterName ];
+               } );
+
+               return filterState;
+       };
+       /**
+        * Delete excluded and sticky parameters from given object. If object isn't given, output
+        * the current param state without the excluded values
+        *
+        * @param {Object} [paramState] Parameter state
+        * @return {Object} Parameter state without excluded filters
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.removeExcludedParams = function ( paramState ) {
+               paramState = paramState !== undefined ?
+                       $.extend( true, {}, paramState ) :
+                       this.getCurrentParameterState();
+
+               // Remove excluded filters
+               this.getExcludedParams().forEach( function ( paramName ) {
+                       delete paramState[ paramName ];
+               } );
+
+               // Remove sticky filters
+               this.getStickyParams().forEach( function ( paramName ) {
+                       delete paramState[ paramName ];
+               } );
+
+               return paramState;
+       };
+
        /**
         * Get the names of all available filters
         *
        /**
         * Get an object representing default parameters state
         *
+        * @param {boolean} [excludeHiddenParams] Exclude hidden and sticky params
         * @return {Object} Default parameter values
         */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getDefaultParams = function () {
+       mw.rcfilters.dm.FiltersViewModel.prototype.getDefaultParams = function ( excludeHiddenParams ) {
                var result = {};
 
                // Get default filter state
                        $.extend( true, result, model.getDefaultParams() );
                } );
 
+               if ( excludeHiddenParams ) {
+                       Object.keys( this.getDefaultHiddenParams() ).forEach( function ( paramName ) {
+                               delete result[ paramName ];
+                       } );
+               }
+
+               return result;
+       };
+
+       /**
+        * Get an object representing defaults for the hidden parameters state
+        *
+        * @return {Object} Default values for hidden parameters
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.getDefaultHiddenParams = function () {
+               var result = {};
+
+               // Get default filter state
+               $.each( this.groups, function ( name, model ) {
+                       if ( model.isHidden() ) {
+                               $.extend( true, result, model.getDefaultParams() );
+                       }
+               } );
+
                return result;
        };
 
         * @return {Object} Sticky parameter values
         */
        mw.rcfilters.dm.FiltersViewModel.prototype.getStickyParams = function () {
+               var result = [];
+
+               $.each( this.groups, function ( name, model ) {
+                       if ( model.isSticky() ) {
+                               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;
+       };
+
+       /**
+        * Get a parameter representation of all sticky parameters
+        *
+        * @return {Object} Sticky parameter values
+        */
+       mw.rcfilters.dm.FiltersViewModel.prototype.getStickyParamsValues = function () {
                var result = {};
 
                $.each( this.groups, function ( name, model ) {
                var result = {};
 
                this.getItems().forEach( function ( filterItem ) {
-                       result[ filterItem.getName() + '_color' ] = filterItem.getHighlightColor() || null;
+                       if ( filterItem.isHighlightSupported() ) {
+                               result[ filterItem.getName() + '_color' ] = filterItem.getHighlightColor() || null;
+                       }
                } );
                result.highlight = String( Number( this.isHighlightEnabled() ) );
 
                var result = {};
 
                this.getItems().forEach( function ( filterItem ) {
-                       result[ filterItem.getName() + '_color' ] = null;
+                       if ( filterItem.isHighlightSupported() ) {
+                               result[ filterItem.getName() + '_color' ] = null;
+                       }
                } );
                result.highlight = '0';
 
index edb9644..29585e9 100644 (file)
@@ -80,8 +80,7 @@
         * @fires initialize
         */
        mw.rcfilters.dm.SavedQueriesModel.prototype.initialize = function ( savedQueries ) {
-               var model = this,
-                       excludedParams = this.filtersModel.getExcludedParams();
+               var model = this;
 
                savedQueries = savedQueries || {};
 
                        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 ];
-                               } );
+                               normalizedData.params = model.filtersModel.removeExcludedParams( normalizedData.params );
 
                                id = String( id );
-                               model.addNewQuery( obj.label, normalizedData, isDefault, id );
+
+                               // Skip the addNewQuery method because we don't want to unnecessarily manipulate
+                               // the given saved queries unless we literally intend to (like in backwards compat fixes)
+                               // And the addNewQuery method also uses a minimization routine that checks for the
+                               // validity of items and minimizes the query. This isn't necessary for queries loaded
+                               // from the backend, and has the risk of removing values if they're temporarily
+                               // invalid (example: if we temporarily removed a cssClass from a filter in the backend)
+                               model.addItems( [
+                                       new mw.rcfilters.dm.SavedQueryItemModel(
+                                               id,
+                                               obj.label,
+                                               normalizedData,
+                                               { 'default': isDefault }
+                                       )
+                               ] );
 
                                if ( isDefault ) {
                                        model.default = id;
                delete data.highlights.highlight;
 
                // Filters
-               newData.params = this.filtersModel.getParametersFromFilters( fullFilterRepresentation );
+               newData.params = this.filtersModel.getMinimizedParamRepresentation(
+                       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 ];
+               $.each( data.highlights, function ( highlightedFilterName, value ) {
+                       if ( value ) {
+                               newData.highlights[ highlightedFilterName + '_color' ] = data.highlights[ highlightedFilterName ];
+                       }
                } );
 
                // Add highlight
                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, { highlight: '0' }, allParams ),
-                               highlights: highlightedItems
-                       };
-               }
-
-               return this.baseParamState;
-       };
-
-       /**
-        * 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;
-       };
-
        /**
         * Add a query item
         *
         * @param {string} label Label for the new query
-        * @param {Object} data Data for the new query
+        * @param {Object} fulldata Full data representation for the new query, combining highlights and filters
         * @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, isDefault, id ) {
-               var randomID = String( id || ( new Date() ).getTime() ),
-                       normalizedData = this.getMinimalParamList( data );
+       mw.rcfilters.dm.SavedQueriesModel.prototype.addNewQuery = function ( label, fulldata, isDefault, id ) {
+               var normalizedData = { params: {}, highlights: {} },
+                       highlightParamNames = Object.keys( this.filtersModel.getEmptyHighlightParameters() ),
+                       randomID = String( id || ( new Date() ).getTime() ),
+                       data = this.filtersModel.getMinimizedParamRepresentation( fulldata );
+
+               // Split highlight/params
+               $.each( data, function ( param, value ) {
+                       if ( param !== 'highlight' && highlightParamNames.indexOf( param ) > -1 ) {
+                               normalizedData.highlights[ param ] = value;
+                       } else {
+                               normalizedData.params[ param ] = value;
+                       }
+               } );
 
                // Add item
                this.addItems( [
         */
        mw.rcfilters.dm.SavedQueriesModel.prototype.findMatchingQuery = function ( fullQueryComparison ) {
                // Minimize before comparison
-               fullQueryComparison = this.getMinimalParamList( fullQueryComparison );
+               fullQueryComparison = this.filtersModel.getMinimizedParamRepresentation( fullQueryComparison );
 
                return this.getItems().filter( function ( item ) {
                        return OO.compare(
-                               item.getData(),
+                               item.getCombinedData(),
                                fullQueryComparison
                        );
                } )[ 0 ];
        };
 
        /**
-        * Get an item's full data
+        * Get the full data representation of the default query, if it exists
         *
-        * @param {string} queryID Query identifier
-        * @return {Object} Item's full data
+        * @param {boolean} [excludeHiddenParams] Exclude hidden parameters in the result
+        * @return {Object|null} Representation of the default params if exists.
+        *  Null if default doesn't exist or if the user is not logged in.
         */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.getItemFullData = function ( queryID ) {
-               var item = this.getItemByID( queryID );
+       mw.rcfilters.dm.SavedQueriesModel.prototype.getDefaultParams = function ( excludeHiddenParams ) {
+               var data = ( !mw.user.isAnon() && this.getItemParams( this.getDefault() ) ) || {};
+
+               if ( excludeHiddenParams ) {
+                       Object.keys( this.filtersModel.getDefaultHiddenParams() ).forEach( function ( paramName ) {
+                               delete data[ paramName ];
+                       } );
+               }
 
-               // Fill in the base params
-               return item ? $.extend( true, {}, this.getBaseParamState(), item.getData() ) : {};
+               return data;
+       };
+
+       /**
+        * Get a full parameter representation of an item data
+        *
+        * @param  {Object} queryID Query ID
+        * @return {Object} Parameter representation
+        */
+       mw.rcfilters.dm.SavedQueriesModel.prototype.getItemParams = function ( queryID ) {
+               var item = this.getItemByID( queryID ),
+                       data = item ? item.getData() : {};
+
+               return !$.isEmptyObject( data ) ? this.buildParamsFromData( data ) : {};
+       };
+
+       /**
+        * Build a full parameter representation given item data and model sticky values state
+        *
+        * @param  {Object} data Item data
+        * @return {Object} Full param representation
+        */
+       mw.rcfilters.dm.SavedQueriesModel.prototype.buildParamsFromData = function ( data ) {
+               // Merge saved filter state with sticky filter values
+               var savedFilters;
+
+               data = data || {};
+
+               // In order to merge sticky filters with the data, we have to
+               // transform this to filters first, merge, and then back to
+               // parameters
+               savedFilters = $.extend(
+                       true, {},
+                       this.filtersModel.getFiltersFromParameters( data.params ),
+                       this.filtersModel.getStickyFiltersState()
+               );
+
+               // Return parameter representation
+               return this.filtersModel.getMinimizedParamRepresentation( $.extend( true, {},
+                       this.filtersModel.getParametersFromFilters( savedFilters ),
+                       data.highlights,
+                       { highlight: data.params.highlight }
+               ) );
        };
 
        /**
         * @return {Object} Object representing the state of the model and items
         */
        mw.rcfilters.dm.SavedQueriesModel.prototype.getState = function () {
-               var model = this,
-                       obj = { queries: {}, version: '2' };
+               var 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;
+                       obj.queries[ item.getID() ] = item.getState();
                } );
 
                if ( this.getDefault() ) {
index 81c8306..a6ff9a1 100644 (file)
                return this.data;
        };
 
+       /**
+        * Get the combined data of this item as a flat object of parameters
+        *
+        * @return {Object} Combined parameter data
+        */
+       mw.rcfilters.dm.SavedQueryItemModel.prototype.getCombinedData = function () {
+               return $.extend( true, {}, this.data.params, this.data.highlights );
+       };
+
        /**
         * Check whether this item is the default
         *
index 5b12cf7..0b2dd8d 100644 (file)
         * Reset to default filters
         */
        mw.rcfilters.Controller.prototype.resetToDefaults = function () {
-               this.uriProcessor.updateModelBasedOnQuery( this._getDefaultParams() );
+               this.filtersModel.updateStateFromParams( this._getDefaultParams() );
 
                this.updateChangesList();
        };
         * @return {boolean} Defaults are all false
         */
        mw.rcfilters.Controller.prototype.areDefaultsEmpty = function () {
-               var defaultParams = this._getDefaultParams(),
-                       defaultFilters = this.filtersModel.getFiltersFromParameters( defaultParams );
-
-               this._deleteExcludedValuesFromFilterState( defaultFilters );
-
-               if ( Object.keys( defaultParams ).some( function ( paramName ) {
-                       return paramName.match( /_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 ];
-               } );
+               return $.isEmptyObject( this._getDefaultParams( true ) );
        };
 
        /**
                        .getHighlightedItems()
                        .map( function ( filterItem ) { return { name: filterItem.getName() }; } );
 
-               this.filtersModel.emptyAllFilters();
-               this.filtersModel.clearAllHighlightColors();
-               // Check all filter interactions
-               this.filtersModel.reassessFilterInteractions();
+               this.filtersModel.updateStateFromParams( {} );
 
                this.updateChangesList();
 
         */
        mw.rcfilters.Controller.prototype.toggleHighlight = function () {
                this.filtersModel.toggleHighlight();
-               this._updateURL();
+               this.uriProcessor.updateURL();
 
                if ( this.filtersModel.isHighlightEnabled() ) {
                        mw.hook( 'RcFilters.highlight.enable' ).fire();
         */
        mw.rcfilters.Controller.prototype.setHighlightColor = function ( filterName, color ) {
                this.filtersModel.setHighlightColor( filterName, color );
-               this._updateURL();
+               this.uriProcessor.updateURL();
                this._trackHighlight( 'set', { name: filterName, color: color } );
        };
 
         */
        mw.rcfilters.Controller.prototype.clearHighlightColor = function ( filterName ) {
                this.filtersModel.clearHighlightColor( filterName );
-               this._updateURL();
+               this.uriProcessor.updateURL();
                this._trackHighlight( 'clear', filterName );
        };
 
         * @param {boolean} [setAsDefault=false] This query should be set as the default
         */
        mw.rcfilters.Controller.prototype.saveCurrentQuery = function ( label, setAsDefault ) {
-               var highlightedItems = {},
-                       highlightEnabled = this.filtersModel.isHighlightEnabled(),
-                       selectedState = this.filtersModel.getSelectedState();
-
-               // Prepare highlights
-               this.filtersModel.getHighlightedItems().forEach( function ( item ) {
-                       highlightedItems[ item.getName() + '_color' ] = highlightEnabled ?
-                               item.getHighlightColor() : null;
-               } );
-
-               // Delete all excluded filters
-               this._deleteExcludedValuesFromFilterState( selectedState );
-
                // Add item
                this.savedQueriesModel.addNewQuery(
                        label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ),
-                       {
-                               params: $.extend(
-                                       true,
-                                       {
-                                               highlight: String( Number( this.filtersModel.isHighlightEnabled() ) )
-                                       },
-                                       this.filtersModel.getParametersFromFilters( selectedState )
-                               ),
-                               highlights: highlightedItems
-                       },
+                       this.filtersModel.getCurrentParameterState( true ),
                        setAsDefault
                );
 
         * @param {string} queryID Query id
         */
        mw.rcfilters.Controller.prototype.applySavedQuery = function ( queryID ) {
-               var highlights,
-                       queryItem = this.savedQueriesModel.getItemByID( queryID ),
-                       data = this.savedQueriesModel.getItemFullData( queryID ),
-                       currentMatchingQuery = this.findQueryMatchingCurrentState();
+               var currentMatchingQuery,
+                       params = this.savedQueriesModel.getItemParams( queryID );
+
+               currentMatchingQuery = this.findQueryMatchingCurrentState();
 
                if (
-                       queryItem &&
-                       (
-                               // If there's already a query, don't reload it
-                               // if it's the same as the one that already exists
-                               !currentMatchingQuery ||
-                               currentMatchingQuery.getID() !== queryItem.getID()
-                       )
+                       currentMatchingQuery &&
+                       currentMatchingQuery.getID() === queryID
                ) {
-                       highlights = data.highlights;
-
-                       // Update model state from filters
-                       this.filtersModel.toggleFiltersSelected(
-                               // Merge filters with excluded values
-                               $.extend(
-                                       true,
-                                       {},
-                                       this.filtersModel.getFiltersFromParameters( data.params ),
-                                       this.filtersModel.getExcludedFiltersState()
-                               )
-                       );
-
-                       // Update highlight state
-                       this.filtersModel.toggleHighlight( !!Number( data.params.highlight ) );
-                       this.filtersModel.getItems().forEach( function ( filterItem ) {
-                               var color = highlights[ filterItem.getName() + '_color' ];
-                               if ( color ) {
-                                       filterItem.setHighlightColor( color );
-                               } else {
-                                       filterItem.clearHighlightColor();
-                               }
-                       } );
+                       // If the query we want to load is the one that is already
+                       // loaded, don't reload it
+                       return;
+               }
 
-                       // Check all filter interactions
-                       this.filtersModel.reassessFilterInteractions();
+               // Apply parameters to model
+               this.filtersModel.updateStateFromParams( params );
 
-                       this.updateChangesList();
+               this.updateChangesList();
 
-                       // Log filter grouping
-                       this.trackFilterGroupings( 'savedfilters' );
-               }
+               // Log filter grouping
+               this.trackFilterGroupings( 'savedfilters' );
        };
 
        /**
         * @return {boolean} Query exists
         */
        mw.rcfilters.Controller.prototype.findQueryMatchingCurrentState = function () {
-               var highlightedItems = {},
-                       selectedState = this.filtersModel.getSelectedState();
-
-               // Prepare highlights of the current query
-               this.filtersModel.getItemsSupportingHighlights().forEach( function ( item ) {
-                       highlightedItems[ item.getName() + '_color' ] = item.getHighlightColor();
-               } );
-
-               // Remove anything that should be excluded from the saved query
-               // this includes sticky filters and filters marked with 'excludedFromSavedQueries'
-               this._deleteExcludedValuesFromFilterState( selectedState );
-
                return this.savedQueriesModel.findMatchingQuery(
-                       {
-                               params: $.extend(
-                                       true,
-                                       {
-                                               highlight: String( Number( this.filtersModel.isHighlightEnabled() ) )
-                                       },
-                                       this.filtersModel.getParametersFromFilters( selectedState )
-                               ),
-                               highlights: highlightedItems
-                       }
+                       this.filtersModel.getCurrentParameterState( true )
                );
        };
 
-       /**
-        * Delete sticky filters from given object
-        *
-        * @param {Object} filterState Filter state
-        */
-       mw.rcfilters.Controller.prototype._deleteExcludedValuesFromFilterState = function ( filterState ) {
-               // Remove excluded filters
-               $.each( this.filtersModel.getExcludedFiltersState(), function ( filterName ) {
-                       delete filterState[ filterName ];
-               } );
-       };
-
        /**
         * Save the current state of the saved queries model with all
         * query item representation in the user settings.
         * without adding an history entry.
         */
        mw.rcfilters.Controller.prototype.replaceUrl = function () {
-               mw.rcfilters.UriProcessor.static.replaceState( this._getUpdatedUri() );
+               this.uriProcessor.replaceUpdatedUri();
        };
 
        /**
                updateMode = updateMode === undefined ? this.FILTER_CHANGE : updateMode;
 
                if ( updateMode === this.FILTER_CHANGE ) {
-                       this._updateURL( params );
+                       this.uriProcessor.updateURL( params );
                }
                if ( updateMode === this.FILTER_CHANGE || updateMode === this.SHOW_NEW_CHANGES ) {
                        this.changesListModel.invalidate();
         * Get an object representing the default parameter state, whether
         * it is from the model defaults or from the saved queries.
         *
+        * @param {boolean} [excludeHiddenParams] Exclude hidden and sticky params
         * @return {Object} Default parameters
         */
-       mw.rcfilters.Controller.prototype._getDefaultParams = function () {
-               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()
-                       );
-
-                       // Return parameter representation
-                       return $.extend( true, {},
-                               this.filtersModel.getParametersFromFilters( savedFilters ),
-                               data.highlights,
-                               { highlight: data.params.highlight }
-                       );
-               }
-               return this.filtersModel.getDefaultParams();
-       };
-
-       /**
-        * Update the URL of the page to reflect current filters
-        *
-        * This should not be called directly from outside the controller.
-        * If an action requires changing the URL, it should either use the
-        * highlighting actions below, or call #updateChangesList which does
-        * the uri corrections already.
-        *
-        * @param {Object} [params] Extra parameters to add to the API call
-        */
-       mw.rcfilters.Controller.prototype._updateURL = function ( params ) {
-               var currentUri = new mw.Uri(),
-                       updatedUri = this._getUpdatedUri();
-
-               updatedUri.extend( params || {} );
-
-               if (
-                       this.uriProcessor.getVersion( currentUri.query ) !== 2 ||
-                       this.uriProcessor.isNewState( currentUri.query, updatedUri.query )
-               ) {
-                       mw.rcfilters.UriProcessor.static.replaceState( updatedUri );
+       mw.rcfilters.Controller.prototype._getDefaultParams = function ( excludeHiddenParams ) {
+               if ( this.savedQueriesModel.getDefault() ) {
+                       return this.savedQueriesModel.getDefaultParams( excludeHiddenParams );
+               } else {
+                       return this.filtersModel.getDefaultParams( excludeHiddenParams );
                }
        };
 
-       /**
-        * Get an updated mw.Uri object based on the model state
-        *
-        * @return {mw.Uri} Updated Uri
-        */
-       mw.rcfilters.Controller.prototype._getUpdatedUri = function () {
-               var uri = new mw.Uri();
-
-               // 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;
-       };
-
        /**
         * Query the list of changes from the server for the current filters
         *
         * @return {jQuery.Promise} Promise object resolved with { content, status }
         */
        mw.rcfilters.Controller.prototype._queryChangesList = function ( counterId, params ) {
-               var uri = this._getUpdatedUri(),
-                       stickyParams = this.filtersModel.getStickyParams(),
+               var uri = this.uriProcessor.getUpdatedUri(),
+                       stickyParams = this.filtersModel.getStickyParamsValues(),
                        requestId,
                        latestRequest;
 
index 0450639..044712c 100644 (file)
@@ -6,11 +6,7 @@
         * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
         */
        mw.rcfilters.UriProcessor = function MwRcfiltersController( filtersModel ) {
-               this.emptyParameterState = {};
                this.filtersModel = filtersModel;
-
-               // Initialize
-               this._buildEmptyParameterState();
        };
 
        /* Initialization */
        };
 
        /**
-        * 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
+        * Replace the current URI with an updated one from the model state
         */
-       mw.rcfilters.UriProcessor.prototype.updateModelBasedOnQuery = function ( uriQuery ) {
-               var parameters;
-
-               uriQuery = uriQuery || new mw.Uri().query;
-
-               // For arbitrary numeric single_option values, check the uri and see if it's beyond the limit
-               $.each( this.filtersModel.getFilterGroups(), function ( groupName, groupModel ) {
-                       if (
-                               groupModel.getType() === 'single_option' &&
-                               groupModel.isAllowArbitrary()
-                       ) {
-                               if (
-                                       groupModel.getMaxValue() !== null &&
-                                       uriQuery[ groupName ] > groupModel.getMaxValue()
-                               ) {
-                                       // Change the value to the actual max value
-                                       uriQuery[ groupName ] = String( groupModel.getMaxValue() );
-                               } else if (
-                                       groupModel.getMinValue() !== null &&
-                                       uriQuery[ groupName ] < groupModel.getMinValue()
-                               ) {
-                                       // Change the value to the actual min value
-                                       uriQuery[ groupName ] = String( groupModel.getMinValue() );
-                               }
-                       }
-               } );
-
-               // Normalize
-               parameters = this._getNormalizedQueryParams( uriQuery );
+       mw.rcfilters.UriProcessor.prototype.replaceUpdatedUri = function () {
+               this.constructor.static.replaceState( this.getUpdatedUri() );
+       };
 
-               // Update filter states
-               this.filtersModel.toggleFiltersSelected(
-                       this.filtersModel.getFiltersFromParameters(
-                               parameters
+       /**
+        * Get an updated mw.Uri object based on the model state
+        *
+        * @param {Object} [uriQuery] An external URI query to build the new uri
+        *  with. This is mainly for tests, to be able to supply external parameters
+        *  and make sure they are retained.
+        * @return {mw.Uri} Updated Uri
+        */
+       mw.rcfilters.UriProcessor.prototype.getUpdatedUri = function ( uriQuery ) {
+               var uri = new mw.Uri(),
+                       unrecognizedParams = this.getUnrecognizedParams( uriQuery || uri.query );
+
+               if ( uriQuery ) {
+                       // This is mainly for tests, to be able to give the method
+                       // an initial URI Query and test that it retains parameters
+                       uri.query = uriQuery;
+               }
+
+               uri.query = this.filtersModel.getMinimizedParamRepresentation(
+                       $.extend(
+                               true,
+                               {},
+                               uri.query,
+                               // The representation must be expanded so it can
+                               // override the uri query params but we then output
+                               // a minimized version for the entire URI representation
+                               // for the method
+                               this.filtersModel.getExpandedParamRepresentation()
                        )
                );
 
-               // Update highlight state
-               this.filtersModel.getItems().forEach( function ( filterItem ) {
-                       var color = parameters[ filterItem.getName() + '_color' ];
-                       if ( color ) {
-                               filterItem.setHighlightColor( color );
-                       } else {
-                               filterItem.clearHighlightColor();
-                       }
-               } );
-               this.filtersModel.toggleHighlight( !!Number( parameters.highlight ) );
+               // Reapply unrecognized params and url version
+               uri.query = $.extend( true, {}, uri.query, unrecognizedParams, { urlversion: '2' } );
 
-               // Check all filter interactions
-               this.filtersModel.reassessFilterInteractions();
+               return uri;
        };
 
        /**
-        * Get parameters representing the current state of the model
+        * Get an object representing given parameters that are unrecognized by the model
         *
-        * @return {Object} Uri query parameters
+        * @param  {Object} params Full params object
+        * @return {Object} Unrecognized params
         */
-       mw.rcfilters.UriProcessor.prototype.getUriParametersFromModel = function () {
-               return $.extend(
-                       true,
-                       {},
-                       this.filtersModel.getParametersFromFilters(),
-                       this.filtersModel.getHighlightParameters(),
-                       {
-                               highlight: String( Number( this.filtersModel.isHighlightEnabled() ) )
+       mw.rcfilters.UriProcessor.prototype.getUnrecognizedParams = function ( params ) {
+               // Start with full representation
+               var givenParamNames = Object.keys( params ),
+                       unrecognizedParams = $.extend( true, {}, params );
+
+               // Extract unrecognized parameters
+               Object.keys( this.filtersModel.getEmptyParameterState() ).forEach( function ( paramName ) {
+                       // Remove recognized params
+                       if ( givenParamNames.indexOf( paramName ) > -1 ) {
+                               delete unrecognizedParams[ paramName ];
                        }
-               );
+               } );
+
+               return unrecognizedParams;
        };
 
        /**
-        * Build the full parameter representation based on given query parameters
+        * Update the URL of the page to reflect current filters
         *
-        * @private
-        * @param {Object} uriQuery Given URI query
-        * @return {Object} Full parameter state representing the URI query
+        * This should not be called directly from outside the controller.
+        * If an action requires changing the URL, it should either use the
+        * highlighting actions below, or call #updateChangesList which does
+        * the uri corrections already.
+        *
+        * @param {Object} [params] Extra parameters to add to the API call
         */
-       mw.rcfilters.UriProcessor.prototype._expandModelParameters = function ( uriQuery ) {
-               var filterRepresentation = this.filtersModel.getFiltersFromParameters( uriQuery );
+       mw.rcfilters.UriProcessor.prototype.updateURL = function ( params ) {
+               var currentUri = new mw.Uri(),
+                       updatedUri = this.getUpdatedUri();
+
+               updatedUri.extend( params || {} );
+
+               if (
+                       this.getVersion( currentUri.query ) !== 2 ||
+                       this.isNewState( currentUri.query, updatedUri.query )
+               ) {
+                       this.constructor.static.replaceState( updatedUri );
+               }
+       };
 
-               return $.extend( true,
-                       {},
-                       uriQuery,
-                       this.filtersModel.getParametersFromFilters( filterRepresentation ),
-                       this.filtersModel.extractHighlightValues( uriQuery ),
-                       {
-                               highlight: String( Number( uriQuery.highlight ) )
-                       }
+       /**
+        * 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 ) {
+               this.filtersModel.updateStateFromParams(
+                       this._getNormalizedQueryParams( uriQuery || new mw.Uri().query )
                );
        };
 
                // 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 );
+               currentParamState = $.extend(
+                       true,
+                       {},
+                       this.filtersModel.getMinimizedParamRepresentation( currentUriQuery ),
+                       this.getUnrecognizedParams( currentUriQuery )
+               );
+               updatedParamState = $.extend(
+                       true,
+                       {},
+                       this.filtersModel.getMinimizedParamRepresentation( updatedUriQuery ),
+                       this.getUnrecognizedParams( updatedUriQuery )
+               );
 
                return notEquivalent( currentParamState, updatedParamState );
        };
         */
        mw.rcfilters.UriProcessor.prototype.doesQueryContainRecognizedParams = function ( uriQuery ) {
                var anyValidInUrl,
-                       validParameterNames = Object.keys( this._getEmptyParameterState() )
+                       validParameterNames = Object.keys( this.filtersModel.getEmptyParameterState() )
                                .filter( function ( param ) {
                                        // Remove 'highlight' parameter from this check;
                                        // if it's the only parameter in the URL we still
                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.
+        * Always merge in the hidden parameter defaults.
         *
         * @private
         * @param {Object} uriQuery Current URI query
                // wiki default.
                // Any subsequent change of the URL through the RCFilters
                // system will receive 'urlversion=2'
-               var hiddenParamDefaults = {},
+               var hiddenParamDefaults = this.filtersModel.getDefaultHiddenParams(),
                        base = this.getVersion( uriQuery ) === 2 ?
                                {} :
                                this.filtersModel.getDefaultParams();
 
-               // Go over the model and get all hidden parameters' defaults
-               // These defaults should be applied regardless of the urlversion
-               // but be overridden by the URL params if they exist
-               $.each( this.filtersModel.getFilterGroups(), function ( groupName, groupModel ) {
-                       if ( groupModel.isHidden() ) {
-                               $.extend( true, hiddenParamDefaults, groupModel.getDefaultParams() );
-                       }
-               } );
-
-               return this.minimizeQuery(
-                       $.extend( true, {}, hiddenParamDefaults, base, uriQuery, { urlversion: '2' } )
-               );
-       };
-
-       /**
-        * Get the representation of an empty parameter state
-        *
-        * @private
-        * @return {Object} Empty parameter state
-        */
-       mw.rcfilters.UriProcessor.prototype._getEmptyParameterState = function () {
-               // Override empty parameter state with the sticky parameter values
-               return $.extend( true, {}, this.emptyParameterState, this.filtersModel.getStickyParams() );
-       };
-
-       /**
-        * 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.getEmptyHighlightParameters();
-
-               this.emptyParameterState = $.extend(
+               return $.extend(
                        true,
                        {},
-                       emptyParams,
-                       emptyHighlights,
-                       { highlight: '0' }
+                       this.filtersModel.getMinimizedParamRepresentation(
+                               $.extend( true, {}, hiddenParamDefaults, base, uriQuery )
+                       ),
+                       { urlversion: '2' }
                );
        };
 }( mediaWiki, jQuery ) );
index 38ade4d..291d5c7 100644 (file)
@@ -6,24 +6,24 @@
                        title: 'Group 1',
                        type: 'send_unselected_if_any',
                        filters: [
-                               { name: 'filter1', default: true },
-                               { name: 'filter2' }
+                               { name: 'filter1', cssClass: 'filter1class', default: true },
+                               { name: 'filter2', cssClass: 'filter2class' }
                        ]
                }, {
                        name: 'group2',
                        title: 'Group 2',
                        type: 'send_unselected_if_any',
                        filters: [
-                               { name: 'filter3' },
-                               { name: 'filter4', default: true }
+                               { name: 'filter3', cssClass: 'filter3class' },
+                               { name: 'filter4', cssClass: 'filter4class', default: true }
                        ]
                }, {
                        name: 'group3',
                        title: 'Group 3',
                        type: 'string_options',
                        filters: [
-                               { name: 'filter5' },
-                               { name: 'filter6' }
+                               { name: 'filter5', cssClass: 'filter5class' },
+                               { name: 'filter6' } // Not supporting highlights
                        ]
                } ],
                minimalDefaultParams = {
                );
        } );
 
-       QUnit.test( 'updateModelBasedOnQuery & getUriParametersFromModel', function ( assert ) {
+       QUnit.test( 'getUpdatedUri', 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 = new mw.rcfilters.dm.FiltersViewModel();
+
+               filtersModel.initializeFilters( mockFilterStructure );
+               uriProcessor = new mw.rcfilters.UriProcessor( filtersModel );
+
+               assert.deepEqual(
+                       ( uriProcessor.getUpdatedUri( {} ) ).query,
+                       { urlversion: '2' },
+                       'Empty model state with empty uri state, assumes the given uri is already normalized, and adds urlversion=2'
+               );
+
+               assert.deepEqual(
+                       ( uriProcessor.getUpdatedUri( { foo: 'bar' } ) ).query,
+                       { urlversion: '2', foo: 'bar' },
+                       'Empty model state with unrecognized params retains unrecognized params'
+               );
+
+               // Update the model
+               filtersModel.toggleFiltersSelected( {
+                       group1__filter1: true, // Param: filter2: '1'
+                       group3__filter5: true // Param: group3: 'filter5'
+               } );
+
+               assert.deepEqual(
+                       ( uriProcessor.getUpdatedUri( {} ) ).query,
+                       { urlversion: '2', filter2: '1', group3: 'filter5' },
+                       'Model state is reflected in the updated URI'
+               );
+
+               assert.deepEqual(
+                       ( uriProcessor.getUpdatedUri( { foo: 'bar' } ) ).query,
+                       { urlversion: '2', filter2: '1', group3: 'filter5', foo: 'bar' },
+                       'Model state is reflected in the updated URI with existing uri params'
+               );
+       } );
+
+       QUnit.test( 'updateModelBasedOnQuery', function ( assert ) {
+               var uriProcessor,
+                       filtersModel = new mw.rcfilters.dm.FiltersViewModel();
 
                filtersModel.initializeFilters( mockFilterStructure );
                uriProcessor = new mw.rcfilters.UriProcessor( filtersModel );
 
                uriProcessor.updateModelBasedOnQuery( {} );
                assert.deepEqual(
-                       uriProcessor.getUriParametersFromModel(),
-                       $.extend( true, {}, baseParams, minimalDefaultParams ),
+                       filtersModel.getCurrentParameterState(),
+                       minimalDefaultParams,
                        'Version 1: Empty url query sets model to defaults'
                );
 
                uriProcessor.updateModelBasedOnQuery( { urlversion: '2' } );
                assert.deepEqual(
-                       uriProcessor.getUriParametersFromModel(),
-                       baseParams,
+                       filtersModel.getCurrentParameterState(),
+                       {},
                        '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' } ),
+                       filtersModel.getCurrentParameterState(),
+                       $.extend( true, {}, { 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, {
+                       filtersModel.getCurrentParameterState(),
+                       {
                                highlight: '1',
                                group1__filter1_color: 'c1'
-                       } ),
+                       },
                        'Highlight parameters in Uri query set highlight state in the model'
                );
        } );
index 4eec02a..dde49ba 100644 (file)
@@ -7,6 +7,7 @@
                                {
                                        name: 'filter1', label: 'group1filter1-label', description: 'group1filter1-desc',
                                        default: true,
+                                       cssClass: 'filter1class',
                                        conflicts: [ { group: 'group2' } ],
                                        subset: [
                                                {
@@ -22,6 +23,7 @@
                                {
                                        name: 'filter2', label: 'group1filter2-label', description: 'group1filter2-desc',
                                        conflicts: [ { group: 'group2', filter: 'filter6' } ],
+                                       cssClass: 'filter2class',
                                        subset: [
                                                {
                                                        group: 'group1',
                                                }
                                        ]
                                },
+                               // NOTE: This filter has no highlight!
                                { name: 'filter3', label: 'group1filter3-label', description: 'group1filter3-desc', default: true }
                        ]
                }, {
                        name: 'group2',
                        type: 'send_unselected_if_any',
                        fullCoverage: true,
+                       excludedFromSavedQueries: true,
                        conflicts: [ { group: 'group1', filter: 'filter1' } ],
                        filters: [
-                               { name: 'filter4', label: 'group2filter4-label', description: 'group2filter4-desc' },
-                               { name: 'filter5', label: 'group2filter5-label', description: 'group2filter5-desc', default: true },
+                               { name: 'filter4', label: 'group2filter4-label', description: 'group2filter4-desc', cssClass: 'filter4class' },
+                               { name: 'filter5', label: 'group2filter5-label', description: 'group2filter5-desc', default: true, cssClass: 'filter5class' },
                                {
-                                       name: 'filter6', label: 'group2filter6-label', description: 'group2filter6-desc',
+                                       name: 'filter6', label: 'group2filter6-label', description: 'group2filter6-desc', cssClass: 'filter6class',
                                        conflicts: [ { group: 'group1', filter: 'filter2' } ]
                                }
                        ]
                        separator: ',',
                        default: 'filter8',
                        filters: [
-                               { name: 'filter7', label: 'group3filter7-label', description: 'group3filter7-desc' },
-                               { name: 'filter8', label: 'group3filter8-label', description: 'group3filter8-desc' },
-                               { name: 'filter9', label: 'group3filter9-label', description: 'group3filter9-desc' }
+                               { name: 'filter7', label: 'group3filter7-label', description: 'group3filter7-desc', cssClass: 'filter7class' },
+                               { name: 'filter8', label: 'group3filter8-label', description: 'group3filter8-desc', cssClass: 'filter8class' },
+                               { name: 'filter9', label: 'group3filter9-label', description: 'group3filter9-desc', cssClass: 'filter9class' }
                        ]
                }, {
                        name: 'group4',
                        type: 'single_option',
                        default: 'option2',
                        filters: [
+                               // NOTE: The entire group has no highlight supported
                                { name: 'option1', label: 'group4option1-label', description: 'group4option1-desc' },
                                { name: 'option2', label: 'group4option2-label', description: 'group4option2-desc' },
                                { name: 'option3', label: 'group4option3-label', description: 'group4option3-desc' }
                        name: 'group5',
                        type: 'single_option',
                        filters: [
-                               { name: 'option1', label: 'group5option1-label', description: 'group5option1-desc' },
-                               { name: 'option2', label: 'group5option2-label', description: 'group5option2-desc' },
-                               { name: 'option3', label: 'group5option3-label', description: 'group5option3-desc' }
+                               { name: 'option1', label: 'group5option1-label', description: 'group5option1-desc', cssClass: 'group5opt1class' },
+                               { name: 'option2', label: 'group5option2-label', description: 'group5option2-desc', cssClass: 'group5opt2class' },
+                               { name: 'option3', label: 'group5option3-label', description: 'group5option3-desc', cssClass: 'group5opt3class' }
                        ]
                }, {
                        name: 'group6',
                        type: 'boolean',
                        isSticky: true,
                        filters: [
-                               { name: 'group6option1', label: 'group6option1-label', description: 'group6option1-desc' },
-                               { name: 'group6option2', label: 'group6option2-label', description: 'group6option2-desc', default: true },
-                               { name: 'group6option3', label: 'group6option3-label', description: 'group6option3-desc', default: true }
+                               { name: 'group6option1', label: 'group6option1-label', description: 'group6option1-desc', cssClass: 'group6opt1class' },
+                               { name: 'group6option2', label: 'group6option2-label', description: 'group6option2-desc', default: true, cssClass: 'group6opt2class' },
+                               { name: 'group6option3', label: 'group6option3-label', description: 'group6option3-desc', default: true, cssClass: 'group6opt3class' }
                        ]
                }, {
                        name: 'group7',
@@ -86,9 +91,9 @@
                        isSticky: true,
                        default: 'group7option2',
                        filters: [
-                               { name: 'group7option1', label: 'group7option1-label', description: 'group7option1-desc' },
-                               { name: 'group7option2', label: 'group7option2-label', description: 'group7option2-desc' },
-                               { name: 'group7option3', label: 'group7option3-label', description: 'group7option3-desc' }
+                               { name: 'group7option1', label: 'group7option1-label', description: 'group7option1-desc', cssClass: 'group7opt1class' },
+                               { name: 'group7option2', label: 'group7option2-label', description: 'group7option2-desc', cssClass: 'group7opt2class' },
+                               { name: 'group7option3', label: 'group7option3-label', description: 'group7option3-desc', cssClass: 'group7opt3class' }
                        ]
                } ],
                viewsDefinition = {
                                        type: 'string_options',
                                        separator: ';',
                                        filters: [
-                                               { name: 0, label: 'Main' },
-                                               { name: 1, label: 'Talk' },
-                                               { name: 2, label: 'User' },
-                                               { name: 3, label: 'User talk' }
+                                               { name: 0, label: 'Main', cssClass: 'namespace-0' },
+                                               { name: 1, label: 'Talk', cssClass: 'namespace-1' },
+                                               { name: 2, label: 'User', cssClass: 'namespace-2' },
+                                               { name: 3, label: 'User talk', cssClass: 'namespace-3' }
                                        ]
                                } ]
                        }
                        group7: 'group7option2',
                        namespace: ''
                },
+               emptyParamRepresentation = {
+                       filter1: '0',
+                       filter2: '0',
+                       filter3: '0',
+                       filter4: '0',
+                       filter5: '0',
+                       filter6: '0',
+                       group3: '',
+                       group4: '',
+                       group5: '',
+                       group6option1: '0',
+                       group6option2: '0',
+                       group6option3: '0',
+                       group7: '',
+                       namespace: '',
+                       highlight: '0',
+                       // Null highlights
+                       group1__filter1_color: null,
+                       group1__filter2_color: null,
+                       // group1__filter3_color: null, // Highlight isn't supported
+                       group2__filter4_color: null,
+                       group2__filter5_color: null,
+                       group2__filter6_color: null,
+                       group3__filter7_color: null,
+                       group3__filter8_color: null,
+                       group3__filter9_color: null,
+                       // group4__option1_color: null, // Highlight isn't supported
+                       // group4__option2_color: null, // Highlight isn't supported
+                       // group4__option3_color: null, // Highlight isn't supported
+                       group5__option1_color: null,
+                       group5__option2_color: null,
+                       group5__option3_color: null,
+                       group6__group6option1_color: null,
+                       group6__group6option2_color: null,
+                       group6__group6option3_color: null,
+                       group7__group7option1_color: null,
+                       group7__group7option2_color: null,
+                       group7__group7option3_color: null,
+                       namespace__0_color: null,
+                       namespace__1_color: null,
+                       namespace__2_color: null,
+                       namespace__3_color: null
+               },
                baseFilterRepresentation = {
                        group1__filter1: false,
                        group1__filter2: false,
                );
        } );
 
+       QUnit.test( 'Parameter minimal state', function ( assert ) {
+               var model = new mw.rcfilters.dm.FiltersViewModel(),
+                       cases = [
+                               {
+                                       input: {},
+                                       result: {},
+                                       msg: 'Empty parameter representation produces an empty result'
+                               },
+                               {
+                                       input: {
+                                               filter1: '1',
+                                               filter2: '0',
+                                               filter3: '0',
+                                               group3: '',
+                                               group4: 'option2'
+                                       },
+                                       result: {
+                                               filter1: '1',
+                                               group4: 'option2'
+                                       },
+                                       msg: 'Mixed input results in only non-falsey values as result'
+                               },
+                               {
+                                       input: {
+                                               filter1: '0',
+                                               filter2: '0',
+                                               filter3: '0',
+                                               group3: '',
+                                               group4: '',
+                                               group1__filter1_color: null
+                                       },
+                                       result: {},
+                                       msg: 'An all-falsey input results in an empty result.'
+                               },
+                               {
+                                       input: {
+                                               filter1: '0',
+                                               filter2: '0',
+                                               filter3: '0',
+                                               group3: '',
+                                               group4: '',
+                                               group1__filter1_color: 'c1'
+                                       },
+                                       result: {
+                                               group1__filter1_color: 'c1'
+                                       },
+                                       msg: 'An all-falsey input with highlight params result in only the highlight param.'
+                               },
+                               {
+                                       input: {
+                                               group1__filter1_color: 'c1',
+                                               group1__filter3_color: 'c3' // Not supporting highlights
+                                       },
+                                       result: {
+                                               group1__filter1_color: 'c1'
+                                       },
+                                       msg: 'Unsupported highlights are removed.'
+                               }
+                       ];
+
+               model.initializeFilters( filterDefinition, viewsDefinition );
+
+               cases.forEach( function ( test ) {
+                       assert.deepEqual(
+                               model.getMinimizedParamRepresentation( test.input ),
+                               test.result,
+                               test.msg
+                       );
+               } );
+       } );
+
+       QUnit.test( 'Parameter states', function ( assert ) {
+               // Some groups / params have their defaults immediately applied
+               // to their state. These include single_option which can never
+               // be empty, etc. These are these states:
+               var parametersWithoutExcluded,
+                       appliedDefaultParameters = {
+                               group4: 'option2',
+                               group5: 'option1',
+                               // Sticky, their defaults apply immediately
+                               group6option2: '1',
+                               group6option3: '1',
+                               group7: 'group7option2'
+                       },
+                       model = new mw.rcfilters.dm.FiltersViewModel();
+
+               model.initializeFilters( filterDefinition, viewsDefinition );
+               assert.deepEqual(
+                       model.getEmptyParameterState(),
+                       emptyParamRepresentation,
+                       'Producing an empty parameter state'
+               );
+
+               model.toggleFiltersSelected( {
+                       group1__filter1: true,
+                       group3__filter7: true
+               } );
+
+               assert.deepEqual(
+                       model.getCurrentParameterState(),
+                       // appliedDefaultParams applies the default value to parameters
+                       // who must have an initial value to begin with, so we have to
+                       // take it into account in the current state
+                       $.extend( true, {}, appliedDefaultParameters, {
+                               filter2: '1',
+                               filter3: '1',
+                               group3: 'filter7'
+                       } ),
+                       'Producing a current parameter state'
+               );
+
+               // Reset
+               model = new mw.rcfilters.dm.FiltersViewModel();
+               model.initializeFilters( filterDefinition, viewsDefinition );
+
+               parametersWithoutExcluded = $.extend( true, {}, appliedDefaultParameters );
+               delete parametersWithoutExcluded.group7;
+               delete parametersWithoutExcluded.group6option2;
+               delete parametersWithoutExcluded.group6option3;
+
+               assert.deepEqual(
+                       model.getCurrentParameterState( true ),
+                       parametersWithoutExcluded,
+                       'Producing a current clean parameter state without excluded filters'
+               );
+       } );
+
+       QUnit.test( 'Cleaning up parameter states', function ( assert ) {
+               var model = new mw.rcfilters.dm.FiltersViewModel(),
+                       cases = [
+                               {
+                                       input: {},
+                                       result: {},
+                                       msg: 'Empty parameter representation produces an empty result'
+                               },
+                               {
+                                       input: {
+                                               filter1: '1', // Regular (do not strip)
+                                               group6option1: '1', // Sticky
+                                               filter4: '1', // Excluded
+                                               filter5: '0' // Excluded
+                                       },
+                                       result: { filter1: '1' },
+                                       msg: 'Valid input strips all sticky and excluded params regardless of value'
+                               }
+                       ];
+
+               model.initializeFilters( filterDefinition, viewsDefinition );
+
+               cases.forEach( function ( test ) {
+                       assert.deepEqual(
+                               model.removeExcludedParams( test.input ),
+                               test.result,
+                               test.msg
+                       );
+               } );
+
+       } );
+
        QUnit.test( 'Finding matching filters', function ( assert ) {
                var matches,
                        testCases = [
index 6a05920..539bab4 100644 (file)
@@ -6,28 +6,38 @@
                        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: 'filter1', cssClass: 'filter1class' },
+                               { name: 'filter2', cssClass: 'filter2class', default: true },
+                               { name: 'filter3', cssClass: 'filter3class' }
                        ]
                }, {
                        name: 'group2',
                        type: 'string_options',
                        separator: ',',
                        filters: [
-                               { name: 'filter4' },
-                               { name: 'filter5' },
-                               { name: 'filter6' }
+                               { name: 'filter4', cssClass: 'filter4class' },
+                               { name: 'filter5' }, // NOTE: Not supporting highlights!
+                               { name: 'filter6', cssClass: 'filter6class' }
                        ]
                }, {
                        name: 'group3',
                        type: 'boolean',
                        isSticky: true,
                        filters: [
-                               { name: 'group3option1' },
-                               { name: 'group3option2' },
-                               { name: 'group3option3' }
+                               { name: 'group3option1', cssClass: 'filter1class' },
+                               { name: 'group3option2', cssClass: 'filter1class' },
+                               { name: 'group3option3', cssClass: 'filter1class' }
                        ]
+               }, {
+                       // Copy of the way the controller defines invert
+                       // to check whether the conversion works
+                       name: 'invertGroup',
+                       type: 'boolean',
+                       hidden: true,
+                       filters: [ {
+                               name: 'invert',
+                               default: '0'
+                       } ]
                } ],
                queriesFilterRepresentation = {
                        queries: {
                                                },
                                                highlights: {
                                                        highlight: true,
-                                                       filter1: 'c5',
-                                                       group3option1: 'c1'
-                                               }
+                                                       group1__filter1: 'c5',
+                                                       group3__group3option1: 'c1'
+                                               },
+                                               invert: true
                                        }
                                }
                        }
@@ -78,8 +89,8 @@
                                                        highlight: '1'
                                                },
                                                highlights: {
-                                                       filter1_color: 'c5',
-                                                       group3option1_color: 'c1'
+                                                       group1__filter1_color: 'c5',
+                                                       group3__group3option1_color: 'c1'
                                                }
                                        }
                                }
                                                                filter2: '1'
                                                        },
                                                        highlights: {
-                                                               filter5_color: 'c2'
+                                                               group1__filter3_color: 'c2'
                                                        }
                                                }
                                        }
                                        finalState: $.extend( true, {}, queriesParamRepresentation ),
                                        msg: 'Conversion from filter representation to parameters retains data.'
                                },
+                               {
+                                       // Converting from old structure
+                                       input: $.extend( true, {}, queriesFilterRepresentation, { queries: { 1234: { data: {
+                                               filters: {
+                                                       // Entire group true: normalize params
+                                                       filter1: true,
+                                                       filter2: true,
+                                                       filter3: true
+                                               },
+                                               highlights: {
+                                                       filter3: null // Get rid of empty highlight
+                                               }
+                                       } } } } ),
+                                       finalState: $.extend( true, {}, queriesParamRepresentation ),
+                                       msg: 'Conversion from filter representation to parameters normalizes params and highlights.'
+                               },
                                {
                                        // Converting from old structure with default
                                        input: $.extend( true, { default: '1234' }, queriesFilterRepresentation ),
                                        input: $.extend( true, {}, queriesParamRepresentation ),
                                        finalState: $.extend( true, {}, queriesParamRepresentation ),
                                        msg: 'Parameter representation retains its queries structure'
+                               },
+                               {
+                                       // Do not touch invalid color parameters from the initialization routine
+                                       // (Normalization, or "fixing" the query should only happen when we add new query or actively convert queries)
+                                       input: $.extend( true, { queries: { 1234: { data: { highlights: { group2__filter5_color: 'c2' } } } } }, exampleQueryStructure ),
+                                       finalState: $.extend( true, { queries: { 1234: { data: { highlights: { group2__filter5_color: 'c2' } } } } }, exampleQueryStructure ),
+                                       msg: 'Structure that contains invalid highlights remains the same in initialization'
                                }
                        ];
 
                } );
        } );
 
+       QUnit.test( 'Adding new queries', function ( assert ) {
+               var filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
+                       queriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ),
+                       cases = [
+                               {
+                                       methodParams: [
+                                               'label1', // Label
+                                               { // Data
+                                                       filter1: '1',
+                                                       filter2: '2',
+                                                       group1__filter1_color: 'c2',
+                                                       group1__filter3_color: 'c5'
+                                               },
+                                               true, // isDefault
+                                               '1234' // ID
+                                       ],
+                                       result: {
+                                               itemState: {
+                                                       label: 'label1',
+                                                       data: {
+                                                               params: {
+                                                                       filter1: '1',
+                                                                       filter2: '2'
+                                                               },
+                                                               highlights: {
+                                                                       group1__filter1_color: 'c2',
+                                                                       group1__filter3_color: 'c5'
+                                                               }
+                                                       }
+                                               },
+                                               isDefault: true,
+                                               id: '1234'
+                                       },
+                                       msg: 'Given valid data is preserved.'
+                               },
+                               {
+                                       methodParams: [
+                                               'label2',
+                                               {
+                                                       filter1: '1',
+                                                       invert: '1',
+                                                       filter15: '1', // Invalid filter - removed
+                                                       filter2: '0', // Falsey value - removed
+                                                       group1__filter1_color: 'c3',
+                                                       foobar: 'w00t' // Unrecognized parameter - removed
+                                               }
+                                       ],
+                                       result: {
+                                               itemState: {
+                                                       label: 'label2',
+                                                       data: {
+                                                               params: {
+                                                                       filter1: '1',
+                                                                       invert: '1'
+                                                               },
+                                                               highlights: {
+                                                                       group1__filter1_color: 'c3'
+                                                               }
+                                                       }
+                                               },
+                                               isDefault: false
+                                       },
+                                       msg: 'Given data with invalid filters and highlights is normalized'
+                               }
+                       ];
+
+               filtersModel.initializeFilters( filterDefinition );
+
+               // Start with an empty saved queries model
+               queriesModel.initialize( {} );
+
+               cases.forEach( function ( testCase ) {
+                       var itemID = queriesModel.addNewQuery.apply( queriesModel, testCase.methodParams ),
+                               item = queriesModel.getItemByID( itemID );
+
+                       assert.deepEqual(
+                               item.getState(),
+                               testCase.result.itemState,
+                               testCase.msg + ' (itemState)'
+                       );
+
+                       assert.equal(
+                               item.isDefault(),
+                               testCase.result.isDefault,
+                               testCase.msg + ' (isDefault)'
+                       );
+
+                       if ( testCase.result.id !== undefined ) {
+                               assert.equal(
+                                       item.getID(),
+                                       testCase.result.id,
+                                       testCase.msg + ' (item ID)'
+                               );
+                       }
+               } );
+       } );
+
        QUnit.test( 'Manipulating queries', function ( assert ) {
                var id1, id2, item1, matchingItem,
                        queriesStructure = {},
                id1 = queriesModel.addNewQuery(
                        'New query 1',
                        {
-                               params: {
-                                       group2: 'filter5',
-                                       highlight: '1'
-                               },
-                               highlights: {
-                                       filter1_color: 'c5',
-                                       group3option1_color: 'c1'
-                               }
+                               group2: 'filter5',
+                               highlight: '1',
+                               group1__filter1_color: 'c5',
+                               group3__group3option1_color: 'c1'
                        }
                );
                id2 = queriesModel.addNewQuery(
                        'New query 2',
                        {
-                               params: {
-                                       filter1: '1',
-                                       filter2: '1',
-                                       invert: '1'
-                               },
-                               highlights: {}
+                               filter1: '1',
+                               filter2: '1',
+                               invert: '1'
                        }
                );
                item1 = queriesModel.getItemByID( id1 );
                                        highlight: '1'
                                },
                                highlights: {
-                                       filter1_color: 'c5',
-                                       group3option1_color: 'c1'
+                                       group1__filter1_color: 'c5',
+                                       group3__group3option1_color: 'c1'
                                }
                        }
                };
                // Find matching query
                matchingItem = queriesModel.findMatchingQuery(
                        {
-                               params: {
-                                       group2: 'filter5',
-                                       highlight: '1'
-                               },
-                               highlights: {
-                                       filter1_color: 'c5',
-                                       group3option1_color: 'c1'
-                               }
+                               highlight: '1',
+                               group2: 'filter5',
+                               group1__filter1_color: 'c5',
+                               group3__group3option1_color: 'c1'
                        }
                );
                assert.deepEqual(
                // Find matching query with 0-values (base state)
                matchingItem = queriesModel.findMatchingQuery(
                        {
-                               params: {
-                                       group2: 'filter5',
-                                       filter1: '0',
-                                       filter2: '0',
-                                       highlight: '1'
-                               },
-                               highlights: {
-                                       filter1_color: 'c5',
-                                       group3option1_color: 'c1'
-                               }
+                               group2: 'filter5',
+                               filter1: '0',
+                               filter2: '0',
+                               highlight: '1',
+                               invert: '0',
+                               group1__filter1_color: 'c5',
+                               group3__group3option1_color: 'c1'
                        }
                );
                assert.deepEqual(