From 565c56e682577189f92565af8f9a3c0a3a70cfb6 Mon Sep 17 00:00:00 2001 From: Johny Ho Date: Sun, 13 Oct 2024 17:43:38 -0400 Subject: [PATCH] Convert Nav and ApplicationBase to functional components This PR is fairly long, but it converts the two components into its functional counterpart. With the `Nav` we converted to `Navigation`that exports a `NavigationProvider` component and a `NavigationContext`, the latter is handy if we wanted to access visit, remote, pageKey, and navigateTo to help users build a custom `` component or if they want to use the functions without passing it from parent to child. With `Application`, we removed `ApplicationBase`. In doing so, we've simplified the component to the point that if someone wanted fine grain control of building the redux `Provider` or `Navigation`, they can just refer to the source code and build their own using the same helper methods in `lib/index.ts`. We also updated the docs. --- docs/configuration.md | 27 +- mkdocs.yml | 2 +- superglue/.prettierrc | 4 +- superglue/lib/action_creators/index.ts | 7 + superglue/lib/actions.ts | 65 ++++ superglue/lib/components/Nav.tsx | 282 --------------- superglue/lib/components/Navigation.tsx | 236 ++++++++++++ superglue/lib/index.tsx | 336 +++++++----------- superglue/lib/reducers/index.ts | 3 +- superglue/lib/types/index.ts | 151 +++++++- superglue/spec/features/navigation.spec.jsx | 80 +++-- superglue/spec/lib/NavComponent.spec.jsx | 34 +- superglue/typedoc.json | 3 +- .../lib/install/templates/web/application.js | 23 +- .../lib/install/templates/web/store.js | 7 +- 15 files changed, 684 insertions(+), 576 deletions(-) delete mode 100644 superglue/lib/components/Nav.tsx create mode 100644 superglue/lib/components/Navigation.tsx diff --git a/docs/configuration.md b/docs/configuration.md index 697d4fe6..1a867e96 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -54,28 +54,14 @@ const pageIdentifierToPageComponent = { ## `application.js` -!!! hint - Normally you wouldn't need to configure this class as it'll be generated - for you. - -Your `Application` component inherits from Superglue's [ApplicationBase] -abstract class and is the entry point for your Superglue app. It overrides -the methods [buildStore], [visitAndRemote], and [mapping], to perform -setup of redux, UJS, and other functionality. - - -```js -import { ApplicationBase } from '@thoughtbot/superglue' - -export default class Application extends ApplicationBase { - ... -} -``` - +This is the entry point of your application and uses Superglue's [Application] +component. There's nothing to do here, but if you need finer control of +how redux is setup, you can build your own Application using the [source] as +inspiration.
- - [:octicons-arrow-right-24: See complete reference](reference/index.md#abstract-applicationbase) - for `ApplicationBase` + - [:octicons-arrow-right-24: See complete reference](reference/index.md#application) + for `Application` and source reference under [Defined in](reference/index.md#defined-in_13)
## `flash.js` @@ -112,7 +98,6 @@ def create end ``` -[ApplicationBase]: reference/index.md#abstract-applicationbase [buildStore]: reference/index.md#buildstore [visitAndRemote]: requests.md [mapping]: reference/index.md#mapping diff --git a/mkdocs.yml b/mkdocs.yml index 87405c46..27c3ac57 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -122,7 +122,7 @@ nav: - Rails Utilities: rails-utils.md - Page utilities: functions-passed.md - Hooks: reference/hooks.md - - Nav: reference/components.Nav.md + - Navigation: reference/components.Navigation.md - Superglue: reference/index.md - Types: reference/types.md - Requests: reference/types.requests.md diff --git a/superglue/.prettierrc b/superglue/.prettierrc index 2c2c282d..26ac540f 100644 --- a/superglue/.prettierrc +++ b/superglue/.prettierrc @@ -1,5 +1,7 @@ { "semi": false, "singleQuote": true, - "printWidth": 80 + "printWidth": 80, + "useTabs": false, + "tabWidth": 2 } diff --git a/superglue/lib/action_creators/index.ts b/superglue/lib/action_creators/index.ts index 2c95cc74..22041ea9 100644 --- a/superglue/lib/action_creators/index.ts +++ b/superglue/lib/action_creators/index.ts @@ -77,6 +77,13 @@ function getChangedFragments(page: Page) { return changedFragments } +/** + * Save and process a rendered view from PropsTemplate. This is the primitive + * function that `visit` and `remote` calls when it receives a page. + * + * If you render a page outside the normal request response cycle, e.g, + * websocket, you can use this function to save the payload. + */ export function saveAndProcessPage( pageKey: string, page: VisitResponse | GraftResponse diff --git a/superglue/lib/actions.ts b/superglue/lib/actions.ts index a269447c..e35488e2 100644 --- a/superglue/lib/actions.ts +++ b/superglue/lib/actions.ts @@ -47,23 +47,88 @@ export const updateFragments = createAction<{ changedFragments: Record }>('@@superglue/UPDATE_FRAGMENTS') +/** + * A redux action you can dispatch to copy a page from one pageKey to another. Its + * a very useful way to create optimistic updates with a URL change. For example: + * + * ``` + * import { copyPage, remote } from '@thoughtbot/superglue' + * + * dispatch(copyPage({ from: originalKey, to: targetKey})) + * + * ... make edits to target page and finally + * + * navigateTo(targetKey) + * ``` + */ export const copyPage = createAction<{ from: PageKey; to: PageKey }>( '@@superglue/COPY_PAGE' ) +/** + * A redux action you can dispatch to remove a page from your store. + * + * ``` + * import { removePage } from '@thoughtbot/superglue' + * + * dispatch(removePage({ pageKey: '/delete_me_please"})) + * ``` + */ export const removePage = createAction<{ pageKey: PageKey }>( '@@superglue/REMOVE_PAGE' ) +/** + * A redux action called before a `fetch` takes place. It will fire in `remote` + * and `visit`. You can hook into this event in your redux slices like this: + * + * ``` + * import { beforeFetch } from '@thoughtbot/superglue' + * + * export const exampleSlice = createSlice({ + * name: 'Example', + * initialState: {}, + * extraReducers: (builder) => { + * builder.addCase(beforeFetch, (state, action) => { + * ``` + */ export const beforeFetch = createAction<{ fetchArgs: FetchArgs }>( '@@superglue/BEFORE_FETCH' ) +/** + * A redux action called before a `visit` takes place. You can hook into this event + * in your redux slices like this: + * + * ``` + * import { beforeVisit } from '@thoughtbot/superglue' + * + * export const exampleSlice = createSlice({ + * name: 'Example', + * initialState: {}, + * extraReducers: (builder) => { + * builder.addCase(beforeVisit, (state, action) => { + * ``` + */ export const beforeVisit = createAction<{ currentPageKey: PageKey fetchArgs: FetchArgs }>('@@superglue/BEFORE_VISIT') +/** + * A redux action called before `remote` takes place. You can hook into this event + * in your redux slices like this: + * + * ``` + * import { beforeRemote } from '@thoughtbot/superglue' + * + * export const exampleSlice = createSlice({ + * name: 'Example', + * initialState: {}, + * extraReducers: (builder) => { + * builder.addCase(beforeRemote, (state, action) => { + * ``` + */ export const beforeRemote = createAction<{ currentPageKey: PageKey fetchArgs: FetchArgs diff --git a/superglue/lib/components/Nav.tsx b/superglue/lib/components/Nav.tsx deleted file mode 100644 index 7369a419..00000000 --- a/superglue/lib/components/Nav.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import React from 'react' -import { urlToPageKey, pathWithoutBZParams } from '../utils' -import { removePage, historyChange } from '../actions' -import { - HistoryState, - Keypath, - NavigationAction, - PageOwnProps, - Remote, - SuperglueStore, - Visit, -} from '../types' -import { ConnectedComponent } from 'react-redux' -import { History, Update } from 'history' - -interface Props { - store: SuperglueStore - history: History - mapping: Record> - visit: Visit - remote: Remote - initialPageKey: string -} - -interface State { - pageKey: string - ownProps: Record -} - -/** - * A Nav component for browsers. It handles changine the browser history, - * deciding which page component to render based on a passed mapping, and - * passes a `navigateTo` to all page components. - */ -class Nav extends React.Component { - private history: History - private hasWindow: boolean - private unsubscribeHistory: () => void - - /** - * @ignore - */ - constructor(props: Props) { - super(props) - const { history, initialPageKey } = this.props - this.history = history - this.navigateTo = this.navigateTo.bind(this) - this.scrollTo = this.scrollTo.bind(this) - this.onHistoryChange = this.onHistoryChange.bind(this) - this.state = { - pageKey: initialPageKey, - ownProps: {}, - } - this.hasWindow = typeof window !== 'undefined' - } - /** - * @ignore - */ - componentDidMount(): void { - this.unsubscribeHistory = this.history.listen(this.onHistoryChange) - } - - /** - * @ignore - */ - componentWillUnmount(): void { - this.unsubscribeHistory() - } - - /** - * Passed to every page component. Manually navigate using pages that exists - * in the store and restores scroll position. This is what {@link Visit} in - * your `application_visit.js` ultimately calls. - * - * If there is an existing page in your store `navigateTo` will restore the props, - * render the correct component, and return `true`. Otherwise, it will return - * `false`. This is useful if you want to restore an existing page before making a - * call to `visit` or `remote`. - * - * @param path - * @param options when `none`, immediately returns `false` - * @returns `true` if the navigation was a success, `false` if the page was not found in the - * store. - */ - navigateTo( - path: Keypath, - { - action, - ownProps, - }: { action: NavigationAction; ownProps: Record } = { - action: 'push', - ownProps: {}, - } - ): boolean { - if (action === 'none') { - return false - } - - path = pathWithoutBZParams(path) - const nextPageKey = urlToPageKey(path) - const { store } = this.props - const hasPage = !!store.getState().pages[nextPageKey] - - if (hasPage) { - const location = this.history.location - const state = location.state as HistoryState - const prevPageKey = state.pageKey - const historyArgs = [ - path, - { - pageKey: nextPageKey, - superglue: true, - posY: 0, - posX: 0, - }, - ] as const - - if (action === 'push') { - if (this.hasWindow) { - this.history.replace( - { - pathname: location.pathname, - search: location.search, - hash: location.hash, - }, - { - ...state, - posY: window.pageYOffset, - posX: window.pageXOffset, - } - ) - } - - this.history.push(...historyArgs) - } - - if (action === 'replace') { - this.history.replace(...historyArgs) - } - - this.setState({ pageKey: nextPageKey, ownProps }) - this.scrollTo(0, 0) - - if (action === 'replace' && prevPageKey && prevPageKey !== nextPageKey) { - store.dispatch(removePage({ pageKey: prevPageKey })) - } - - return true - } else { - console.warn( - `\`navigateTo\` was called , but could not find. - the pageKey in the store. This may happen when the wrong - content_location was set in your non-get controller action. - No navigation will take place` - ) - return false - } - } - - /** - * @ignore - */ - scrollTo(posX: number, posY: number): void { - this.hasWindow && window.scrollTo(posX, posY) - } - - /** - * @ignore - */ - onHistoryChange({ location, action }: Update): void { - const { store, visit } = this.props - const { pathname, search, hash } = location - const state = location.state as HistoryState - - if (state && 'superglue' in state) { - store.dispatch( - historyChange({ - pathname, - search, - hash, - }) - ) - - if (action !== 'POP') { - return - } - - const { pageKey, posX, posY } = state - const containsKey = !!store.getState().pages[pageKey] - - if (containsKey) { - const { restoreStrategy } = store.getState().pages[pageKey] - - switch (restoreStrategy) { - case 'fromCacheOnly': - this.setState({ pageKey }) - this.scrollTo(posX, posY) - break - case 'fromCacheAndRevisitInBackground': - this.setState({ pageKey }) - this.scrollTo(posX, posY) - visit(pageKey, { revisit: true }) - break - case 'revisitOnly': - default: - visit(pageKey, { revisit: true }).then((meta) => { - if (meta === undefined) { - console.warn( - `scoll restoration was skipped. Your visit's then funtion - should return the meta object it recieved if you want your - application to restore the page's previous scroll.` - ) - } - - if (!!meta && meta.suggestedAction === 'none') { - this.setState({ pageKey }) - this.scrollTo(posX, posY) - } - }) - } - } else { - visit(pageKey, { revisit: true }).then((meta) => { - if (meta === undefined) { - console.warn( - `scoll restoration was skipped. Your visit's then funtion - should return the meta object it recieved if you want your - application to restore the page's previous scroll.` - ) - } - - if (!!meta && meta.suggestedAction === 'none') { - this.setState({ pageKey }) - this.scrollTo(posX, posY) - } - }) - } - } - } - - /** - * @ignore - */ - notFound(identifier: string | undefined): never { - let reminder = '' - if (!identifier) { - reminder = - 'Did you forget to add `json.componentIdentifier` in your application.json.props layout?' - } - - const error = new Error( - `Superglue Nav component was looking for ${identifier} but could not find it in your mapping. ${reminder}` - ) - - throw error - } - - /** - * @ignore - */ - render(): JSX.Element { - const { store, visit, remote } = this.props - const { pageKey, ownProps } = this.state - const { componentIdentifier } = store.getState().pages[pageKey] - const Component = this.props.mapping[componentIdentifier] - - if (Component) { - return ( - - ) - } else { - this.notFound(componentIdentifier) - } - } -} - -export default Nav diff --git a/superglue/lib/components/Navigation.tsx b/superglue/lib/components/Navigation.tsx new file mode 100644 index 00000000..2a62fb62 --- /dev/null +++ b/superglue/lib/components/Navigation.tsx @@ -0,0 +1,236 @@ +import React, { + ReactNode, + createContext, + useEffect, + useState, + forwardRef, + useImperativeHandle, +} from 'react' +import { urlToPageKey, pathWithoutBZParams } from '../utils' +import { removePage, historyChange } from '../actions' +import { + HistoryState, + RootState, + NavigateTo, + NavigationContextProps, + NavigationProviderProps, +} from '../types' +import { ConnectedComponent } from 'react-redux' +import { History, Update } from 'history' +import { useDispatch, useSelector, useStore } from 'react-redux' + +const NavigationContext = createContext( + undefined +) + +const hasWindow = typeof window !== 'undefined' + +const scrollTo = (posX: number, posY: number): void => { + hasWindow && window.scrollTo(posX, posY) +} + +const notFound = (identifier: string | undefined): never => { + let reminder = '' + if (!identifier) { + reminder = + 'Did you forget to add `json.componentIdentifier` in your application.json.props layout?' + } + + const error = new Error( + `Superglue Nav component was looking for ${identifier} but could not find it in your mapping. ${reminder}` + ) + + throw error +} + +const NavigationProvider = forwardRef(function NavigationProvider( + { history, visit, remote, initialPageKey, mapping }: NavigationProviderProps, + ref +) { + const [activePage, setActivePage] = useState({ + pageKey: initialPageKey, + ownProps: {}, + }) + + const dispatch = useDispatch() + const pages = useSelector((state) => state.pages) + const store = useStore() + + useEffect(() => { + return history.listen(onHistoryChange) + }, []) + + useImperativeHandle( + ref, + () => { + return { + navigateTo, + } + }, + [] + ) + + const onHistoryChange = ({ location, action }: Update): void => { + const { pathname, search, hash } = location + const state = location.state as HistoryState + + if (state && 'superglue' in state) { + dispatch( + historyChange({ + pathname, + search, + hash, + }) + ) + + if (action !== 'POP') { + return + } + + const { pageKey, posX, posY } = state + const containsKey = !!pages[pageKey] + + if (containsKey) { + const { restoreStrategy } = pages[pageKey] + + switch (restoreStrategy) { + case 'fromCacheOnly': + setActivePage({ pageKey, ownProps: {} }) + scrollTo(posX, posY) + break + case 'fromCacheAndRevisitInBackground': + setActivePage({ pageKey, ownProps: {} }) + scrollTo(posX, posY) + visit(pageKey, { revisit: true }) + break + case 'revisitOnly': + default: + visit(pageKey, { revisit: true }).then((meta) => { + if (meta === undefined) { + console.warn( + `scoll restoration was skipped. Your visit's then funtion + should return the meta object it recieved if you want your + application to restore the page's previous scroll.` + ) + } + + if (!!meta && meta.suggestedAction === 'none') { + setActivePage({ pageKey, ownProps: {} }) + scrollTo(posX, posY) + } + }) + } + } else { + visit(pageKey, { revisit: true }).then((meta) => { + if (meta === undefined) { + console.warn( + `scoll restoration was skipped. Your visit's then funtion + should return the meta object it recieved if you want your + application to restore the page's previous scroll.` + ) + } + + if (!!meta && meta.suggestedAction === 'none') { + setActivePage({ pageKey, ownProps: {} }) + scrollTo(posX, posY) + } + }) + } + } + } + + const navigateTo: NavigateTo = ( + path, + { action, ownProps } = { + action: 'push', + ownProps: {}, + } + ) => { + if (action === 'none') { + return false + } + + path = pathWithoutBZParams(path) + const nextPageKey = urlToPageKey(path) + const hasPage = store.getState().pages.hasOwnProperty(nextPageKey) + + if (hasPage) { + const location = history.location + const state = location.state as HistoryState + const prevPageKey = state.pageKey + const historyArgs = [ + path, + { + pageKey: nextPageKey, + superglue: true, + posY: 0, + posX: 0, + }, + ] as const + + if (action === 'push') { + if (hasWindow) { + history.replace( + { + pathname: location.pathname, + search: location.search, + hash: location.hash, + }, + { + ...state, + posY: window.pageYOffset, + posX: window.pageXOffset, + } + ) + } + + history.push(...historyArgs) + } + + if (action === 'replace') { + history.replace(...historyArgs) + } + + setActivePage({ pageKey: nextPageKey, ownProps }) + scrollTo(0, 0) + + if (action === 'replace' && prevPageKey && prevPageKey !== nextPageKey) { + dispatch(removePage({ pageKey: prevPageKey })) + } + + return true + } else { + console.warn( + `\`navigateTo\` was called , but could not find. + the pageKey in the store. This may happen when the wrong + content_location was set in your non-get controller action. + No navigation will take place` + ) + return false + } + } + + const { pageKey, ownProps } = activePage + const { componentIdentifier } = pages[pageKey] + const Component = mapping[componentIdentifier] + + if (Component) { + return ( + + + + ) + } else { + notFound(componentIdentifier) + } +}) + +export { NavigationContext, NavigationProvider } diff --git a/superglue/lib/index.tsx b/superglue/lib/index.tsx index 05e5d71a..fee2220a 100644 --- a/superglue/lib/index.tsx +++ b/superglue/lib/index.tsx @@ -1,26 +1,22 @@ -import React from 'react' +import React, { useEffect, forwardRef, useImperativeHandle } from 'react' import parse from 'url-parse' import { rootReducer } from './reducers' import { config } from './config' import { urlToPageKey, ujsHandlers, argsForHistory } from './utils' import { saveAndProcessPage } from './action_creators' import { historyChange, setCSRFToken } from './actions' -import { ConnectedComponent, Provider, connect } from 'react-redux' +import { Provider, connect } from 'react-redux' -import { - BrowserHistory, - History, - createBrowserHistory, - createMemoryHistory, -} from 'history' +import { History, createBrowserHistory, createMemoryHistory } from 'history' -import Nav from './components/Nav' +import { NavigationProvider } from './components/Navigation' +export { NavigationProvider, NavigationContext } from './components/Navigation' +export { saveAndProcessPage } from './action_creators' export { beforeFetch, beforeVisit, beforeRemote, - saveResponse, updateFragments, copyPage, removePage, @@ -28,24 +24,26 @@ export { GRAFTING_SUCCESS, } from './actions' +export { usePage, useSuperglue } from './hooks' + import { mapStateToProps, mapDispatchToProps } from './utils/react' import { - Remote, SuperglueStore, - Handlers, - Visit, VisitResponse, - PageOwnProps, - AllPages, Page, - JSONValue, + buildVisitAndRemote, + buildStore, + ConnectedMapping, + ApplicationProps, } from './types' -// export { superglueReducer, pageReducer, rootReducer } from './reducers' +export { superglueReducer, pageReducer, rootReducer } from './reducers' export { fragmentMiddleware } from './middleware' export { getIn } from './utils/immutability' export { urlToPageKey } export { usePage, useSuperglue } from './hooks' +const hasWindow = typeof window !== 'undefined' + function pageToInitialState(key: string, page: VisitResponse) { const slices = page.slices || {} const nextPage: Page = { @@ -60,7 +58,7 @@ function pageToInitialState(key: string, page: VisitResponse) { } } -function start({ +function populateStore({ initialPage, baseUrl = config.baseUrl, maxPages = config.maxPages, @@ -95,199 +93,139 @@ function start({ initialPageKey, } } -/** - * Props for the `ApplicationBase` component - */ -interface ApplicationProps { - /** - * The global var SUPERGLUE_INITIAL_PAGE_STATE is set by your erb - * template, e.g., index.html.erb - */ - initialPage: VisitResponse - /** - * The base url prefixed to all calls made by `visit` and - * `remote`. - */ - baseUrl: string - /** - * The path of the current page. It should equal to the `location.pathname` + - * `location.search` + `location.hash` - */ - path: string - /** - * The app element that was passed to React's `createRoot`. This will be used - * to setup UJS helpers. - */ - appEl: HTMLElement - /** - * A store to perform setup on. If none is provided, it will build a basic - * store. - */ - store?: SuperglueStore -} -type ConnectedMapping = Record< - string, - ConnectedComponent -> -/** - * The entry point to your superglue application. You should create a class - * (Application) that inherit from the ApplicationBase component and override - * the {@link buildStore}, {@link mapping}, and {@link visitAndRemote} methods. - * - * This would be setup for you when installing Superglue at `application.js`. - */ -export abstract class ApplicationBase extends React.Component { - private hasWindow: boolean - private navigatorRef: React.RefObject