From 18b6fe05d459dbc89f22be49002ccd38080ba3e5 Mon Sep 17 00:00:00 2001 From: Jordan Lawrence Date: Thu, 29 Aug 2024 11:01:25 +0100 Subject: [PATCH 1/5] chore: support for sticky params in URL and intent ops --- packages/sanity/src/router/RouterProvider.tsx | 78 +++++++++++++++++-- packages/sanity/src/router/types.ts | 10 +++ .../src/structure/components/IntentButton.tsx | 8 +- .../src/structure/structureBuilder/Intent.ts | 4 + 4 files changed, 94 insertions(+), 6 deletions(-) diff --git a/packages/sanity/src/router/RouterProvider.tsx b/packages/sanity/src/router/RouterProvider.tsx index d0f8ada9523..c33e2ec76cb 100644 --- a/packages/sanity/src/router/RouterProvider.tsx +++ b/packages/sanity/src/router/RouterProvider.tsx @@ -1,3 +1,4 @@ +import {partition} from 'lodash' import {type ReactElement, type ReactNode, useCallback, useMemo} from 'react' import {RouterContext} from 'sanity/_singletons' @@ -94,10 +95,39 @@ export function RouterProvider(props: RouterProviderProps): ReactElement { ) const resolvePathFromState = useCallback( - (nextState: Record): string => { - return routerProp.encode(nextState) + (nextState: RouterState): string => { + const currentStateParams = state._searchParams || [] + const nextStateParams = nextState._searchParams || [] + const nextParams = STICKY.reduce((acc, param) => { + return replaceStickyParam( + acc, + param, + findParam(nextStateParams, param) ?? findParam(currentStateParams, param), + ) + }, nextStateParams || []) + + return routerProp.encode({ + ...nextState, + _searchParams: nextParams, + }) }, - [routerProp], + [routerProp, state], + ) + + const handleNavigateStickyParam = useCallback( + (param: string, value: string | undefined, options: NavigateOptions = {}) => { + if (!STICKY.includes(param)) { + throw new Error('Parameter is not sticky') + } + onNavigate({ + path: resolvePathFromState({ + ...state, + _searchParams: [[param, value || '']], + }), + replace: options.replace, + }) + }, + [onNavigate, resolvePathFromState, state], ) const navigate = useCallback( @@ -114,17 +144,55 @@ export function RouterProvider(props: RouterProviderProps): ReactElement { [onNavigate, resolveIntentLink], ) + const [routerState, stickyParams] = useMemo(() => { + if (!state._searchParams) { + return [state, null] + } + const {_searchParams, ...rest} = state + const [sticky, restParams] = partition(_searchParams, ([key]) => STICKY.includes(key)) + if (sticky.length === 0) { + return [state, null] + } + return [{...rest, _searchParams: restParams}, sticky] + }, [state]) + const router: RouterContextValue = useMemo( () => ({ navigate, navigateIntent, + navigateStickyParam: handleNavigateStickyParam, navigateUrl: onNavigate, resolveIntentLink, resolvePathFromState, - state, + state: routerState, + stickyParams: Object.fromEntries(stickyParams || []), }), - [navigate, navigateIntent, onNavigate, resolveIntentLink, resolvePathFromState, state], + [ + handleNavigateStickyParam, + navigate, + navigateIntent, + onNavigate, + resolveIntentLink, + resolvePathFromState, + routerState, + stickyParams, + ], ) return {props.children} } +const STICKY = ['perspective'] + +function replaceStickyParam( + current: SearchParam[], + param: string, + value: string | undefined, +): SearchParam[] { + const filtered = current.filter(([key]) => key !== param) + return value === undefined || value == '' ? filtered : [...filtered, [param, value]] +} + +function findParam(searchParams: SearchParam[], key: string): string | undefined { + const entry = searchParams.find(([k]) => k === key) + return entry ? entry[1] : undefined +} diff --git a/packages/sanity/src/router/types.ts b/packages/sanity/src/router/types.ts index 7b219ae811c..69ba8dd4d9f 100644 --- a/packages/sanity/src/router/types.ts +++ b/packages/sanity/src/router/types.ts @@ -264,6 +264,11 @@ export interface RouterContextValue { */ navigateUrl: (opts: {path: string; replace?: boolean}) => void + /** + * Navigates to the current URL with the sticky url search param set to the given value + */ + navigateStickyParam: (param: string, value: string, options?: NavigateOptions) => void + /** * Navigates to the given router state. * See {@link RouterState} and {@link NavigateOptions} @@ -280,4 +285,9 @@ export interface RouterContextValue { * The current router state. See {@link RouterState} */ state: RouterState + + /** + * The current router state. See {@link RouterState} + */ + stickyParams: Record } diff --git a/packages/sanity/src/structure/components/IntentButton.tsx b/packages/sanity/src/structure/components/IntentButton.tsx index fbba8eed29b..6a82570072e 100644 --- a/packages/sanity/src/structure/components/IntentButton.tsx +++ b/packages/sanity/src/structure/components/IntentButton.tsx @@ -23,7 +23,13 @@ export const IntentButton = forwardRef(function IntentButton( linkRef: ForwardedRef, ) { return ( - + ) }), [intent], diff --git a/packages/sanity/src/structure/structureBuilder/Intent.ts b/packages/sanity/src/structure/structureBuilder/Intent.ts index ad0810f6379..956da0c021f 100644 --- a/packages/sanity/src/structure/structureBuilder/Intent.ts +++ b/packages/sanity/src/structure/structureBuilder/Intent.ts @@ -1,3 +1,5 @@ +import {type SearchParam} from 'sanity/router' + import {getTypeNamesFromFilter, type PartialDocumentList} from './DocumentList' import {type StructureNode} from './StructureNodes' @@ -75,6 +77,8 @@ export interface Intent { /** Intent parameters. See {@link IntentParams} */ params?: IntentParams + + searchParams?: SearchParam[] } /** From 180c07a1c96cf983b462022a3d936719c047a831 Mon Sep 17 00:00:00 2001 From: Jordan Lawrence Date: Thu, 29 Aug 2024 11:01:35 +0100 Subject: [PATCH 2/5] chore: support for sticky params in URL and intent ops --- packages/sanity/src/router/RouterProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sanity/src/router/RouterProvider.tsx b/packages/sanity/src/router/RouterProvider.tsx index c33e2ec76cb..fb3364d376f 100644 --- a/packages/sanity/src/router/RouterProvider.tsx +++ b/packages/sanity/src/router/RouterProvider.tsx @@ -181,7 +181,7 @@ export function RouterProvider(props: RouterProviderProps): ReactElement { return {props.children} } -const STICKY = ['perspective'] +const STICKY: string[] = [] function replaceStickyParam( current: SearchParam[], From 0d487c41c4a22aa0a76805c6304099b01e3ba183 Mon Sep 17 00:00:00 2001 From: Ash Date: Thu, 29 Aug 2024 16:14:04 +0100 Subject: [PATCH 3/5] feat(sanity): preserve current sticky parameters when resolving intent links --- .../sanity/src/router/IntentLink.test.tsx | 65 +++++++++++++++++++ packages/sanity/src/router/RouterProvider.tsx | 9 ++- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/packages/sanity/src/router/IntentLink.test.tsx b/packages/sanity/src/router/IntentLink.test.tsx index a08cbd9b344..526d9e6a41d 100644 --- a/packages/sanity/src/router/IntentLink.test.tsx +++ b/packages/sanity/src/router/IntentLink.test.tsx @@ -1,5 +1,6 @@ import {describe, expect, it} from '@jest/globals' import {render} from '@testing-library/react' +import {noop} from 'lodash' import {IntentLink} from './IntentLink' import {route} from './route' @@ -30,4 +31,68 @@ describe('IntentLink', () => { '/test/intent/edit/id=document-id-123;type=document-type/?perspective=bundle.summer-drop', ) }) + + it('should preserve sticky parameters when resolving intent link', () => { + const router = route.create('/test', [route.intents('/intent')]) + const component = render( + , + { + wrapper: ({children}) => ( + + {children} + + ), + }, + ) + // Component should render the query param in the href + expect(component.container.querySelector('a')?.href).toContain( + '/test/intent/edit/id=document-id-123;type=document-type/?perspective=bundle.summer-drop', + ) + }) + + it('should allow sticky parameters to be overridden when resolving intent link', () => { + const router = route.create('/test', [route.intents('/intent')]) + const component = render( + , + { + wrapper: ({children}) => ( + + {children} + + ), + }, + ) + // Component should render the query param in the href + expect(component.container.querySelector('a')?.href).toContain( + '/test/intent/edit/id=document-id-123;type=document-type/?perspective=bundle.autumn-drop', + ) + expect(component.container.querySelector('a')?.href).not.toContain( + 'perspective=bundle.summer-drop', + ) + }) }) diff --git a/packages/sanity/src/router/RouterProvider.tsx b/packages/sanity/src/router/RouterProvider.tsx index fb3364d376f..78e4593ef8d 100644 --- a/packages/sanity/src/router/RouterProvider.tsx +++ b/packages/sanity/src/router/RouterProvider.tsx @@ -1,4 +1,4 @@ -import {partition} from 'lodash' +import {fromPairs, partition, toPairs} from 'lodash' import {type ReactElement, type ReactNode, useCallback, useMemo} from 'react' import {RouterContext} from 'sanity/_singletons' @@ -88,10 +88,13 @@ export function RouterProvider(props: RouterProviderProps): ReactElement { intent: intentName, params, payload, - _searchParams, + _searchParams: toPairs({ + ...fromPairs((state._searchParams ?? []).filter(([key]) => STICKY.includes(key))), + ...fromPairs(_searchParams ?? []), + }), }) }, - [routerProp], + [routerProp, state._searchParams], ) const resolvePathFromState = useCallback( From 432cf107f2572a7404443a226c13b5bb6bb1de97 Mon Sep 17 00:00:00 2001 From: Ash Date: Thu, 29 Aug 2024 16:15:18 +0100 Subject: [PATCH 4/5] chore(sanity): use `noop` from `lodash` in test --- packages/sanity/src/router/IntentLink.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sanity/src/router/IntentLink.test.tsx b/packages/sanity/src/router/IntentLink.test.tsx index 526d9e6a41d..91ba9340783 100644 --- a/packages/sanity/src/router/IntentLink.test.tsx +++ b/packages/sanity/src/router/IntentLink.test.tsx @@ -20,7 +20,7 @@ describe('IntentLink', () => { />, { wrapper: ({children}) => ( - null} router={router} state={{}}> + {children} ), From e89d3d04229db05289d9990ca037af827c8b027d Mon Sep 17 00:00:00 2001 From: Jordan Lawrence Date: Fri, 30 Aug 2024 15:17:56 +0100 Subject: [PATCH 5/5] tests(router): reworking sticky params to support mocked testing --- .../sanity/src/router/IntentLink.test.tsx | 22 +++++++++++-------- packages/sanity/src/router/RouterProvider.tsx | 10 ++++----- packages/sanity/src/router/stickyParams.ts | 1 + 3 files changed, 19 insertions(+), 14 deletions(-) create mode 100644 packages/sanity/src/router/stickyParams.ts diff --git a/packages/sanity/src/router/IntentLink.test.tsx b/packages/sanity/src/router/IntentLink.test.tsx index 91ba9340783..008ca2f0042 100644 --- a/packages/sanity/src/router/IntentLink.test.tsx +++ b/packages/sanity/src/router/IntentLink.test.tsx @@ -1,4 +1,4 @@ -import {describe, expect, it} from '@jest/globals' +import {describe, expect, it, jest} from '@jest/globals' import {render} from '@testing-library/react' import {noop} from 'lodash' @@ -6,6 +6,10 @@ import {IntentLink} from './IntentLink' import {route} from './route' import {RouterProvider} from './RouterProvider' +jest.mock('./stickyParams', () => ({ + STICKY_PARAMS: ['aTestStickyParam'], +})) + describe('IntentLink', () => { it('should resolve intent link with query params', () => { const router = route.create('/test', [route.intents('/intent')]) @@ -16,7 +20,7 @@ describe('IntentLink', () => { id: 'document-id-123', type: 'document-type', }} - searchParams={[['perspective', `bundle.summer-drop`]]} + searchParams={[['aTestStickyParam', `aStickyParam.value`]]} />, { wrapper: ({children}) => ( @@ -28,7 +32,7 @@ describe('IntentLink', () => { ) // Component should render the query param in the href expect(component.container.querySelector('a')?.href).toContain( - '/test/intent/edit/id=document-id-123;type=document-type/?perspective=bundle.summer-drop', + '/test/intent/edit/id=document-id-123;type=document-type/?aTestStickyParam=aStickyParam.value', ) }) @@ -48,7 +52,7 @@ describe('IntentLink', () => { onNavigate={noop} router={router} state={{ - _searchParams: [['perspective', 'bundle.summer-drop']], + _searchParams: [['aTestStickyParam', 'aStickyParam.value']], }} > {children} @@ -58,7 +62,7 @@ describe('IntentLink', () => { ) // Component should render the query param in the href expect(component.container.querySelector('a')?.href).toContain( - '/test/intent/edit/id=document-id-123;type=document-type/?perspective=bundle.summer-drop', + '/test/intent/edit/id=document-id-123;type=document-type/?aTestStickyParam=aStickyParam.value', ) }) @@ -71,7 +75,7 @@ describe('IntentLink', () => { id: 'document-id-123', type: 'document-type', }} - searchParams={[['perspective', `bundle.autumn-drop`]]} + searchParams={[['aTestStickyParam', `aStickyParam.value.to-be-defined`]]} />, { wrapper: ({children}) => ( @@ -79,7 +83,7 @@ describe('IntentLink', () => { onNavigate={noop} router={router} state={{ - _searchParams: [['perspective', 'bundle.summer-drop']], + _searchParams: [['aTestStickyParam', 'aStickyParam.value.to-be-overridden']], }} > {children} @@ -89,10 +93,10 @@ describe('IntentLink', () => { ) // Component should render the query param in the href expect(component.container.querySelector('a')?.href).toContain( - '/test/intent/edit/id=document-id-123;type=document-type/?perspective=bundle.autumn-drop', + '/test/intent/edit/id=document-id-123;type=document-type/?aTestStickyParam=aStickyParam.value.to-be-defined', ) expect(component.container.querySelector('a')?.href).not.toContain( - 'perspective=bundle.summer-drop', + 'aTestStickyParam=aStickyParam.value.to-be-overridden', ) }) }) diff --git a/packages/sanity/src/router/RouterProvider.tsx b/packages/sanity/src/router/RouterProvider.tsx index 78e4593ef8d..4b819bff1a4 100644 --- a/packages/sanity/src/router/RouterProvider.tsx +++ b/packages/sanity/src/router/RouterProvider.tsx @@ -2,6 +2,7 @@ import {fromPairs, partition, toPairs} from 'lodash' import {type ReactElement, type ReactNode, useCallback, useMemo} from 'react' import {RouterContext} from 'sanity/_singletons' +import {STICKY_PARAMS} from './stickyParams' import { type IntentParameters, type NavigateOptions, @@ -89,7 +90,7 @@ export function RouterProvider(props: RouterProviderProps): ReactElement { params, payload, _searchParams: toPairs({ - ...fromPairs((state._searchParams ?? []).filter(([key]) => STICKY.includes(key))), + ...fromPairs((state._searchParams ?? []).filter(([key]) => STICKY_PARAMS.includes(key))), ...fromPairs(_searchParams ?? []), }), }) @@ -101,7 +102,7 @@ export function RouterProvider(props: RouterProviderProps): ReactElement { (nextState: RouterState): string => { const currentStateParams = state._searchParams || [] const nextStateParams = nextState._searchParams || [] - const nextParams = STICKY.reduce((acc, param) => { + const nextParams = STICKY_PARAMS.reduce((acc, param) => { return replaceStickyParam( acc, param, @@ -119,7 +120,7 @@ export function RouterProvider(props: RouterProviderProps): ReactElement { const handleNavigateStickyParam = useCallback( (param: string, value: string | undefined, options: NavigateOptions = {}) => { - if (!STICKY.includes(param)) { + if (!STICKY_PARAMS.includes(param)) { throw new Error('Parameter is not sticky') } onNavigate({ @@ -152,7 +153,7 @@ export function RouterProvider(props: RouterProviderProps): ReactElement { return [state, null] } const {_searchParams, ...rest} = state - const [sticky, restParams] = partition(_searchParams, ([key]) => STICKY.includes(key)) + const [sticky, restParams] = partition(_searchParams, ([key]) => STICKY_PARAMS.includes(key)) if (sticky.length === 0) { return [state, null] } @@ -184,7 +185,6 @@ export function RouterProvider(props: RouterProviderProps): ReactElement { return {props.children} } -const STICKY: string[] = [] function replaceStickyParam( current: SearchParam[], diff --git a/packages/sanity/src/router/stickyParams.ts b/packages/sanity/src/router/stickyParams.ts new file mode 100644 index 00000000000..577e1874654 --- /dev/null +++ b/packages/sanity/src/router/stickyParams.ts @@ -0,0 +1 @@ +export const STICKY_PARAMS: string[] = []