From a5368d0afe6e69774070821dd48436daafa79e4a Mon Sep 17 00:00:00 2001 From: Matthew Reishus Date: Mon, 13 Nov 2023 14:44:45 -0600 Subject: [PATCH 01/15] Initial implementation --- src/lib/redux/cache.js | 19 ++++++-- src/lib/utils.js | 56 +++++++++++++++++++++- src/state/index.js | 3 +- src/state/serialize-to-url-middleware.js | 60 ++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 src/state/serialize-to-url-middleware.js diff --git a/src/lib/redux/cache.js b/src/lib/redux/cache.js index f1d0b55..c8c012e 100644 --- a/src/lib/redux/cache.js +++ b/src/lib/redux/cache.js @@ -1,4 +1,6 @@ import { SERIALIZE, DESERIALIZE } from './action-types'; +import { deserializeFullState } from '../../state/serialize-to-url-middleware'; +import { deepMerge } from '../utils'; const DAY_IN_HOURS = 24; const HOUR_IN_MS = 3600000; @@ -19,12 +21,23 @@ 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 ); + } + + // If possible, apply URL state 'over' + let urlParams = new URL( window.location.href ).searchParams; + console.log({ urlParams }); + let stateEnhancement = deserializeFullState( urlParams ); + if ( stateEnhancement ) { + state = deepMerge( state, stateEnhancement ); } - return deserialize( localStorageState, reducer ); + return state; } export function persistState( store, reducer ) { diff --git a/src/lib/utils.js b/src/lib/utils.js index a1d8389..082f7ac 100644 --- a/src/lib/utils.js +++ b/src/lib/utils.js @@ -17,8 +17,60 @@ 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 ); } + +// Filter and serialize part of the Redux state for URL encoding +export const serializeStateForUrl = ( state, keysToKeep ) => { + const filteredState = keysToKeep.reduce( ( obj, key ) => { + if ( state.hasOwnProperty( key ) ) { + obj[ key ] = state[ key ]; + } + return obj; + }, {} ); + + const jsonString = JSON.stringify( filteredState ); + const base64Encoded = btoa( jsonString ); + return base64Encoded; +}; + +// Deserialize the Base64 encoded string back to state object +export const deserializeStateFromUrl = ( base64String, keysToKeep ) => { + try { + const jsonString = atob( base64String ); + const parsedState = JSON.parse( jsonString ); + + // Validate the parsed state contains only the keys we're interested in + return keysToKeep.reduce( ( obj, key ) => { + if ( parsedState.hasOwnProperty( key ) ) { + obj[ key ] = parsedState[ key ]; + } + 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/index.js b/src/state/index.js index b95b768..4c2103b 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 './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/serialize-to-url-middleware.js b/src/state/serialize-to-url-middleware.js new file mode 100644 index 0000000..bdbf7fe --- /dev/null +++ b/src/state/serialize-to-url-middleware.js @@ -0,0 +1,60 @@ +// serializeMiddleware.js +import { serializeStateForUrl, deserializeStateFromUrl } from '../lib/utils'; + +export const serializeFullState = ( state ) => { + const serializedParts = { + ui: serializeStateForUrl( state.ui, [ 'api', 'version' ] ), + request: serializeStateForUrl( state.request, [ + 'url', + 'queryParams', + 'pathValues', + 'method', + 'bodyParams', + ] ), + }; + const urlParams = new URLSearchParams(); + for ( const [ key, value ] of Object.entries( serializedParts ) ) { + if ( value ) { + urlParams.set( key, value ); + } + } + return urlParams.toString(); +}; + +export const deserializeFullState = ( urlParams ) => { + const fullState = {}; + const deserializers = { + ui: ( x ) => deserializeStateFromUrl( x, [ 'api', 'version' ] ), + request: ( x ) => + deserializeStateFromUrl( x, [ 'url', 'queryParams', 'pathValues', 'method', 'bodyParams' ] ), + }; + for ( const [ key, deserializer ] of Object.entries( deserializers ) ) { + const serializedValue = urlParams.get( key ); + if ( serializedValue !== null ) { + fullState[ key ] = deserializer( serializedValue ); + } + } + return fullState; +}; + +/// /// + +const actionsThatUpdateUrl = [ 'REQUEST_TRIGGER' ]; + +export const serializeMiddleware = ( store ) => ( next ) => ( action ) => { + const result = next( action ); // Let the action pass through all middleware and reducers + + console.log( 'middleware check' ); + if ( actionsThatUpdateUrl.includes( action.type ) ) { + console.log( 'middleware done' ); + const state = store.getState(); + const serializedState = serializeFullState( state ); + const url = new URL( window.location ); + url.search = serializedState; + window.history.pushState( {}, '', url ); + } + + return result; +}; + +export default serializeMiddleware; From c623a99a1f674e00382346fa01698e1ea5f1565b Mon Sep 17 00:00:00 2001 From: Matthew Reishus Date: Mon, 13 Nov 2023 15:06:29 -0600 Subject: [PATCH 02/15] Try delegating serialization responsibility --- src/state/request/reducer.js | 3 +++ src/state/serialize-to-url-middleware.js | 18 ++++++------------ src/state/ui/reducer.js | 2 ++ 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/state/request/reducer.js b/src/state/request/reducer.js index a072d2b..fbe37f6 100644 --- a/src/state/request/reducer.js +++ b/src/state/request/reducer.js @@ -10,6 +10,7 @@ import { UI_SELECT_VERSION, } from '../actions'; import schema from './schema'; +import { serializeStateForUrl } from '../../lib/utils'; const defaultState = { method: 'GET', @@ -21,6 +22,8 @@ const defaultState = { }; const reducer = createReducer( defaultState, { + [ 'SERIALIZE_URL' ]: ( state ) => + serializeStateForUrl( state, [ 'url', 'queryParams', 'pathValues', 'method', 'bodyParams' ] ), [ REQUEST_SET_METHOD ]: ( state, { payload } ) => { return ( { ...state, diff --git a/src/state/serialize-to-url-middleware.js b/src/state/serialize-to-url-middleware.js index bdbf7fe..ff6ce61 100644 --- a/src/state/serialize-to-url-middleware.js +++ b/src/state/serialize-to-url-middleware.js @@ -1,20 +1,12 @@ // serializeMiddleware.js import { serializeStateForUrl, deserializeStateFromUrl } from '../lib/utils'; +import reducers from '../state/reducer'; export const serializeFullState = ( state ) => { - const serializedParts = { - ui: serializeStateForUrl( state.ui, [ 'api', 'version' ] ), - request: serializeStateForUrl( state.request, [ - 'url', - 'queryParams', - 'pathValues', - 'method', - 'bodyParams', - ] ), - }; + const serializedState = reducers( state, { type: 'SERIALIZE_URL' } ); const urlParams = new URLSearchParams(); - for ( const [ key, value ] of Object.entries( serializedParts ) ) { - if ( value ) { + for ( const [ key, value ] of Object.entries( serializedState ) ) { + if ( typeof value === 'string' && value ) { urlParams.set( key, value ); } } @@ -48,7 +40,9 @@ export const serializeMiddleware = ( store ) => ( next ) => ( action ) => { if ( actionsThatUpdateUrl.includes( action.type ) ) { console.log( 'middleware done' ); const state = store.getState(); + const serializedState = serializeFullState( state ); + const url = new URL( window.location ); url.search = serializedState; window.history.pushState( {}, '', url ); diff --git a/src/state/ui/reducer.js b/src/state/ui/reducer.js index c75032d..730f607 100644 --- a/src/state/ui/reducer.js +++ b/src/state/ui/reducer.js @@ -2,8 +2,10 @@ import { createReducer } from '../../lib/redux/create-reducer'; import { UI_SELECT_API, UI_SELECT_VERSION } from '../actions'; import { getDefault } from '../../api'; import schema from './schema'; +import { serializeStateForUrl } from '../../lib/utils'; const reducer = createReducer( { api: getDefault().name, version: null }, { + [ 'SERIALIZE_URL' ]: ( state ) => serializeStateForUrl( state, [ 'api', 'version' ] ), [ UI_SELECT_API ]: ( state, { payload } ) => { return ( { version: null, From 7849f5b3e32ce0ea43572f32e952a63f82f834ef Mon Sep 17 00:00:00 2001 From: Matthew Reishus Date: Mon, 13 Nov 2023 15:26:22 -0600 Subject: [PATCH 03/15] update --- src/lib/redux/cache.js | 1 - src/state/request/reducer.js | 4 +++- src/state/serialize-to-url-middleware.js | 26 ++++++++++-------------- src/state/ui/reducer.js | 3 ++- 4 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/lib/redux/cache.js b/src/lib/redux/cache.js index c8c012e..afac4af 100644 --- a/src/lib/redux/cache.js +++ b/src/lib/redux/cache.js @@ -31,7 +31,6 @@ export function loadInitialState( initialState, reducer ) { // If possible, apply URL state 'over' let urlParams = new URL( window.location.href ).searchParams; - console.log({ urlParams }); let stateEnhancement = deserializeFullState( urlParams ); if ( stateEnhancement ) { state = deepMerge( state, stateEnhancement ); diff --git a/src/state/request/reducer.js b/src/state/request/reducer.js index fbe37f6..3f0cb29 100644 --- a/src/state/request/reducer.js +++ b/src/state/request/reducer.js @@ -10,7 +10,7 @@ import { UI_SELECT_VERSION, } from '../actions'; import schema from './schema'; -import { serializeStateForUrl } from '../../lib/utils'; +import { serializeStateForUrl, deserializeStateFromUrl } from '../../lib/utils'; const defaultState = { method: 'GET', @@ -24,6 +24,8 @@ const defaultState = { const reducer = createReducer( defaultState, { [ 'SERIALIZE_URL' ]: ( state ) => serializeStateForUrl( state, [ 'url', 'queryParams', 'pathValues', 'method', 'bodyParams' ] ), + [ 'DESERIALIZE_URL' ]: ( state ) => + deserializeStateFromUrl( state, [ 'url', 'queryParams', 'pathValues', 'method', 'bodyParams' ] ), [ REQUEST_SET_METHOD ]: ( state, { payload } ) => { return ( { ...state, diff --git a/src/state/serialize-to-url-middleware.js b/src/state/serialize-to-url-middleware.js index ff6ce61..66b5b47 100644 --- a/src/state/serialize-to-url-middleware.js +++ b/src/state/serialize-to-url-middleware.js @@ -1,9 +1,9 @@ // serializeMiddleware.js -import { serializeStateForUrl, deserializeStateFromUrl } from '../lib/utils'; import reducers from '../state/reducer'; export const serializeFullState = ( 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 ) { @@ -14,23 +14,19 @@ export const serializeFullState = ( state ) => { }; export const deserializeFullState = ( urlParams ) => { - const fullState = {}; - const deserializers = { - ui: ( x ) => deserializeStateFromUrl( x, [ 'api', 'version' ] ), - request: ( x ) => - deserializeStateFromUrl( x, [ 'url', 'queryParams', 'pathValues', 'method', 'bodyParams' ] ), - }; - for ( const [ key, deserializer ] of Object.entries( deserializers ) ) { - const serializedValue = urlParams.get( key ); - if ( serializedValue !== null ) { - fullState[ key ] = deserializer( serializedValue ); - } + // Convert urlParams to a plain object + let paramsObject = {}; + for (let [key, value] of urlParams.entries()) { + paramsObject[key] = value; } - return fullState; -}; -/// /// + // Let each reducer handle its own state + const deserializedState = reducers( paramsObject, { type: 'DESERIALIZE_URL' } ); + console.log('deserializing', deserializedState); + return deserializedState; +}; +// On these actions, we compute the new URL and push it to the browser history const actionsThatUpdateUrl = [ 'REQUEST_TRIGGER' ]; export const serializeMiddleware = ( store ) => ( next ) => ( action ) => { diff --git a/src/state/ui/reducer.js b/src/state/ui/reducer.js index 730f607..0f9e6fc 100644 --- a/src/state/ui/reducer.js +++ b/src/state/ui/reducer.js @@ -2,10 +2,11 @@ import { createReducer } from '../../lib/redux/create-reducer'; import { UI_SELECT_API, UI_SELECT_VERSION } from '../actions'; import { getDefault } from '../../api'; import schema from './schema'; -import { serializeStateForUrl } from '../../lib/utils'; +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, From 63bf68fdda87fa7b950c43142ec2ae61bab08712 Mon Sep 17 00:00:00 2001 From: Matthew Reishus Date: Mon, 13 Nov 2023 15:30:15 -0600 Subject: [PATCH 04/15] Slight reorg --- src/lib/redux/cache.js | 2 +- src/{state => lib/redux}/serialize-to-url-middleware.js | 3 +-- src/state/index.js | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) rename src/{state => lib/redux}/serialize-to-url-middleware.js (95%) diff --git a/src/lib/redux/cache.js b/src/lib/redux/cache.js index afac4af..1207bb7 100644 --- a/src/lib/redux/cache.js +++ b/src/lib/redux/cache.js @@ -1,5 +1,5 @@ import { SERIALIZE, DESERIALIZE } from './action-types'; -import { deserializeFullState } from '../../state/serialize-to-url-middleware'; +import { deserializeFullState } from './serialize-to-url-middleware'; import { deepMerge } from '../utils'; const DAY_IN_HOURS = 24; diff --git a/src/state/serialize-to-url-middleware.js b/src/lib/redux/serialize-to-url-middleware.js similarity index 95% rename from src/state/serialize-to-url-middleware.js rename to src/lib/redux/serialize-to-url-middleware.js index 66b5b47..d703628 100644 --- a/src/state/serialize-to-url-middleware.js +++ b/src/lib/redux/serialize-to-url-middleware.js @@ -1,5 +1,4 @@ -// serializeMiddleware.js -import reducers from '../state/reducer'; +import reducers from '../../state/reducer'; export const serializeFullState = ( state ) => { const serializedState = reducers( state, { type: 'SERIALIZE_URL' } ); diff --git a/src/state/index.js b/src/state/index.js index 4c2103b..b79e0e0 100644 --- a/src/state/index.js +++ b/src/state/index.js @@ -4,7 +4,7 @@ import thunk from 'redux-thunk'; import reducer from './reducer'; import { boot } from './security/actions'; import { loadInitialState, persistState } from '../lib/redux/cache'; -import serializeToUrlMiddleware from './serialize-to-url-middleware'; +import serializeToUrlMiddleware from '../lib/redux/serialize-to-url-middleware'; const store = createStore( reducer, From 27bc17e62a4de75c1f2a8f22cea13cdb872177af Mon Sep 17 00:00:00 2001 From: Matthew Reishus Date: Mon, 13 Nov 2023 16:29:38 -0600 Subject: [PATCH 05/15] Got the madlib style state restore working --- src/lib/redux/cache.js | 4 +- src/lib/redux/serialize-to-url-middleware.js | 75 +++++++++++++++----- src/state/request/reducer.js | 6 +- 3 files changed, 64 insertions(+), 21 deletions(-) diff --git a/src/lib/redux/cache.js b/src/lib/redux/cache.js index 1207bb7..bbc9112 100644 --- a/src/lib/redux/cache.js +++ b/src/lib/redux/cache.js @@ -1,5 +1,5 @@ import { SERIALIZE, DESERIALIZE } from './action-types'; -import { deserializeFullState } from './serialize-to-url-middleware'; +import { deserializeURLParamsToStateEnhancement } from './serialize-to-url-middleware'; import { deepMerge } from '../utils'; const DAY_IN_HOURS = 24; @@ -31,7 +31,7 @@ export function loadInitialState( initialState, reducer ) { // If possible, apply URL state 'over' let urlParams = new URL( window.location.href ).searchParams; - let stateEnhancement = deserializeFullState( urlParams ); + let stateEnhancement = deserializeURLParamsToStateEnhancement( urlParams ); if ( stateEnhancement ) { state = deepMerge( state, stateEnhancement ); } diff --git a/src/lib/redux/serialize-to-url-middleware.js b/src/lib/redux/serialize-to-url-middleware.js index d703628..15f60a2 100644 --- a/src/lib/redux/serialize-to-url-middleware.js +++ b/src/lib/redux/serialize-to-url-middleware.js @@ -1,6 +1,13 @@ import reducers from '../../state/reducer'; +import { + REQUEST_TRIGGER, + API_ENDPOINTS_RECEIVE, + REQUEST_SELECT_ENDPOINT, +} from '../../state/actions'; +import { getEndpoints } from '../../state/endpoints/selectors'; +import { loadEndpoints } from '../../state/endpoints/actions'; -export const serializeFullState = ( state ) => { +export const serializeStateToURLString = ( state ) => { const serializedState = reducers( state, { type: 'SERIALIZE_URL' } ); const urlParams = new URLSearchParams(); @@ -12,38 +19,72 @@ export const serializeFullState = ( state ) => { return urlParams.toString(); }; -export const deserializeFullState = ( urlParams ) => { +export const deserializeURLParamsToStateEnhancement = ( urlParams ) => { // Convert urlParams to a plain object let paramsObject = {}; - for (let [key, value] of urlParams.entries()) { - paramsObject[key] = value; + for ( let [ key, value ] of urlParams.entries() ) { + paramsObject[ key ] = value; } // Let each reducer handle its own state const deserializedState = reducers( paramsObject, { type: 'DESERIALIZE_URL' } ); - console.log('deserializing', deserializedState); return deserializedState; }; // On these actions, we compute the new URL and push it to the browser history -const actionsThatUpdateUrl = [ 'REQUEST_TRIGGER' ]; +const actionsThatUpdateUrl = [ REQUEST_TRIGGER ]; -export const serializeMiddleware = ( store ) => ( next ) => ( action ) => { - const result = next( action ); // Let the action pass through all middleware and reducers +export const serializeMiddleware = ( store ) => { + // Outer section of middleware, runs once when the middleware is created. - console.log( 'middleware check' ); - if ( actionsThatUpdateUrl.includes( action.type ) ) { - console.log( 'middleware done' ); - const state = store.getState(); + // 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; + const stateEnhancement = deserializeURLParamsToStateEnhancement( urlParams ); - const serializedState = serializeFullState( state ); + let { + ui: { api: apiFromUrl, version: versionFromUrl }, + request: { endpointPathLabeledForURLSerialize }, + } = stateEnhancement; - const url = new URL( window.location ); - url.search = serializedState; - window.history.pushState( {}, '', url ); + // In the case that the outer url provides a endpointPathLabeledForURLSerialize, + // we need to 1.) Fetch the entire list of endpoints, then 2.) Select the endpoint. + // This is a workaround we do because state.request.endpoint is too large to + // store in the URL. + let isInitializingEndpoint = false; + if ( endpointPathLabeledForURLSerialize && apiFromUrl && versionFromUrl ) { + const { dispatch } = store; + loadEndpoints( apiFromUrl, versionFromUrl )( dispatch ); + isInitializingEndpoint = true; } - return result; + // The actual middleware that runs on every request. + return ( next ) => ( action ) => { + const result = next( action ); // Let the action pass through all middleware and reducers + + 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 ); + } + + if ( isInitializingEndpoint && action.type === API_ENDPOINTS_RECEIVE ) { + const state = store.getState(); + const endpoints = getEndpoints( state, state.ui.api, state.ui.version ); + const endpoint = endpoints.find( + ( { pathLabeled } ) => pathLabeled === endpointPathLabeledForURLSerialize + ); + if ( endpoint ) { + store.dispatch( { type: REQUEST_SELECT_ENDPOINT, payload: { endpoint } } ); + } + isInitializingEndpoint = false; + } + + return result; + }; }; export default serializeMiddleware; diff --git a/src/state/request/reducer.js b/src/state/request/reducer.js index 3f0cb29..06dd7f2 100644 --- a/src/state/request/reducer.js +++ b/src/state/request/reducer.js @@ -19,13 +19,14 @@ const defaultState = { url: '', queryParams: {}, bodyParams: {}, + endpointPathLabeledForURLSerialize: '', // 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' ] ), + serializeStateForUrl( state, [ 'url', 'queryParams', 'pathValues', 'method', 'bodyParams', 'endpointPathLabeledForURLSerialize' ] ), [ 'DESERIALIZE_URL' ]: ( state ) => - deserializeStateFromUrl( state, [ 'url', 'queryParams', 'pathValues', 'method', 'bodyParams' ] ), + deserializeStateFromUrl( state, [ 'url', 'queryParams', 'pathValues', 'method', 'bodyParams', 'endpointPathLabeledForURLSerialize' ] ), [ REQUEST_SET_METHOD ]: ( state, { payload } ) => { return ( { ...state, @@ -36,6 +37,7 @@ const reducer = createReducer( defaultState, { return ( { ...state, endpoint, + endpointPathLabeledForURLSerialize: endpoint?.pathLabeled || '', url: '', } ); }, From 78a60c383242948fc87cd674301b47c1e661657a Mon Sep 17 00:00:00 2001 From: Matthew Reishus Date: Mon, 13 Nov 2023 16:38:23 -0600 Subject: [PATCH 06/15] Add comments --- src/lib/redux/serialize-to-url-middleware.js | 21 ++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/lib/redux/serialize-to-url-middleware.js b/src/lib/redux/serialize-to-url-middleware.js index 15f60a2..dbfb40c 100644 --- a/src/lib/redux/serialize-to-url-middleware.js +++ b/src/lib/redux/serialize-to-url-middleware.js @@ -7,6 +7,19 @@ import { 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 REQUEST_TRIGGER actions. + * + * 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 as the results will + * be deep merged over the current state 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' } ); @@ -19,6 +32,7 @@ export const serializeStateToURLString = ( state ) => { return urlParams.toString(); }; +// Given URL Params, return a state enhancement object that can be used to enhance the state. export const deserializeURLParamsToStateEnhancement = ( urlParams ) => { // Convert urlParams to a plain object let paramsObject = {}; @@ -34,6 +48,8 @@ export const deserializeURLParamsToStateEnhancement = ( urlParams ) => { // On these actions, we compute the new URL and push it to the browser history const actionsThatUpdateUrl = [ REQUEST_TRIGGER ]; +// 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 serializeMiddleware = ( store ) => { // Outer section of middleware, runs once when the middleware is created. @@ -59,11 +75,11 @@ export const serializeMiddleware = ( store ) => { // The actual middleware that runs on every request. return ( next ) => ( action ) => { - const result = next( action ); // Let the action pass through all middleware and reducers + 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 ); @@ -71,6 +87,7 @@ export const serializeMiddleware = ( store ) => { window.history.pushState( {}, '', url ); } + // Choose the correct endpoint once per load. if ( isInitializingEndpoint && action.type === API_ENDPOINTS_RECEIVE ) { const state = store.getState(); const endpoints = getEndpoints( state, state.ui.api, state.ui.version ); From c74a19114fab8dd4da9a62605a412c338e216b00 Mon Sep 17 00:00:00 2001 From: Matthew Reishus Date: Mon, 13 Nov 2023 16:51:01 -0600 Subject: [PATCH 07/15] Fix madlib -> simple url case and update on more actions --- src/lib/redux/serialize-to-url-middleware.js | 20 +++++++++++++++----- src/state/request/reducer.js | 9 +++++++-- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/lib/redux/serialize-to-url-middleware.js b/src/lib/redux/serialize-to-url-middleware.js index dbfb40c..b74c2e2 100644 --- a/src/lib/redux/serialize-to-url-middleware.js +++ b/src/lib/redux/serialize-to-url-middleware.js @@ -1,8 +1,11 @@ import reducers from '../../state/reducer'; import { - REQUEST_TRIGGER, API_ENDPOINTS_RECEIVE, REQUEST_SELECT_ENDPOINT, + REQUEST_SET_METHOD, + REQUEST_TRIGGER, + UI_SELECT_API, + UI_SELECT_VERSION, } from '../../state/actions'; import { getEndpoints } from '../../state/endpoints/selectors'; import { loadEndpoints } from '../../state/endpoints/actions'; @@ -10,12 +13,13 @@ import { loadEndpoints } from '../../state/endpoints/actions'; /** * This lets us serialize the state to the URL. * - * Note: Serialization is only ran on REQUEST_TRIGGER actions. + * 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 as the results will - * be deep merged over the current state by cache.js. + * 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. * **/ @@ -46,7 +50,13 @@ export const deserializeURLParamsToStateEnhancement = ( urlParams ) => { }; // On these actions, we compute the new URL and push it to the browser history -const actionsThatUpdateUrl = [ REQUEST_TRIGGER ]; +const actionsThatUpdateUrl = [ + REQUEST_TRIGGER, + UI_SELECT_API, + UI_SELECT_VERSION, + REQUEST_SET_METHOD, + REQUEST_SELECT_ENDPOINT, +]; // 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. diff --git a/src/state/request/reducer.js b/src/state/request/reducer.js index 06dd7f2..696b0e6 100644 --- a/src/state/request/reducer.js +++ b/src/state/request/reducer.js @@ -25,8 +25,13 @@ const defaultState = { const reducer = createReducer( defaultState, { [ 'SERIALIZE_URL' ]: ( state ) => serializeStateForUrl( state, [ 'url', 'queryParams', 'pathValues', 'method', 'bodyParams', 'endpointPathLabeledForURLSerialize' ] ), - [ 'DESERIALIZE_URL' ]: ( state ) => - deserializeStateFromUrl( state, [ 'url', 'queryParams', 'pathValues', 'method', 'bodyParams', 'endpointPathLabeledForURLSerialize' ] ), + [ 'DESERIALIZE_URL' ]: ( state ) => { + let newState = deserializeStateFromUrl( state, [ 'url', 'queryParams', 'pathValues', 'method', 'bodyParams', 'endpointPathLabeledForURLSerialize' ] ); + if ( ! newState.endpointPathLabeledForURLSerialize ) { + newState.endpoint = false; + } + return newState; + }, [ REQUEST_SET_METHOD ]: ( state, { payload } ) => { return ( { ...state, From 96981d331f16af0e915feb73b8e6bf741c017d7f Mon Sep 17 00:00:00 2001 From: Matthew Reishus Date: Mon, 13 Nov 2023 16:58:28 -0600 Subject: [PATCH 08/15] Add better comment about deepMerge --- src/lib/redux/cache.js | 6 +++++- src/lib/redux/serialize-to-url-middleware.js | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/lib/redux/cache.js b/src/lib/redux/cache.js index bbc9112..f0a0004 100644 --- a/src/lib/redux/cache.js +++ b/src/lib/redux/cache.js @@ -29,7 +29,11 @@ export function loadInitialState( initialState, reducer ) { state = deserialize( localStorageState, reducer ); } - // If possible, apply URL state 'over' + // 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 = deserializeURLParamsToStateEnhancement( urlParams ); if ( stateEnhancement ) { diff --git a/src/lib/redux/serialize-to-url-middleware.js b/src/lib/redux/serialize-to-url-middleware.js index b74c2e2..485bf1f 100644 --- a/src/lib/redux/serialize-to-url-middleware.js +++ b/src/lib/redux/serialize-to-url-middleware.js @@ -37,7 +37,7 @@ export const serializeStateToURLString = ( state ) => { }; // Given URL Params, return a state enhancement object that can be used to enhance the state. -export const deserializeURLParamsToStateEnhancement = ( urlParams ) => { +export const urlParamsToStateObj = ( urlParams ) => { // Convert urlParams to a plain object let paramsObject = {}; for ( let [ key, value ] of urlParams.entries() ) { @@ -65,7 +65,7 @@ export const serializeMiddleware = ( 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; - const stateEnhancement = deserializeURLParamsToStateEnhancement( urlParams ); + const stateEnhancement = urlParamsToStateObj( urlParams ); let { ui: { api: apiFromUrl, version: versionFromUrl }, From c98e1d43dc0acf7c2acfa055281f82a45593905a Mon Sep 17 00:00:00 2001 From: Matthew Reishus Date: Mon, 13 Nov 2023 17:04:50 -0600 Subject: [PATCH 09/15] Move SERIALIZE_URL to constant --- src/lib/redux/cache.js | 4 ++-- src/lib/redux/serialize-to-url-middleware.js | 6 ++++-- src/state/actions.js | 3 +++ src/state/request/reducer.js | 6 ++++-- src/state/ui/reducer.js | 6 +++--- 5 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/lib/redux/cache.js b/src/lib/redux/cache.js index f0a0004..cc562eb 100644 --- a/src/lib/redux/cache.js +++ b/src/lib/redux/cache.js @@ -1,5 +1,5 @@ import { SERIALIZE, DESERIALIZE } from './action-types'; -import { deserializeURLParamsToStateEnhancement } from './serialize-to-url-middleware'; +import { urlParamsToStateObj } from './serialize-to-url-middleware'; import { deepMerge } from '../utils'; const DAY_IN_HOURS = 24; @@ -35,7 +35,7 @@ export function loadInitialState( initialState, reducer ) { // 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 = deserializeURLParamsToStateEnhancement( urlParams ); + let stateEnhancement = urlParamsToStateObj( urlParams ); if ( stateEnhancement ) { state = deepMerge( state, stateEnhancement ); } diff --git a/src/lib/redux/serialize-to-url-middleware.js b/src/lib/redux/serialize-to-url-middleware.js index 485bf1f..1567bcf 100644 --- a/src/lib/redux/serialize-to-url-middleware.js +++ b/src/lib/redux/serialize-to-url-middleware.js @@ -6,6 +6,8 @@ import { 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'; @@ -25,7 +27,7 @@ import { loadEndpoints } from '../../state/endpoints/actions'; // 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 serializedState = reducers( state, { type: SERIALIZE_URL } ); const urlParams = new URLSearchParams(); for ( const [ key, value ] of Object.entries( serializedState ) ) { @@ -45,7 +47,7 @@ export const urlParamsToStateObj = ( urlParams ) => { } // Let each reducer handle its own state - const deserializedState = reducers( paramsObject, { type: 'DESERIALIZE_URL' } ); + const deserializedState = reducers( paramsObject, { type: DESERIALIZE_URL } ); return deserializedState; }; 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/request/reducer.js b/src/state/request/reducer.js index 696b0e6..843e268 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, @@ -23,9 +25,9 @@ const defaultState = { }; const reducer = createReducer( defaultState, { - [ 'SERIALIZE_URL' ]: ( state ) => + [ SERIALIZE_URL ]: ( state ) => serializeStateForUrl( state, [ 'url', 'queryParams', 'pathValues', 'method', 'bodyParams', 'endpointPathLabeledForURLSerialize' ] ), - [ 'DESERIALIZE_URL' ]: ( state ) => { + [ DESERIALIZE_URL ]: ( state ) => { let newState = deserializeStateFromUrl( state, [ 'url', 'queryParams', 'pathValues', 'method', 'bodyParams', 'endpointPathLabeledForURLSerialize' ] ); if ( ! newState.endpointPathLabeledForURLSerialize ) { newState.endpoint = false; diff --git a/src/state/ui/reducer.js b/src/state/ui/reducer.js index 0f9e6fc..0974562 100644 --- a/src/state/ui/reducer.js +++ b/src/state/ui/reducer.js @@ -1,12 +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' ] ), + [ SERIALIZE_URL ]: ( state ) => serializeStateForUrl( state, [ 'api', 'version' ] ), + [ DESERIALIZE_URL ]: ( state ) => deserializeStateFromUrl( state, [ 'api', 'version' ] ), [ UI_SELECT_API ]: ( state, { payload } ) => { return ( { version: null, From 0122bd0f3d5d8f126d90491544323115bf1e1fd1 Mon Sep 17 00:00:00 2001 From: Matthew Reishus Date: Mon, 13 Nov 2023 17:12:04 -0600 Subject: [PATCH 10/15] Use lz-string --- package-lock.json | 9 +++++++++ package.json | 1 + src/lib/utils.js | 32 +++++++++++++++++++++++++------- 3 files changed, 35 insertions(+), 7 deletions(-) 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/utils.js b/src/lib/utils.js index 082f7ac..99732b3 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 ) { @@ -23,30 +24,47 @@ export function isPlainObject( value ) { ); } +// Use this to make the URL shorter +const keyMap = { + method: 'me', + endpoint: 'ep', + pathValues: 'pv', + url: 'u', + queryParams: 'qp', + bodyParams: 'bp', + endpointPathLabeledForURLSerialize: '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[ key ] = state[ key ]; + obj[ shortKey ] = state[ key ]; } return obj; }, {} ); const jsonString = JSON.stringify( filteredState ); - const base64Encoded = btoa( jsonString ); - return base64Encoded; + return compressToEncodedURIComponent( jsonString ); }; -// Deserialize the Base64 encoded string back to state object +// Deserialize the encoded string back to state object export const deserializeStateFromUrl = ( base64String, keysToKeep ) => { try { - const jsonString = atob( base64String ); + 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 ) => { - if ( parsedState.hasOwnProperty( key ) ) { - obj[ key ] = parsedState[ key ]; + const shortKey = keyMap[ key ] || key; + if ( parsedState.hasOwnProperty( shortKey ) ) { + obj[ key ] = parsedState[ shortKey ]; } return obj; }, {} ); From 6d8baf1eb26d0800bf77d1d1b1305fe2de86ffd3 Mon Sep 17 00:00:00 2001 From: Matthew Reishus Date: Mon, 13 Nov 2023 17:36:45 -0600 Subject: [PATCH 11/15] update tests --- src/state/request/reducer.js | 1 + src/state/request/tests/reducer.test.js | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/src/state/request/reducer.js b/src/state/request/reducer.js index 843e268..fdb1bfa 100644 --- a/src/state/request/reducer.js +++ b/src/state/request/reducer.js @@ -85,6 +85,7 @@ const reducer = createReducer( defaultState, { return ( { ...state, endpoint: false, + endpointPathLabeledForURLSerialize: '', url: '', } ); }, diff --git a/src/state/request/tests/reducer.test.js b/src/state/request/tests/reducer.test.js index f2a255f..0d7c193 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, + endpointPathLabeledForURLSerialize: '/$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, + endpointPathLabeledForURLSerialize: '/$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, + endpointPathLabeledForURLSerialize: '/$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, + endpointPathLabeledForURLSerialize: '/$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, + endpointPathLabeledForURLSerialize: '/$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, + endpointPathLabeledForURLSerialize: '/$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, + endpointPathLabeledForURLSerialize: '/$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, + endpointPathLabeledForURLSerialize: '', 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, + endpointPathLabeledForURLSerialize: '', method: 'GET', queryParams: {}, bodyParams: {}, From 0305ff5e8bff9108b82520c799ca773fe6e0bcfa Mon Sep 17 00:00:00 2001 From: Matthew Reishus Date: Mon, 13 Nov 2023 21:56:59 -0600 Subject: [PATCH 12/15] extract: initializeFromUrl --- src/lib/redux/serialize-to-url-middleware.js | 37 ++++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/lib/redux/serialize-to-url-middleware.js b/src/lib/redux/serialize-to-url-middleware.js index 1567bcf..2cd5290 100644 --- a/src/lib/redux/serialize-to-url-middleware.js +++ b/src/lib/redux/serialize-to-url-middleware.js @@ -60,32 +60,31 @@ const actionsThatUpdateUrl = [ REQUEST_SELECT_ENDPOINT, ]; -// 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 serializeMiddleware = ( store ) => { - // Outer section of middleware, runs once when the middleware is created. - - // 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; +// 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 ); - - let { + const { ui: { api: apiFromUrl, version: versionFromUrl }, request: { endpointPathLabeledForURLSerialize }, } = stateEnhancement; - - // In the case that the outer url provides a endpointPathLabeledForURLSerialize, - // we need to 1.) Fetch the entire list of endpoints, then 2.) Select the endpoint. - // This is a workaround we do because state.request.endpoint is too large to - // store in the URL. - let isInitializingEndpoint = false; if ( endpointPathLabeledForURLSerialize && 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 ); - isInitializingEndpoint = true; + return { isInitializing: true, endpointPathLabeledForURLSerialize }; } + 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 serializeMiddleware = ( 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, endpointPathLabeledForURLSerialize } = initializeFromUrl( store, urlParams ); - // The actual middleware that runs on every request. + // The actual middleware that runs on every action. return ( next ) => ( action ) => { const result = next( action ); @@ -100,7 +99,7 @@ export const serializeMiddleware = ( store ) => { } // Choose the correct endpoint once per load. - if ( isInitializingEndpoint && action.type === API_ENDPOINTS_RECEIVE ) { + if ( isInitializing && action.type === API_ENDPOINTS_RECEIVE ) { const state = store.getState(); const endpoints = getEndpoints( state, state.ui.api, state.ui.version ); const endpoint = endpoints.find( @@ -109,7 +108,7 @@ export const serializeMiddleware = ( store ) => { if ( endpoint ) { store.dispatch( { type: REQUEST_SELECT_ENDPOINT, payload: { endpoint } } ); } - isInitializingEndpoint = false; + isInitializing = false; } return result; From 2784457001077623088b43970ad1497c4e206d50 Mon Sep 17 00:00:00 2001 From: Matthew Reishus Date: Mon, 13 Nov 2023 21:59:57 -0600 Subject: [PATCH 13/15] endpointPathLabeledForURLSerialize: Use a slightly less worse variable name --- src/lib/redux/serialize-to-url-middleware.js | 10 +++++----- src/lib/utils.js | 2 +- src/state/request/reducer.js | 12 ++++++------ src/state/request/tests/reducer.test.js | 18 +++++++++--------- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/lib/redux/serialize-to-url-middleware.js b/src/lib/redux/serialize-to-url-middleware.js index 2cd5290..b3b4e4b 100644 --- a/src/lib/redux/serialize-to-url-middleware.js +++ b/src/lib/redux/serialize-to-url-middleware.js @@ -65,14 +65,14 @@ const initializeFromUrl = ( store, urlParams ) => { const stateEnhancement = urlParamsToStateObj( urlParams ); const { ui: { api: apiFromUrl, version: versionFromUrl }, - request: { endpointPathLabeledForURLSerialize }, + request: { endpointPathLabeledInURL }, } = stateEnhancement; - if ( endpointPathLabeledForURLSerialize && apiFromUrl && versionFromUrl ) { + 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, endpointPathLabeledForURLSerialize }; + return { isInitializing: true, endpointPathLabeledInURL }; } return { isInitializing: false }; }; @@ -82,7 +82,7 @@ const initializeFromUrl = ( store, urlParams ) => { export const serializeMiddleware = ( 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, endpointPathLabeledForURLSerialize } = initializeFromUrl( store, urlParams ); + let { isInitializing, endpointPathLabeledInURL } = initializeFromUrl( store, urlParams ); // The actual middleware that runs on every action. return ( next ) => ( action ) => { @@ -103,7 +103,7 @@ export const serializeMiddleware = ( store ) => { const state = store.getState(); const endpoints = getEndpoints( state, state.ui.api, state.ui.version ); const endpoint = endpoints.find( - ( { pathLabeled } ) => pathLabeled === endpointPathLabeledForURLSerialize + ( { pathLabeled } ) => pathLabeled === endpointPathLabeledInURL ); if ( endpoint ) { store.dispatch( { type: REQUEST_SELECT_ENDPOINT, payload: { endpoint } } ); diff --git a/src/lib/utils.js b/src/lib/utils.js index 99732b3..f6b2b97 100644 --- a/src/lib/utils.js +++ b/src/lib/utils.js @@ -32,7 +32,7 @@ const keyMap = { url: 'u', queryParams: 'qp', bodyParams: 'bp', - endpointPathLabeledForURLSerialize: 'epu', + endpointPathLabeledInURL: 'epu', version: 've', api: 'ap', }; diff --git a/src/state/request/reducer.js b/src/state/request/reducer.js index fdb1bfa..0f29b7a 100644 --- a/src/state/request/reducer.js +++ b/src/state/request/reducer.js @@ -21,15 +21,15 @@ const defaultState = { url: '', queryParams: {}, bodyParams: {}, - endpointPathLabeledForURLSerialize: '', // 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. + 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', 'endpointPathLabeledForURLSerialize' ] ), + serializeStateForUrl( state, [ 'url', 'queryParams', 'pathValues', 'method', 'bodyParams', 'endpointPathLabeledInURL' ] ), [ DESERIALIZE_URL ]: ( state ) => { - let newState = deserializeStateFromUrl( state, [ 'url', 'queryParams', 'pathValues', 'method', 'bodyParams', 'endpointPathLabeledForURLSerialize' ] ); - if ( ! newState.endpointPathLabeledForURLSerialize ) { + let newState = deserializeStateFromUrl( state, [ 'url', 'queryParams', 'pathValues', 'method', 'bodyParams', 'endpointPathLabeledInURL' ] ); + if ( ! newState.endpointPathLabeledInURL ) { newState.endpoint = false; } return newState; @@ -44,7 +44,7 @@ const reducer = createReducer( defaultState, { return ( { ...state, endpoint, - endpointPathLabeledForURLSerialize: endpoint?.pathLabeled || '', + endpointPathLabeledInURL: endpoint?.pathLabeled || '', url: '', } ); }, @@ -85,7 +85,7 @@ const reducer = createReducer( defaultState, { return ( { ...state, endpoint: false, - endpointPathLabeledForURLSerialize: '', + endpointPathLabeledInURL: '', url: '', } ); }, diff --git a/src/state/request/tests/reducer.test.js b/src/state/request/tests/reducer.test.js index 0d7c193..e7050b5 100644 --- a/src/state/request/tests/reducer.test.js +++ b/src/state/request/tests/reducer.test.js @@ -15,7 +15,7 @@ import { const endpoint = { pathLabeled: '/$site/posts' }; const state = deepFreeze( { endpoint, - endpointPathLabeledForURLSerialize: '/$site/posts', + endpointPathLabeledInURL: '/$site/posts', method: 'GET', queryParams: { context: 'view' }, bodyParams: { a: 'b' }, @@ -37,7 +37,7 @@ it( 'should set the new method', () => { expect( reducer( state, action ) ).toEqual( { endpoint, - endpointPathLabeledForURLSerialize: '/$site/posts', + endpointPathLabeledInURL: '/$site/posts', method: 'POST', queryParams: { context: 'view' }, bodyParams: { a: 'b' }, @@ -55,7 +55,7 @@ it( 'should select a new endpoint and reset some params', () => { expect( reducer( state, action ) ).toEqual( { endpoint: newEndpoint, - endpointPathLabeledForURLSerialize: '/$site/comments', + endpointPathLabeledInURL: '/$site/comments', method: 'GET', queryParams: { context: 'view' }, bodyParams: { a: 'b' }, @@ -72,7 +72,7 @@ it( 'should set a new URL', () => { expect( reducer( state, action ) ).toEqual( { endpoint, - endpointPathLabeledForURLSerialize: '/$site/posts', + endpointPathLabeledInURL: '/$site/posts', method: 'GET', queryParams: { context: 'view' }, bodyParams: { a: 'b' }, @@ -89,7 +89,7 @@ it( 'should update path values', () => { expect( reducer( state, action ) ).toEqual( { endpoint, - endpointPathLabeledForURLSerialize: '/$site/posts', + endpointPathLabeledInURL: '/$site/posts', method: 'GET', queryParams: { context: 'view' }, bodyParams: { a: 'b' }, @@ -106,7 +106,7 @@ it( 'should set query param', () => { expect( reducer( state, action ) ).toEqual( { endpoint, - endpointPathLabeledForURLSerialize: '/$site/posts', + endpointPathLabeledInURL: '/$site/posts', method: 'GET', queryParams: { context: 'view', page: '2' }, bodyParams: { a: 'b' }, @@ -123,7 +123,7 @@ it( 'should set body param', () => { expect( reducer( state, action ) ).toEqual( { endpoint, - endpointPathLabeledForURLSerialize: '/$site/posts', + endpointPathLabeledInURL: '/$site/posts', method: 'GET', queryParams: { context: 'view' }, bodyParams: { a: 'b', title: 'my title' }, @@ -140,7 +140,7 @@ it( 'should reset the endpoint/url when switching versions', () => { expect( reducer( state, action ) ).toEqual( { endpoint: false, - endpointPathLabeledForURLSerialize: '', + endpointPathLabeledInURL: '', method: 'GET', queryParams: { context: 'view' }, bodyParams: { a: 'b' }, @@ -157,7 +157,7 @@ it( 'should reset the state when switching APIs', () => { expect( reducer( state, action ) ).toEqual( { endpoint: false, - endpointPathLabeledForURLSerialize: '', + endpointPathLabeledInURL: '', method: 'GET', queryParams: {}, bodyParams: {}, From 219ea628c93c80c2a5c61def13c678aa5d09544e Mon Sep 17 00:00:00 2001 From: Matthew Reishus Date: Mon, 13 Nov 2023 22:03:50 -0600 Subject: [PATCH 14/15] extract selectCorrectEndpoint --- src/lib/redux/serialize-to-url-middleware.js | 21 ++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/lib/redux/serialize-to-url-middleware.js b/src/lib/redux/serialize-to-url-middleware.js index b3b4e4b..6275038 100644 --- a/src/lib/redux/serialize-to-url-middleware.js +++ b/src/lib/redux/serialize-to-url-middleware.js @@ -100,14 +100,7 @@ export const serializeMiddleware = ( store ) => { // Choose the correct endpoint once per load. if ( isInitializing && action.type === API_ENDPOINTS_RECEIVE ) { - 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 } } ); - } + selectCorrectEndpoint( store, endpointPathLabeledInURL ); isInitializing = false; } @@ -115,4 +108,16 @@ export const serializeMiddleware = ( store ) => { }; }; +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 serializeMiddleware; From 8969ca1688bf6cb1d654cc0b2fdeafbdf029b12d Mon Sep 17 00:00:00 2001 From: Matthew Reishus Date: Mon, 13 Nov 2023 22:06:53 -0600 Subject: [PATCH 15/15] standardize name --- src/lib/redux/serialize-to-url-middleware.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/redux/serialize-to-url-middleware.js b/src/lib/redux/serialize-to-url-middleware.js index 6275038..1cc687b 100644 --- a/src/lib/redux/serialize-to-url-middleware.js +++ b/src/lib/redux/serialize-to-url-middleware.js @@ -79,7 +79,7 @@ const initializeFromUrl = ( store, urlParams ) => { // 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 serializeMiddleware = ( store ) => { +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 ); @@ -120,4 +120,4 @@ const selectCorrectEndpoint = ( store, endpointPathLabeledInURL ) => { }; -export default serializeMiddleware; +export default serializeToUrlMiddleware;