diff --git a/package-lock.json b/package-lock.json index 315fe8d..fe1fe28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "classnames": "^2.2.5", "hash.js": "^1.1.7", "is-my-json-valid": "^2.20.6", + "lz-string": "^1.5.0", "oauth-1.0a": "^2.0.0", "qs": "^6.3.0", "react": "^16.14.0", @@ -2768,6 +2769,14 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", diff --git a/package.json b/package.json index 2aa5247..ec39f61 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "classnames": "^2.2.5", "hash.js": "^1.1.7", "is-my-json-valid": "^2.20.6", + "lz-string": "^1.5.0", "oauth-1.0a": "^2.0.0", "qs": "^6.3.0", "react": "^16.14.0", diff --git a/src/lib/redux/cache.js b/src/lib/redux/cache.js index f1d0b55..cc562eb 100644 --- a/src/lib/redux/cache.js +++ b/src/lib/redux/cache.js @@ -1,4 +1,6 @@ import { SERIALIZE, DESERIALIZE } from './action-types'; +import { urlParamsToStateObj } from './serialize-to-url-middleware'; +import { deepMerge } from '../utils'; const DAY_IN_HOURS = 24; const HOUR_IN_MS = 3600000; @@ -19,12 +21,26 @@ function deserialize( state, reducer ) { } export function loadInitialState( initialState, reducer ) { + let state = initialState; + + // Look for serialized state in localStorage const localStorageState = JSON.parse( localStorage.getItem( STORAGE_KEY ) ) || {}; - if ( localStorageState._timestamp && localStorageState._timestamp + MAX_AGE < Date.now() ) { - return initialState; + if ( localStorageState._timestamp && localStorageState._timestamp + MAX_AGE >= Date.now() ) { + state = deserialize( localStorageState, reducer ); + } + + // Use deepMerge here to reconcile state derived from localStorage with + // enhancements from URL parameters. It ensures a comprehensive application + // state at launch by merging saved states and any state that's encoded in + // the URL. This is important when the URL provides partial state updates, + // which must be combined with existing state without loss of detail. + let urlParams = new URL( window.location.href ).searchParams; + let stateEnhancement = urlParamsToStateObj( urlParams ); + if ( stateEnhancement ) { + state = deepMerge( state, stateEnhancement ); } - return deserialize( localStorageState, reducer ); + return state; } export function persistState( store, reducer ) { diff --git a/src/lib/redux/serialize-to-url-middleware.js b/src/lib/redux/serialize-to-url-middleware.js new file mode 100644 index 0000000..1cc687b --- /dev/null +++ b/src/lib/redux/serialize-to-url-middleware.js @@ -0,0 +1,123 @@ +import reducers from '../../state/reducer'; +import { + API_ENDPOINTS_RECEIVE, + REQUEST_SELECT_ENDPOINT, + REQUEST_SET_METHOD, + REQUEST_TRIGGER, + UI_SELECT_API, + UI_SELECT_VERSION, + SERIALIZE_URL, + DESERIALIZE_URL, +} from '../../state/actions'; +import { getEndpoints } from '../../state/endpoints/selectors'; +import { loadEndpoints } from '../../state/endpoints/actions'; + +/** + * This lets us serialize the state to the URL. + * + * Note: Serialization is only ran on a few actions listed below (actionsThatUpdateUrl). + * + * The entire state is not serialized. Reducers are responsible for implementing + * SERIALIZE_URL and DESERIALIZE_URL actions to handle their own state if they want to + * serialize it into the URL. They don't have to serialize all keys, or serialize at all. + * If they choose to only serialize some keys, the results will be deep merged over the + * the current state stored in localStorage by cache.js. + * + **/ + +// Given a state, return a string that can be used as a URL query string. +export const serializeStateToURLString = ( state ) => { + const serializedState = reducers( state, { type: SERIALIZE_URL } ); + + const urlParams = new URLSearchParams(); + for ( const [ key, value ] of Object.entries( serializedState ) ) { + if ( typeof value === 'string' && value ) { + urlParams.set( key, value ); + } + } + return urlParams.toString(); +}; + +// Given URL Params, return a state enhancement object that can be used to enhance the state. +export const urlParamsToStateObj = ( urlParams ) => { + // Convert urlParams to a plain object + let paramsObject = {}; + for ( let [ key, value ] of urlParams.entries() ) { + paramsObject[ key ] = value; + } + + // Let each reducer handle its own state + const deserializedState = reducers( paramsObject, { type: DESERIALIZE_URL } ); + return deserializedState; +}; + +// On these actions, we compute the new URL and push it to the browser history +const actionsThatUpdateUrl = [ + REQUEST_TRIGGER, + UI_SELECT_API, + UI_SELECT_VERSION, + REQUEST_SET_METHOD, + REQUEST_SELECT_ENDPOINT, +]; + +// On our initial load, we check the URL params to see if we need to send a request to load endpoints. +const initializeFromUrl = ( store, urlParams ) => { + const stateEnhancement = urlParamsToStateObj( urlParams ); + const { + ui: { api: apiFromUrl, version: versionFromUrl }, + request: { endpointPathLabeledInURL }, + } = stateEnhancement; + if ( endpointPathLabeledInURL && apiFromUrl && versionFromUrl ) { + // They did send an endpointPath in the URL. In order to fill the entire + // endpoint state, we need to load all endpoints, then we can find a match after load. + const { dispatch } = store; + loadEndpoints( apiFromUrl, versionFromUrl )( dispatch ); + return { isInitializing: true, endpointPathLabeledInURL }; + } + return { isInitializing: false }; +}; + +// This middleware is responsible for serializing the state to the URL. +// It also handles a special case of loading endpoints and setting the selected endpoint. +export const serializeToUrlMiddleware = ( store ) => { + // When first loading, check the URL params to see if we need to send a request to load endpoints. + const urlParams = new URL( window.location.href ).searchParams; + let { isInitializing, endpointPathLabeledInURL } = initializeFromUrl( store, urlParams ); + + // The actual middleware that runs on every action. + return ( next ) => ( action ) => { + const result = next( action ); + + // Serialize and upate the URL. + if ( actionsThatUpdateUrl.includes( action.type ) ) { + const state = store.getState(); + const serializedState = serializeStateToURLString( state ); + + const url = new URL( window.location ); + url.search = serializedState; + window.history.pushState( {}, '', url ); + } + + // Choose the correct endpoint once per load. + if ( isInitializing && action.type === API_ENDPOINTS_RECEIVE ) { + selectCorrectEndpoint( store, endpointPathLabeledInURL ); + isInitializing = false; + } + + return result; + }; +}; + +const selectCorrectEndpoint = ( store, endpointPathLabeledInURL ) => { + const state = store.getState(); + const endpoints = getEndpoints( state, state.ui.api, state.ui.version ); + const endpoint = endpoints.find( + ( { pathLabeled } ) => pathLabeled === endpointPathLabeledInURL + ); + if ( endpoint ) { + store.dispatch( { type: REQUEST_SELECT_ENDPOINT, payload: { endpoint } } ); + } +}; + + +export default serializeToUrlMiddleware; diff --git a/src/lib/utils.js b/src/lib/utils.js index a1d8389..f6b2b97 100644 --- a/src/lib/utils.js +++ b/src/lib/utils.js @@ -1,3 +1,4 @@ +import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string'; const objectCtorString = Function.prototype.toString.call( Object ); export function isPlainObject( value ) { @@ -17,8 +18,77 @@ export function isPlainObject( value ) { const Ctor = Object.hasOwnProperty.call( proto, 'constructor' ) && proto.constructor; return ( - typeof Ctor === 'function' && - Ctor instanceof Ctor && + typeof Ctor === 'function' && + Ctor instanceof Ctor && Function.prototype.toString.call( Ctor ) === objectCtorString ); } + +// Use this to make the URL shorter +const keyMap = { + method: 'me', + endpoint: 'ep', + pathValues: 'pv', + url: 'u', + queryParams: 'qp', + bodyParams: 'bp', + endpointPathLabeledInURL: 'epu', + version: 've', + api: 'ap', +}; + +// Filter and serialize part of the Redux state for URL encoding +export const serializeStateForUrl = ( state, keysToKeep ) => { + const filteredState = keysToKeep.reduce( ( obj, key ) => { + const shortKey = keyMap[ key ] || key; + if ( state.hasOwnProperty( key ) ) { + obj[ shortKey ] = state[ key ]; + } + return obj; + }, {} ); + + const jsonString = JSON.stringify( filteredState ); + return compressToEncodedURIComponent( jsonString ); +}; + +// Deserialize the encoded string back to state object +export const deserializeStateFromUrl = ( base64String, keysToKeep ) => { + try { + if ( typeof base64String !== 'string' ) { + return {}; + } + const jsonString = decompressFromEncodedURIComponent( base64String ); + const parsedState = JSON.parse( jsonString ); + + // Validate the parsed state contains only the keys we're interested in + return keysToKeep.reduce( ( obj, key ) => { + const shortKey = keyMap[ key ] || key; + if ( parsedState.hasOwnProperty( shortKey ) ) { + obj[ key ] = parsedState[ shortKey ]; + } + return obj; + }, {} ); + } catch ( error ) { + console.error( 'Error deserializing state from URL:', error ); + return {}; + } +}; + +export const isObject = ( item ) => { + return item && typeof item === 'object' && ! Array.isArray( item ); +}; + +export const deepMerge = ( target, source ) => { + let output = Object.assign( {}, target ); + if ( isObject( target ) && isObject( source ) ) { + Object.keys( source ).forEach( ( key ) => { + if ( isObject( source[ key ] ) ) { + if ( ! ( key in target ) ) Object.assign( output, { [ key ]: source[ key ] } ); + else output[ key ] = deepMerge( target[ key ], source[ key ] ); + } else { + Object.assign( output, { [ key ]: source[ key ] } ); + } + } ); + } + return output; +}; diff --git a/src/state/actions.js b/src/state/actions.js index dc08597..2cefcad 100644 --- a/src/state/actions.js +++ b/src/state/actions.js @@ -17,3 +17,6 @@ export const REQUEST_RESULTS_RECEIVE = 'REQUEST_RESULTS_RECEIVE'; export const SECURITY_CHECK_FAILED = 'SECURITY_CHECK_FAILED'; export const SECURITY_RECEIVE_USER = 'SECURITY_RECEIVE_USER'; export const SECURITY_LOGOUT = 'SECURITY_LOGOUT'; + +export const SERIALIZE_URL = 'SERIALIZE_URL'; +export const DESERIALIZE_URL = 'DESERIALIZE_URL'; diff --git a/src/state/index.js b/src/state/index.js index b95b768..b79e0e0 100644 --- a/src/state/index.js +++ b/src/state/index.js @@ -4,11 +4,12 @@ import thunk from 'redux-thunk'; import reducer from './reducer'; import { boot } from './security/actions'; import { loadInitialState, persistState } from '../lib/redux/cache'; +import serializeToUrlMiddleware from '../lib/redux/serialize-to-url-middleware'; const store = createStore( reducer, loadInitialState( {}, reducer ), - applyMiddleware( thunk ) + applyMiddleware( thunk, serializeToUrlMiddleware ) ); persistState( store, reducer ); store.dispatch( boot() ); diff --git a/src/state/request/reducer.js b/src/state/request/reducer.js index a072d2b..0f29b7a 100644 --- a/src/state/request/reducer.js +++ b/src/state/request/reducer.js @@ -1,5 +1,7 @@ import { createReducer } from '../../lib/redux/create-reducer'; import { + SERIALIZE_URL, + DESERIALIZE_URL, REQUEST_SET_METHOD, REQUEST_SELECT_ENDPOINT, REQUEST_UPDATE_URL, @@ -10,6 +12,7 @@ import { UI_SELECT_VERSION, } from '../actions'; import schema from './schema'; +import { serializeStateForUrl, deserializeStateFromUrl } from '../../lib/utils'; const defaultState = { method: 'GET', @@ -18,9 +21,19 @@ const defaultState = { url: '', queryParams: {}, bodyParams: {}, + endpointPathLabeledInURL: '', // A key of which endpoint is selected, used for url serialization. This is a special case that requires coordination between reducers and is handled in middleware. }; const reducer = createReducer( defaultState, { + [ SERIALIZE_URL ]: ( state ) => + serializeStateForUrl( state, [ 'url', 'queryParams', 'pathValues', 'method', 'bodyParams', 'endpointPathLabeledInURL' ] ), + [ DESERIALIZE_URL ]: ( state ) => { + let newState = deserializeStateFromUrl( state, [ 'url', 'queryParams', 'pathValues', 'method', 'bodyParams', 'endpointPathLabeledInURL' ] ); + if ( ! newState.endpointPathLabeledInURL ) { + newState.endpoint = false; + } + return newState; + }, [ REQUEST_SET_METHOD ]: ( state, { payload } ) => { return ( { ...state, @@ -31,6 +44,7 @@ const reducer = createReducer( defaultState, { return ( { ...state, endpoint, + endpointPathLabeledInURL: endpoint?.pathLabeled || '', url: '', } ); }, @@ -71,6 +85,7 @@ const reducer = createReducer( defaultState, { return ( { ...state, endpoint: false, + endpointPathLabeledInURL: '', url: '', } ); }, diff --git a/src/state/request/tests/reducer.test.js b/src/state/request/tests/reducer.test.js index f2a255f..e7050b5 100644 --- a/src/state/request/tests/reducer.test.js +++ b/src/state/request/tests/reducer.test.js @@ -15,6 +15,7 @@ import { const endpoint = { pathLabeled: '/$site/posts' }; const state = deepFreeze( { endpoint, + endpointPathLabeledInURL: '/$site/posts', method: 'GET', queryParams: { context: 'view' }, bodyParams: { a: 'b' }, @@ -36,6 +37,7 @@ it( 'should set the new method', () => { expect( reducer( state, action ) ).toEqual( { endpoint, + endpointPathLabeledInURL: '/$site/posts', method: 'POST', queryParams: { context: 'view' }, bodyParams: { a: 'b' }, @@ -53,6 +55,7 @@ it( 'should select a new endpoint and reset some params', () => { expect( reducer( state, action ) ).toEqual( { endpoint: newEndpoint, + endpointPathLabeledInURL: '/$site/comments', method: 'GET', queryParams: { context: 'view' }, bodyParams: { a: 'b' }, @@ -69,6 +72,7 @@ it( 'should set a new URL', () => { expect( reducer( state, action ) ).toEqual( { endpoint, + endpointPathLabeledInURL: '/$site/posts', method: 'GET', queryParams: { context: 'view' }, bodyParams: { a: 'b' }, @@ -85,6 +89,7 @@ it( 'should update path values', () => { expect( reducer( state, action ) ).toEqual( { endpoint, + endpointPathLabeledInURL: '/$site/posts', method: 'GET', queryParams: { context: 'view' }, bodyParams: { a: 'b' }, @@ -101,6 +106,7 @@ it( 'should set query param', () => { expect( reducer( state, action ) ).toEqual( { endpoint, + endpointPathLabeledInURL: '/$site/posts', method: 'GET', queryParams: { context: 'view', page: '2' }, bodyParams: { a: 'b' }, @@ -117,6 +123,7 @@ it( 'should set body param', () => { expect( reducer( state, action ) ).toEqual( { endpoint, + endpointPathLabeledInURL: '/$site/posts', method: 'GET', queryParams: { context: 'view' }, bodyParams: { a: 'b', title: 'my title' }, @@ -133,6 +140,7 @@ it( 'should reset the endpoint/url when switching versions', () => { expect( reducer( state, action ) ).toEqual( { endpoint: false, + endpointPathLabeledInURL: '', method: 'GET', queryParams: { context: 'view' }, bodyParams: { a: 'b' }, @@ -149,6 +157,7 @@ it( 'should reset the state when switching APIs', () => { expect( reducer( state, action ) ).toEqual( { endpoint: false, + endpointPathLabeledInURL: '', method: 'GET', queryParams: {}, bodyParams: {}, diff --git a/src/state/ui/reducer.js b/src/state/ui/reducer.js index c75032d..0974562 100644 --- a/src/state/ui/reducer.js +++ b/src/state/ui/reducer.js @@ -1,9 +1,12 @@ import { createReducer } from '../../lib/redux/create-reducer'; -import { UI_SELECT_API, UI_SELECT_VERSION } from '../actions'; +import { UI_SELECT_API, UI_SELECT_VERSION, SERIALIZE_URL, DESERIALIZE_URL } from '../actions'; import { getDefault } from '../../api'; import schema from './schema'; +import { serializeStateForUrl, deserializeStateFromUrl } from '../../lib/utils'; const reducer = createReducer( { api: getDefault().name, version: null }, { + [ SERIALIZE_URL ]: ( state ) => serializeStateForUrl( state, [ 'api', 'version' ] ), + [ DESERIALIZE_URL ]: ( state ) => deserializeStateFromUrl( state, [ 'api', 'version' ] ), [ UI_SELECT_API ]: ( state, { payload } ) => { return ( { version: null,