-
Notifications
You must be signed in to change notification settings - Fork 20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Sync state with URL (Share function) #117
base: master
Are you sure you want to change the base?
Changes from all commits
a5368d0
c623a99
7849f5b
63bf68f
27bc17e
78a60c3
c74a191
96981d3
c98e1d4
0122bd0
6d8baf1
0305ff5
2784457
219ea62
8969ca1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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'; | ||
|
||
/** | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. opening up with it would probably be better here to use I couldn't find any official clarity on this, but if we want this to be a module-level docblock, maybe we should put it above the |
||
* 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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. would it make sense to convert the URL parameters into Redux actions instead of app state? if that were the case it would seem more reusable so listen for alternatively, we could also create a new action that sets all parameters while clearing out the old ones. this could be used in those history events and remove the need for the |
||
}; | ||
|
||
// 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 ); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. fetching happens automatically anyway doesn't it? |
||
return { isInitializing: true, endpointPathLabeledInURL }; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
} | ||
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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick, but if we embrace JSDoc syntax here then IDEs will use these comments to show in-place documentation when calling this function, and if we add type annotations in the |
||
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 ) ) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if we include the actions here directly in a |
||
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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would have expected to see a listener for |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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', | ||
}; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is this necessary? if we expect big URLs, then this seems like a rather minor optimization which brings a fairly big reduction in readability. if we don't expect big URLs, do we need to shorten them? |
||
|
||
// 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; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not important, but we can also create these directly with: