From 184d646edda22fba0df0b730ecd5c56476bb1442 Mon Sep 17 00:00:00 2001 From: Jordan Lawrence Date: Mon, 13 Jan 2025 20:50:33 +0000 Subject: [PATCH] feat: support for sticky params and intent operations --- .../sanity/src/router/IntentLink.test.tsx | 77 ++++++++++++- packages/sanity/src/router/RouterProvider.tsx | 102 ++++++++++++++++-- packages/sanity/src/router/stickyParams.ts | 1 + packages/sanity/src/router/types.ts | 13 +++ .../src/structure/components/IntentButton.tsx | 8 +- .../src/structure/structureBuilder/Intent.ts | 4 + 6 files changed, 193 insertions(+), 12 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 f8be89d1f55..e98759a31b4 100644 --- a/packages/sanity/src/router/IntentLink.test.tsx +++ b/packages/sanity/src/router/IntentLink.test.tsx @@ -1,10 +1,15 @@ import {render} from '@testing-library/react' -import {describe, expect, it} from 'vitest' +import {noop} from 'lodash' +import {describe, expect, it, vi} from 'vitest' import {IntentLink} from './IntentLink' import {route} from './route' import {RouterProvider} from './RouterProvider' +vi.mock('./stickyParams', () => ({ + STICKY_PARAMS: ['aTestStickyParam'], +})) + describe('IntentLink', () => { it('should resolve intent link with query params', () => { const router = route.create('/test', [route.intents('/intent')]) @@ -15,11 +20,41 @@ describe('IntentLink', () => { id: 'document-id-123', type: 'document-type', }} - searchParams={[['perspective', `bundle.summer-drop`]]} + searchParams={[['aTestStickyParam', `aStickyParam.value`]]} + />, + { + 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/?aTestStickyParam=aStickyParam.value', + ) + }) + + it('should preserve sticky parameters when resolving intent link', () => { + const router = route.create('/test', [route.intents('/intent')]) + const component = render( + , { wrapper: ({children}) => ( - null} router={router} state={{}}> + {children} ), @@ -27,7 +62,41 @@ 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', + ) + }) + + 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/?aTestStickyParam=aStickyParam.value.to-be-defined', + ) + expect(component.container.querySelector('a')?.href).not.toContain( + 'aTestStickyParam=aStickyParam.value.to-be-overridden', ) }) }) diff --git a/packages/sanity/src/router/RouterProvider.tsx b/packages/sanity/src/router/RouterProvider.tsx index d8da0e0eed0..f988e5d3045 100644 --- a/packages/sanity/src/router/RouterProvider.tsx +++ b/packages/sanity/src/router/RouterProvider.tsx @@ -1,6 +1,8 @@ +import {fromPairs, partition, toPairs} from 'lodash' import {type ReactNode, useCallback, useMemo} from 'react' import {RouterContext} from 'sanity/_singletons' +import {STICKY_PARAMS} from './stickyParams' import { type IntentParameters, type NavigateOptions, @@ -87,17 +89,64 @@ export function RouterProvider(props: RouterProviderProps): React.JSX.Element { intent: intentName, params, payload, - _searchParams, + _searchParams: toPairs({ + ...fromPairs((state._searchParams ?? []).filter(([key]) => STICKY_PARAMS.includes(key))), + ...fromPairs(_searchParams ?? []), + }), }) }, - [routerProp], + [routerProp, state._searchParams], ) const resolvePathFromState = useCallback( - (nextState: Record): string => { - return routerProp.encode(nextState) + (nextState: RouterState): string => { + const currentStateParams = state._searchParams || [] + const nextStateParams = nextState._searchParams || [] + const nextParams = STICKY_PARAMS.reduce((acc, param) => { + return replaceStickyParam( + acc, + param, + findParam(nextStateParams, param) ?? findParam(currentStateParams, param), + ) + }, nextStateParams || []) + + return routerProp.encode({ + ...nextState, + _searchParams: nextParams, + }) + }, + [routerProp, state], + ) + + const handleNavigateStickyParams = useCallback( + (params: Record, options: NavigateOptions = {}) => { + const hasInvalidParam = Object.keys(params).some((param) => !STICKY_PARAMS.includes(param)) + if (hasInvalidParam) { + throw new Error('One or more parameters are not sticky') + } + + const allNextSearchParams = [...(state._searchParams || []), ...Object.entries(params)] + + const searchParams = Object.entries( + allNextSearchParams.reduce( + (deduppedSearchParams, [key, value]) => ({ + ...deduppedSearchParams, + [key]: value, + }), + [] as unknown as SearchParam, + ), + ) + + // Trigger the navigation with updated _searchParams + onNavigate({ + path: resolvePathFromState({ + ...state, + _searchParams: searchParams, + }), + replace: options.replace, + }) }, - [routerProp], + [onNavigate, resolvePathFromState, state], ) const navigate = useCallback( @@ -114,17 +163,56 @@ export function RouterProvider(props: RouterProviderProps): React.JSX.Element { [onNavigate, resolveIntentLink], ) + const [routerState, stickyParams] = useMemo(() => { + if (!state._searchParams) { + return [state, null] + } + const {_searchParams, ...rest} = state + const [sticky, restParams] = partition(_searchParams, ([key]) => STICKY_PARAMS.includes(key)) + if (sticky.length === 0) { + return [state, null] + } + return [{...rest, _searchParams: restParams}, sticky] + }, [state]) + + const stickyParamsByName = useMemo(() => Object.fromEntries(stickyParams || []), [stickyParams]) + const router: RouterContextValue = useMemo( () => ({ navigate, navigateIntent, + navigateStickyParams: handleNavigateStickyParams, navigateUrl: onNavigate, resolveIntentLink, resolvePathFromState, - state, + state: routerState, + stickyParams: stickyParamsByName, }), - [navigate, navigateIntent, onNavigate, resolveIntentLink, resolvePathFromState, state], + [ + handleNavigateStickyParams, + navigate, + navigateIntent, + onNavigate, + resolveIntentLink, + resolvePathFromState, + routerState, + stickyParamsByName, + ], ) return {props.children} } + +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/stickyParams.ts b/packages/sanity/src/router/stickyParams.ts new file mode 100644 index 00000000000..e823190279a --- /dev/null +++ b/packages/sanity/src/router/stickyParams.ts @@ -0,0 +1 @@ +export const STICKY_PARAMS: string[] = ['perspective', 'excludedPerspectives'] diff --git a/packages/sanity/src/router/types.ts b/packages/sanity/src/router/types.ts index 7b219ae811c..b142f50b921 100644 --- a/packages/sanity/src/router/types.ts +++ b/packages/sanity/src/router/types.ts @@ -264,6 +264,14 @@ 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 values + */ + navigateStickyParams: ( + params: Record, + options?: NavigateOptions, + ) => void + /** * Navigates to the given router state. * See {@link RouterState} and {@link NavigateOptions} @@ -280,4 +288,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[] } /**