diff --git a/superglue/lib/actions.ts b/superglue/lib/actions.ts index 1378742d..31ba2b99 100644 --- a/superglue/lib/actions.ts +++ b/superglue/lib/actions.ts @@ -87,6 +87,13 @@ export const copyPage = createAction<{ from: PageKey; to: PageKey }>( '@@superglue/COPY_PAGE' ) +/** + * A redux action you can dispatch to move a page from one pageKey to another. + */ +export const movePage = createAction<{ from: PageKey; to: PageKey }>( + '@@superglue/MOVE_PAGE' +) + /** * A redux action you can dispatch to remove a page from your store. * diff --git a/superglue/lib/components/Navigation.tsx b/superglue/lib/components/Navigation.tsx index 138f875c..ce084126 100644 --- a/superglue/lib/components/Navigation.tsx +++ b/superglue/lib/components/Navigation.tsx @@ -6,8 +6,8 @@ import React, { useImperativeHandle, ForwardedRef, } from 'react' -import { urlToPageKey, pathWithoutBZParams } from '../utils' -import { removePage, setActivePage } from '../actions' +import { urlToPageKey, pathWithoutBZParams, mergeQuery } from '../utils' +import { removePage, setActivePage, movePage, copyPage } from '../actions' import { HistoryState, RootState, @@ -153,29 +153,31 @@ const NavigationProvider = forwardRef(function NavigationProvider( } } } + const navigateTo: NavigateTo = (path, { action, search } = {}) => { + action ||= 'push' + search ||= {} - const navigateTo: NavigateTo = ( - path, - { action } = { - action: 'push', - } - ) => { if (action === 'none') { return false } - path = pathWithoutBZParams(path) - const nextPageKey = urlToPageKey(path) - const hasPage = Object.prototype.hasOwnProperty.call( - store.getState().pages, - nextPageKey - ) + let nextPath = pathWithoutBZParams(path) + const originalPageKey = urlToPageKey(nextPath) + let nextPageKey = urlToPageKey(originalPageKey) + // store is untyped? + const page = store.getState().pages[nextPageKey] + + if (page) { + const isOptimisticNav = Object.keys(search).length > 0 + if (isOptimisticNav) { + nextPageKey = mergeQuery(nextPageKey, search) + nextPath = mergeQuery(nextPath, search) + } - if (hasPage) { const location = history.location const state = location.state as HistoryState const historyArgs = [ - path, + nextPath, { pageKey: nextPageKey, superglue: true, @@ -200,6 +202,10 @@ const NavigationProvider = forwardRef(function NavigationProvider( ) } + if (isOptimisticNav) { + dispatch(copyPage({ from: originalPageKey, to: nextPageKey })) + } + history.push(...historyArgs) dispatch(setActivePage({ pageKey: nextPageKey })) } @@ -207,7 +213,10 @@ const NavigationProvider = forwardRef(function NavigationProvider( if (action === 'replace') { history.replace(...historyArgs) - if (currentPageKey !== nextPageKey) { + if (isOptimisticNav) { + dispatch(movePage({ from: originalPageKey, to: nextPageKey })) + dispatch(setActivePage({ pageKey: nextPageKey })) + } else if (currentPageKey !== nextPageKey) { dispatch(setActivePage({ pageKey: nextPageKey })) dispatch(removePage({ pageKey: currentPageKey })) } diff --git a/superglue/lib/reducers/index.ts b/superglue/lib/reducers/index.ts index c17b666a..c7157b88 100644 --- a/superglue/lib/reducers/index.ts +++ b/superglue/lib/reducers/index.ts @@ -5,6 +5,7 @@ import { handleGraft, historyChange, copyPage, + movePage, setCSRFToken, setActivePage, removePage, @@ -171,6 +172,16 @@ export function pageReducer(state: AllPages = {}, action: Action): AllPages { return nextState } + if (movePage.match(action)) { + const nextState = { ...state } + const { from, to } = action.payload + + nextState[to] = nextState[from] + delete nextState[from] + + return nextState + } + if (handleGraft.match(action)) { const { pageKey, page } = action.payload diff --git a/superglue/lib/types/index.ts b/superglue/lib/types/index.ts index 56bf803e..6eca23a6 100644 --- a/superglue/lib/types/index.ts +++ b/superglue/lib/types/index.ts @@ -394,7 +394,8 @@ export interface BasicRequestInit extends RequestInit { export type NavigateTo = ( path: Keypath, options: { - action: NavigationAction + action?: NavigationAction + search?: Record } ) => boolean diff --git a/superglue/lib/utils/url.ts b/superglue/lib/utils/url.ts index 1a608784..cf100c41 100644 --- a/superglue/lib/utils/url.ts +++ b/superglue/lib/utils/url.ts @@ -101,3 +101,13 @@ export function parsePageKey(pageKey: PageKey) { search: query, } } + +export function mergeQuery(pageKey: PageKey, search: Record) { + const parsed = new parse(pageKey, {}, true) + + Object.keys(search).forEach((key) => { + parsed.query[key] = search[key] + }) + + return parsed.toString() +} diff --git a/superglue/spec/lib/NavComponent.spec.jsx b/superglue/spec/lib/NavComponent.spec.jsx index 3f391205..5b8a8e51 100644 --- a/superglue/spec/lib/NavComponent.spec.jsx +++ b/superglue/spec/lib/NavComponent.spec.jsx @@ -355,6 +355,118 @@ describe('Nav', () => { }) }) + it('navigates using "push" to a copied page with new params', () => { + const history = createMemoryHistory({}) + history.replace('/home', { + superglue: true, + pageKey: '/home', + posX: 5, + posY: 5, + }) + + vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + + const store = buildStore({ + pages: { + '/home': { + componentIdentifier: 'home', + restoreStrategy: 'fromCacheOnly', + }, + '/about': { + componentIdentifier: 'about', + restoreStrategy: 'fromCacheOnly', + }, + }, + superglue: { + csrfToken: 'abc', + currentPageKey: '/home', + }, + }) + + let instance + + render( + + (instance = node)} + mapping={{ home: Home, about: About }} + history={history} + /> + + ) + + instance.navigateTo('/home', { search: { hello: 'world' } }) + + const pages = store.getState().pages + expect(pages['/home?hello=world']).toMatchObject(pages['/home']) + expect(pages['/home?hello=world']).not.toBe(pages['/home']) + + expect(store.getState().superglue.currentPageKey).toEqual( + '/home?hello=world' + ) + expect(history.location.pathname).toEqual('/home') + expect(history.location.search).toEqual('?hello=world') + }) + + it('navigates using "replace" to a moved page with new params', () => { + const history = createMemoryHistory({}) + history.replace('/home', { + superglue: true, + pageKey: '/home', + posX: 5, + posY: 5, + }) + + vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + + const homeProps = { + componentIdentifier: 'home', + restoreStrategy: 'fromCacheOnly', + } + + const store = buildStore({ + pages: { + '/home': homeProps, + '/about': { + componentIdentifier: 'about', + restoreStrategy: 'fromCacheOnly', + }, + }, + superglue: { + csrfToken: 'abc', + currentPageKey: '/home', + }, + }) + + let instance + + render( + + (instance = node)} + mapping={{ home: Home, about: About }} + history={history} + /> + + ) + + instance.navigateTo('/home', { + action: 'replace', + search: { hello: 'world' }, + }) + + const pages = store.getState().pages + expect(pages['/home?hello=world']).toBe(homeProps) + + expect(store.getState().superglue.currentPageKey).toEqual( + '/home?hello=world' + ) + expect(history.location.pathname).toEqual('/home') + expect(history.location.search).toEqual('?hello=world') + }) + describe('history pop', () => { describe('when the previous page was set to "revisitOnly"', () => { it('revisits the page and scrolls when finished', async () => {