From 835de32fd6ed7b00625f25aaca37a555fcdb4234 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 5 May 2023 13:39:52 +0800 Subject: [PATCH] zod-powered search params (#790) * zod-powered search params * fix ci * fix context menu * fix the *other* context menu --------- Co-authored-by: Utku Bakir <74243531+utkubakir@users.noreply.github.com> --- .../app/$libraryId/Explorer/ContextMenu.tsx | 2 +- .../$libraryId/Explorer/File/ContextMenu.tsx | 2 +- interface/app/$libraryId/Explorer/index.tsx | 2 +- interface/app/$libraryId/Explorer/util.ts | 14 ++----- .../app/$libraryId/Layout/Sidebar/index.tsx | 2 +- interface/app/$libraryId/TopBar/SearchBar.tsx | 31 +++++++++------ interface/app/$libraryId/location/$id.tsx | 4 +- interface/app/$libraryId/search.tsx | 17 +++----- .../settings/library/locations/index.tsx | 2 +- interface/hooks/index.ts | 1 + ...odRouteParams.tsx => useZodRouteParams.ts} | 2 +- interface/hooks/useZodSearchParams.ts | 39 +++++++++++++++++++ 12 files changed, 77 insertions(+), 41 deletions(-) rename interface/hooks/{useZodRouteParams.tsx => useZodRouteParams.ts} (73%) create mode 100644 interface/hooks/useZodSearchParams.ts diff --git a/interface/app/$libraryId/Explorer/ContextMenu.tsx b/interface/app/$libraryId/Explorer/ContextMenu.tsx index 9f170f4db..88c800a94 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu.tsx @@ -38,7 +38,7 @@ export const OpenInNativeExplorer = () => { export default (props: PropsWithChildren) => { const store = useExplorerStore(); - const params = useExplorerSearchParams(); + const [params] = useExplorerSearchParams(); const generateThumbsForLocation = useLibraryMutation('jobs.generateThumbsForLocation'); const objectValidator = useLibraryMutation('jobs.objectValidator'); diff --git a/interface/app/$libraryId/Explorer/File/ContextMenu.tsx b/interface/app/$libraryId/Explorer/File/ContextMenu.tsx index 04a951b8b..20ed77bd8 100644 --- a/interface/app/$libraryId/Explorer/File/ContextMenu.tsx +++ b/interface/app/$libraryId/Explorer/File/ContextMenu.tsx @@ -41,7 +41,7 @@ interface Props extends PropsWithChildren { export default ({ data, className, ...props }: Props) => { const store = useExplorerStore(); - const params = useExplorerSearchParams(); + const [params] = useExplorerSearchParams(); const objectData = data ? (isObject(data) ? data.item : data.item.object) : null; const keyManagerUnlocked = useLibraryQuery(['keys.isUnlocked']).data ?? false; diff --git a/interface/app/$libraryId/Explorer/index.tsx b/interface/app/$libraryId/Explorer/index.tsx index bd25b22fd..14c33820c 100644 --- a/interface/app/$libraryId/Explorer/index.tsx +++ b/interface/app/$libraryId/Explorer/index.tsx @@ -20,7 +20,7 @@ interface Props { export default function Explorer(props: Props) { const { selectedRowIndex, ...expStore } = useExplorerStore(); - const { path } = useExplorerSearchParams(); + const [{ path }] = useExplorerSearchParams(); useLibrarySubscription(['jobs.newThumbnail'], { onData: (cas_id) => { diff --git a/interface/app/$libraryId/Explorer/util.ts b/interface/app/$libraryId/Explorer/util.ts index 0870ef2eb..bfad83886 100644 --- a/interface/app/$libraryId/Explorer/util.ts +++ b/interface/app/$libraryId/Explorer/util.ts @@ -1,8 +1,6 @@ -import { useMemo } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { getParams } from 'remix-params-helper'; import { z } from 'zod'; import { ExplorerItem, ObjectKind, ObjectKindKey, isObject, isPath } from '@sd/client'; +import { useZodSearchParams } from '~/hooks'; export function getExplorerItemData(data: ExplorerItem) { const objectData = getItemObject(data); @@ -25,17 +23,11 @@ export function getItemFilePath(data: ExplorerItem) { return isObject(data) ? data.item.file_paths[0] : data.item; } -const SEARCH_PARAMS = z.object({ +export const SEARCH_PARAMS = z.object({ path: z.string().default(''), limit: z.coerce.number().default(100) }); export function useExplorerSearchParams() { - const [searchParams] = useSearchParams(); - - const result = useMemo(() => getParams(searchParams, SEARCH_PARAMS), [searchParams]); - - if (!result.success) throw result.errors; - - return result.data; + return useZodSearchParams(SEARCH_PARAMS); } diff --git a/interface/app/$libraryId/Layout/Sidebar/index.tsx b/interface/app/$libraryId/Layout/Sidebar/index.tsx index c525dd4ba..4a430f771 100644 --- a/interface/app/$libraryId/Layout/Sidebar/index.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/index.tsx @@ -20,7 +20,7 @@ export default () => { > {showControls && } {(os !== 'browser' || showControls) && ( -
+
)} diff --git a/interface/app/$libraryId/TopBar/SearchBar.tsx b/interface/app/$libraryId/TopBar/SearchBar.tsx index ffdaf2e15..de1c309f7 100644 --- a/interface/app/$libraryId/TopBar/SearchBar.tsx +++ b/interface/app/$libraryId/TopBar/SearchBar.tsx @@ -1,19 +1,25 @@ import clsx from 'clsx'; -import { useEffect, useRef, useState, useTransition } from 'react'; +import { useCallback, useRef, useState, useTransition } from 'react'; import { useLocation, useNavigate, useResolvedPath } from 'react-router'; -import { createSearchParams, useSearchParams } from 'react-router-dom'; +import { createSearchParams } from 'react-router-dom'; import { useKey, useKeys } from 'rooks'; import { useDebouncedCallback } from 'use-debounce'; +import { z } from 'zod'; import { Input, Shortcut } from '@sd/ui'; +import { useZodSearchParams } from '~/hooks'; import { useOperatingSystem } from '~/hooks/useOperatingSystem'; import { getSearchStore } from '~/hooks/useSearchStore'; export const SEARCH_PARAM_KEY = 'search'; +export const SEARCH_PARAMS = z.object({ + search: z.string().default('') +}); + export default () => { const searchRef = useRef(null); - const [searchParams, setSearchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useZodSearchParams(SEARCH_PARAMS); const navigate = useNavigate(); const location = useLocation(); @@ -27,20 +33,23 @@ export default () => { const searchPath = useResolvedPath('search'); - const [value, setValue] = useState(searchParams.get(SEARCH_PARAM_KEY) || ''); + const [value, setValue] = useState(searchParams.search); const updateParams = useDebouncedCallback((value: string) => { startTransition(() => - setSearchParams((p) => (p.set(SEARCH_PARAM_KEY, value), p), { + setSearchParams((p) => ({ ...p, search: value }), { replace: true }) ); }, 300); - useEffect(() => { - if (searchPath.pathname === location.pathname) updateParams(value); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [value]); + const updateValue = useCallback( + (value: string) => { + setValue(value); + if (searchPath.pathname === location.pathname) updateParams(value); + }, + [searchPath.pathname, location.pathname, updateParams] + ); useKeys([os === 'macOS' ? 'Meta' : 'Ctrl', 'f'], () => searchRef.current?.focus()); useKey('Escape', () => searchRef.current?.blur()); @@ -51,11 +60,11 @@ export default () => { placeholder="Search" className="w-52 transition-all duration-200 focus-within:w-60" size="sm" - onChange={(e) => setValue(e.target.value)} + onChange={(e) => updateValue(e.target.value)} onBlur={() => { getSearchStore().isFocused = false; if (value === '') { - setSearchParams((p) => (p.delete(SEARCH_PARAM_KEY), p), { replace: true }); + setSearchParams({}, { replace: true }); navigate(-1); } }} diff --git a/interface/app/$libraryId/location/$id.tsx b/interface/app/$libraryId/location/$id.tsx index 971be7d55..1651159c5 100644 --- a/interface/app/$libraryId/location/$id.tsx +++ b/interface/app/$libraryId/location/$id.tsx @@ -25,7 +25,7 @@ const PARAMS = z.object({ }); export const Component = () => { - const { path } = useExplorerSearchParams(); + const [{ path }] = useExplorerSearchParams(); const { id: location_id } = useZodRouteParams(PARAMS); // // we destructure this since `mutate` is a stable reference but the object it's in is not @@ -108,7 +108,7 @@ const useToolBarOptions = () => { const useItems = () => { const { id: location_id } = useZodRouteParams(PARAMS); - const { path, limit } = useExplorerSearchParams(); + const [{ path, limit }] = useExplorerSearchParams(); const ctx = useRspcLibraryContext(); const { library } = useLibraryContext(); diff --git a/interface/app/$libraryId/search.tsx b/interface/app/$libraryId/search.tsx index fa41fce3a..a8d3c9862 100644 --- a/interface/app/$libraryId/search.tsx +++ b/interface/app/$libraryId/search.tsx @@ -1,21 +1,21 @@ import { MagnifyingGlass } from 'phosphor-react'; import { Suspense, memo, useDeferredValue, useEffect, useMemo } from 'react'; -import { useSearchParams } from 'react-router-dom'; import { z } from 'zod'; import { useLibraryQuery } from '@sd/client'; +import { useZodSearchParams } from '~/hooks'; import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore'; import { useExplorerTopBarOptions } from '~/hooks/useExplorerTopBarOptions'; import Explorer from './Explorer'; import { getExplorerItemData } from './Explorer/util'; import TopBarChildren from './TopBar/TopBarChildren'; -const schema = z.object({ +const SEARCH_PARAMS = z.object({ search: z.string().optional(), - take: z.number().optional(), + take: z.coerce.number().optional(), order: z.union([z.object({ name: z.boolean() }), z.object({ name: z.boolean() })]).optional() }); -export type SearchArgs = z.infer; +export type SearchArgs = z.infer; const ExplorerStuff = memo((props: { args: SearchArgs }) => { const explorerStore = useExplorerStore(); @@ -65,14 +65,9 @@ const ExplorerStuff = memo((props: { args: SearchArgs }) => { }); export const Component = () => { - const [searchParams] = useSearchParams(); + const [searchParams] = useZodSearchParams(SEARCH_PARAMS); - const searchObj = useMemo( - () => schema.parse(Object.fromEntries([...searchParams])), - [searchParams] - ); - - const search = useDeferredValue(searchObj); + const search = useDeferredValue(searchParams); return ( diff --git a/interface/app/$libraryId/settings/library/locations/index.tsx b/interface/app/$libraryId/settings/library/locations/index.tsx index 97aa3aa56..0590d2062 100644 --- a/interface/app/$libraryId/settings/library/locations/index.tsx +++ b/interface/app/$libraryId/settings/library/locations/index.tsx @@ -17,7 +17,7 @@ export const Component = () => { locations.data?.filter((location) => location.name.toLowerCase().includes(search.toLowerCase()) ), - [search] + [search, locations.data] ); return ( diff --git a/interface/hooks/index.ts b/interface/hooks/index.ts index 350a55d74..765daee99 100644 --- a/interface/hooks/index.ts +++ b/interface/hooks/index.ts @@ -13,3 +13,4 @@ export * from './useScrolled'; export * from './useSearchStore'; export * from './useToasts'; export * from './useZodRouteParams'; +export * from './useZodSearchParams'; diff --git a/interface/hooks/useZodRouteParams.tsx b/interface/hooks/useZodRouteParams.ts similarity index 73% rename from interface/hooks/useZodRouteParams.tsx rename to interface/hooks/useZodRouteParams.ts index cccb505cc..3b4e2253c 100644 --- a/interface/hooks/useZodRouteParams.tsx +++ b/interface/hooks/useZodRouteParams.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { useParams } from 'react-router'; import { z } from 'zod'; -export function useZodRouteParams(schema: Z): z.infer { +export function useZodRouteParams>>(schema: Z) { // eslint-disable-next-line no-restricted-syntax const params = useParams(); diff --git a/interface/hooks/useZodSearchParams.ts b/interface/hooks/useZodSearchParams.ts new file mode 100644 index 000000000..2a317f67f --- /dev/null +++ b/interface/hooks/useZodSearchParams.ts @@ -0,0 +1,39 @@ +import { useCallback, useMemo } from 'react'; +import { NavigateOptions, useSearchParams } from 'react-router-dom'; +import { getParams } from 'remix-params-helper'; +import { z } from 'zod'; + +export function useZodSearchParams>>(schema: Z) { + // eslint-disable-next-line no-restricted-syntax + const [searchParams, setSearchParams] = useSearchParams(); + + const typedSearchParams = useMemo( + () => getParams(searchParams, schema), + [searchParams, schema] + ); + + if (!typedSearchParams.success) throw typedSearchParams.errors; + + return [ + typedSearchParams.data, + useCallback( + ( + data: z.input | ((data: z.input) => z.infer), + navigateOpts?: NavigateOptions + ) => { + if (typeof data === 'function') { + setSearchParams((params) => { + const typedPrevParams = getParams(params, schema); + + if (!typedPrevParams.success) throw typedPrevParams.errors; + + return data(typedPrevParams.data); + }, navigateOpts); + } else { + setSearchParams(data as any, navigateOpts); + } + }, + [setSearchParams, schema] + ) + ] as const; +}