diff --git a/client/app/scripts/reducers/__tests__/root-test.js b/client/app/scripts/reducers/__tests__/root-test.js index cf8d168e3..ad58be14d 100644 --- a/client/app/scripts/reducers/__tests__/root-test.js +++ b/client/app/scripts/reducers/__tests__/root-test.js @@ -352,7 +352,7 @@ describe('RootReducer', () => { expect(activeTopologyOptionsSelector(nextState).has('option1')).toBeTruthy(); expect(activeTopologyOptionsSelector(nextState).get('option1')).toBeInstanceOf(Array); expect(activeTopologyOptionsSelector(nextState).get('option1')).toEqual(['off']); - expect(getUrlState(nextState).topologyOptions.topo1.option1).toEqual(['off']); + expect(getUrlState(nextState).topologyOptions).toBeUndefined(); // turn on nextState = reducer(nextState, ChangeTopologyOptionAction); @@ -362,17 +362,17 @@ describe('RootReducer', () => { // turn off nextState = reducer(nextState, ChangeTopologyOptionAction2); expect(activeTopologyOptionsSelector(nextState).get('option1')).toEqual(['off']); - expect(getUrlState(nextState).topologyOptions.topo1.option1).toEqual(['off']); + expect(getUrlState(nextState).topologyOptions).toBeUndefined(); // sub-topology should retain main topo options nextState = reducer(nextState, ClickSubTopologyAction); expect(activeTopologyOptionsSelector(nextState).get('option1')).toEqual(['off']); - expect(getUrlState(nextState).topologyOptions.topo1.option1).toEqual(['off']); + expect(getUrlState(nextState).topologyOptions).toBeUndefined(); // other topology w/o options dont return options, but keep in app state nextState = reducer(nextState, ClickTopology2Action); expect(activeTopologyOptionsSelector(nextState).size).toEqual(0); - expect(getUrlState(nextState).topologyOptions.topo1.option1).toEqual(['off']); + expect(getUrlState(nextState).topologyOptions).toBeUndefined(); }); it('adds/removes a topology option', () => { @@ -433,7 +433,7 @@ describe('RootReducer', () => { nextState = reducer(nextState, ReceiveTopologiesAction); nextState = reducer(nextState, ClickTopologyAction); expect(activeTopologyOptionsSelector(nextState).get('option1')).toEqual(['off']); - expect(getUrlState(nextState).topologyOptions.topo1.option1).toEqual(['off']); + expect(getUrlState(nextState).topologyOptions).toBeUndefined(); }); // nodes delta @@ -479,7 +479,7 @@ describe('RootReducer', () => { nextState = reducer(nextState, ReceiveTopologiesAction); nextState = reducer(nextState, ClickTopologyAction); nextState = reducer(nextState, ReceiveNodesDeltaAction); - expect(getUrlState(nextState).selectedNodeId).toEqual(null); + expect(getUrlState(nextState).selectedNodeId).toBeUndefined(); nextState = reducer(nextState, ClickNodeAction); expect(getUrlState(nextState).selectedNodeId).toEqual('n1'); @@ -487,7 +487,7 @@ describe('RootReducer', () => { // go back in browsing RouteAction.state = {topologyId: 'topo1', selectedNodeId: null}; nextState = reducer(nextState, RouteAction); - expect(nextState.get('selectedNodeId')).toBe(null); + expect(nextState.get('selectedNodeId')).toBeNull(); expect(nextState.get('nodes').toJS()).toEqual(NODE_SET); }); @@ -497,7 +497,7 @@ describe('RootReducer', () => { nextState = reducer(nextState, ClickTopologyAction); nextState = reducer(nextState, ReceiveNodesDeltaAction); - expect(getUrlState(nextState).selectedNodeId).toEqual(null); + expect(getUrlState(nextState).selectedNodeId).toBeUndefined(); expect(getUrlState(nextState).topologyId).toEqual('topo1'); nextState = reducer(nextState, ClickNodeAction); @@ -505,7 +505,7 @@ describe('RootReducer', () => { expect(getUrlState(nextState).topologyId).toEqual('topo1'); nextState = reducer(nextState, ClickSubTopologyAction); - expect(getUrlState(nextState).selectedNodeId).toEqual(null); + expect(getUrlState(nextState).selectedNodeId).toBeUndefined(); expect(getUrlState(nextState).topologyId).toEqual('topo1-grouped'); }); diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 8d9e4ad68..8e04ed00d 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -71,7 +71,7 @@ export const initialState = makeMap({ pinnedSearches: makeList(), // list of node filters routeSet: false, searchFocused: false, - searchQuery: null, + searchQuery: '', selectedNetwork: null, selectedNodeId: null, showingHelp: false, @@ -135,8 +135,9 @@ function setTopology(state, topologyId) { return state.set('currentTopologyId', topologyId); } -function setDefaultTopologyOptions(state, topologyList) { - topologyList.forEach((topology) => { +export function getDefaultTopologyOptions(state) { + let topologyOptions = makeOrderedMap(); + state.get('topologies').forEach((topology) => { let defaultOptions = makeOrderedMap(); if (topology.has('options') && topology.get('options')) { topology.get('options').forEach((option) => { @@ -145,15 +146,11 @@ function setDefaultTopologyOptions(state, topologyList) { defaultOptions = defaultOptions.set(optionId, [defaultValue]); }); } - if (defaultOptions.size) { - state = state.setIn( - ['topologyOptions', topology.get('id')], - defaultOptions - ); + topologyOptions = topologyOptions.set(topology.get('id'), defaultOptions); } }); - return state; + return topologyOptions; } function closeNodeDetails(state, nodeId) { @@ -659,7 +656,7 @@ export function rootReducer(state = initialState, action) { state = setTopology(state, state.get('currentTopologyId')); // only set on first load, if options are not already set via route if (!state.get('topologiesLoaded') && state.get('topologyOptions').size === 0) { - state = setDefaultTopologyOptions(state, state.get('topologies')); + state = state.set('topologyOptions', getDefaultTopologyOptions(state)); } state = state.set('topologiesLoaded', true); @@ -686,12 +683,13 @@ export function rootReducer(state = initialState, action) { state = clearNodes(state); } state = setTopology(state, action.state.topologyId); - state = setDefaultTopologyOptions(state, state.get('topologies')); state = state.merge({ selectedNodeId: action.state.selectedNodeId, - pinnedMetricType: action.state.pinnedMetricType + pinnedMetricType: action.state.pinnedMetricType, }); - state = state.set('topologyViewMode', action.state.topologyViewMode); + if (action.state.topologyViewMode) { + state = state.set('topologyViewMode', action.state.topologyViewMode); + } if (action.state.gridSortedBy) { state = state.set('gridSortedBy', action.state.gridSortedBy); } @@ -722,9 +720,11 @@ export function rootReducer(state = initialState, action) { } else { state = state.update('nodeDetails', nodeDetails => nodeDetails.clear()); } + // Use the default topology options for all the fields not + // explicitly listed in the Scope state (URL or local storage). state = state.set( 'topologyOptions', - fromJS(action.state.topologyOptions) || state.get('topologyOptions') + getDefaultTopologyOptions(state).mergeDeep(action.state.topologyOptions), ); return state; } diff --git a/client/app/scripts/utils/hash-utils.js b/client/app/scripts/utils/hash-utils.js new file mode 100644 index 000000000..31cbbb216 --- /dev/null +++ b/client/app/scripts/utils/hash-utils.js @@ -0,0 +1,19 @@ +import { isPlainObject, mapValues, isEmpty, omitBy } from 'lodash'; + + +export function hashDifferenceDeep(A, B) { + // If the elements have exactly the same content, the difference is an empty object. + // This could fail if the objects are both hashes with different permutation of keys, + // but this case we handle below by digging in recursively. + if (JSON.stringify(A) === JSON.stringify(B)) return {}; + + // Otherwise, if either element is not a hash, always return the first element + // unchanged as this function only takes difference of hash objects. + if (!isPlainObject(A) || !isPlainObject(B)) return A; + + // If both elements are hashes, recursively take the difference by all keys + const rawDiff = mapValues(A, (value, key) => hashDifferenceDeep(value, B[key])); + + // ... and filter out all the empty values. + return omitBy(rawDiff, value => isEmpty(value)); +} diff --git a/client/app/scripts/utils/router-utils.js b/client/app/scripts/utils/router-utils.js index 2de85b253..16f29f8f7 100644 --- a/client/app/scripts/utils/router-utils.js +++ b/client/app/scripts/utils/router-utils.js @@ -1,9 +1,13 @@ import page from 'page'; -import { each } from 'lodash'; +import { fromJS, is as isDeepEqual } from 'immutable'; +import { each, omit, omitBy, isEmpty } from 'lodash'; import { route } from '../actions/app-actions'; +import { hashDifferenceDeep } from './hash-utils'; import { storageGet, storageSet } from './storage-utils'; +import { getDefaultTopologyOptions, initialState as initialRootState } from '../reducers/root'; + // // page.js won't match the routes below if ":state" has a slash in it, so replace those before we // load the state into the URL. @@ -41,11 +45,37 @@ function shouldReplaceState(prevState, nextState) { return terminalToTerminal || closingTheTerminal; } +function omitDefaultValues(urlState) { + // A couple of cases which require special handling because their URL state + // default values might be in different format than their Redux defaults. + if (!urlState.controlPipe) { + urlState = omit(urlState, 'controlPipe'); + } + if (isEmpty(urlState.nodeDetails)) { + urlState = omit(urlState, 'nodeDetails'); + } + if (isEmpty(urlState.topologyOptions)) { + urlState = omit(urlState, 'topologyOptions'); + } + + // Omit all the fields which match their initial Redux state values. + return omitBy(urlState, (value, key) => ( + isDeepEqual(fromJS(value), initialRootState.get(key)) + )); +} + export function getUrlState(state) { const cp = state.get('controlPipes').last(); const nodeDetails = state.get('nodeDetails').toIndexedSeq().map(details => ({ - id: details.id, label: details.label, topologyId: details.topologyId + id: details.id, topologyId: details.topologyId })); + // Compress the topologyOptions hash by removing all the default options, to make + // the Scope state string smaller. The default options will always be used as a + // fallback so they don't need to be explicitly mentioned in the state. + const topologyOptionsDiff = hashDifferenceDeep( + state.get('topologyOptions').toJS(), + getDefaultTopologyOptions(state).toJS(), + ); const urlState = { controlPipe: cp ? cp.toJS() : null, @@ -59,7 +89,7 @@ export function getUrlState(state) { gridSortedBy: state.get('gridSortedBy'), gridSortedDesc: state.get('gridSortedDesc'), topologyId: state.get('currentTopologyId'), - topologyOptions: state.get('topologyOptions').toJS(), // all options, + topologyOptions: topologyOptionsDiff, contrastMode: state.get('contrastMode'), }; @@ -70,7 +100,9 @@ export function getUrlState(state) { } } - return urlState; + // We can omit all the fields whose values correspond to their Redux initial + // state, as that state will be used as fallback anyway when entering routes. + return omitDefaultValues(urlState); } export function updateRoute(getState) {