From a4522d65a7314c4bb2cf28d56a9d9dd79643e963 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Sat, 18 Nov 2023 08:07:33 +1100 Subject: [PATCH] [ENG-1436] Store search state in search params (#1795) * source search query from search params * store filters in search params with round trip * the rest bc apparently i forgot some? * remove all references to overview * add /:libraryId redirect --- .../Explorer/Search/AppliedFilters.tsx | 10 +- .../$libraryId/Explorer/Search/Context.tsx | 108 ++++++++++---- .../app/$libraryId/Explorer/Search/index.tsx | 5 +- .../app/$libraryId/Explorer/Search/store.tsx | 10 +- .../app/$libraryId/Explorer/Search/util.tsx | 43 ------ .../$libraryId/Layout/Sidebar/Contents.tsx | 8 +- interface/app/$libraryId/Layout/index.tsx | 2 +- interface/app/$libraryId/TopBar/SearchBar.tsx | 110 ++++++-------- interface/app/$libraryId/TopBar/index.tsx | 7 +- interface/app/$libraryId/index.tsx | 7 + .../settings/node/libraries/CreateDialog.tsx | 2 +- interface/app/onboarding/Layout.tsx | 3 +- interface/app/onboarding/context.tsx | 2 +- interface/app/route-schemas.ts | 16 +-- interface/hooks/useKeybindEventHandler.ts | 6 +- interface/hooks/useShortcut.ts | 12 +- interface/hooks/useZodSearchParams.ts | 136 +++++++++++++++++- pnpm-lock.yaml | Bin 804480 -> 804478 bytes 18 files changed, 306 insertions(+), 181 deletions(-) diff --git a/interface/app/$libraryId/Explorer/Search/AppliedFilters.tsx b/interface/app/$libraryId/Explorer/Search/AppliedFilters.tsx index 99cc94754..4b1495f1f 100644 --- a/interface/app/$libraryId/Explorer/Search/AppliedFilters.tsx +++ b/interface/app/$libraryId/Explorer/Search/AppliedFilters.tsx @@ -31,7 +31,7 @@ const FiltersOverflowShade = tw.div`from-app-darkerBox/80 absolute w-10 bg-gradi export const AppliedOptions = () => { const searchState = useSearchStore(); - const { allFilterArgs } = useSearchContext(); + const searchCtx = useSearchContext(); const [scroll, setScroll] = useState(0); @@ -47,16 +47,16 @@ export const AppliedOptions = () => { className="no-scrollbar flex h-full items-center gap-2 overflow-y-auto" onScroll={handleScroll} > - {searchState.searchQuery && ( + {searchCtx.searchQuery && searchCtx.searchQuery !== '' && ( - {searchState.searchQuery} + {searchCtx.searchQuery} - (getSearchStore().searchQuery = null)} /> + searchCtx.setSearchQuery('')} /> )} - {allFilterArgs.map(({ arg, removalIndex }, index) => { + {searchCtx.allFilterArgs.map(({ arg, removalIndex }, index) => { const filter = filterRegistry.find((f) => f.extract(arg)); if (!filter) return; diff --git a/interface/app/$libraryId/Explorer/Search/Context.tsx b/interface/app/$libraryId/Explorer/Search/Context.tsx index 860512c5d..88fcf6fe0 100644 --- a/interface/app/$libraryId/Explorer/Search/Context.tsx +++ b/interface/app/$libraryId/Explorer/Search/Context.tsx @@ -1,13 +1,28 @@ -import { createContext, PropsWithChildren, useContext, useMemo } from 'react'; +import { + createContext, + PropsWithChildren, + useContext, + useEffect, + useLayoutEffect, + useMemo +} from 'react'; +import { z } from 'zod'; import { SearchFilterArgs } from '@sd/client'; +import { useZodSearchParams } from '~/hooks'; import { useTopBarContext } from '../../TopBar/Layout'; import { filterRegistry } from './Filters'; -import { argsToOptions, getKey, useSearchStore } from './store'; +import { argsToOptions, getKey, getSearchStore, updateFilterArgs, useSearchStore } from './store'; const Context = createContext | null>(null); +const SEARCH_PARAMS = z.object({ + search: z.string().optional(), + filters: z.string().optional() +}); + function useContextValue() { + const [searchParams, setSearchParams] = useZodSearchParams(SEARCH_PARAMS); const searchState = useSearchStore(); const { fixedArgs, setFixedArgs } = useTopBarContext(); @@ -19,13 +34,13 @@ function useContextValue() { const fixedArgsKeys = useMemo(() => { const keys = fixedArgsAsOptions ? new Set( - fixedArgsAsOptions?.map(({ arg, filter }) => { - return getKey({ + fixedArgsAsOptions?.map(({ arg, filter }) => + getKey({ type: filter.name, name: arg.name, value: arg.value - }); - }) + }) + ) ) : null; return keys; @@ -41,35 +56,74 @@ function useContextValue() { }) ); - for (const [index, arg] of searchState.filterArgs.entries()) { - const filter = filterRegistry.find((f) => f.extract(arg)); - if (!filter) continue; + if (searchParams.filters) { + const args: SearchFilterArgs[] = JSON.parse(searchParams.filters); - const fixedEquivalentIndex = fixedArgs.findIndex( - (a) => filter.extract(a) !== undefined - ); - if (fixedEquivalentIndex !== -1) { - const merged = filter.merge( - filter.extract(fixedArgs[fixedEquivalentIndex]!)! as any, - filter.extract(arg)! as any + for (const [index, arg] of args.entries()) { + const filter = filterRegistry.find((f) => f.extract(arg)); + if (!filter) continue; + + const fixedEquivalentIndex = fixedArgs.findIndex( + (a) => filter.extract(a) !== undefined ); + if (fixedEquivalentIndex !== -1) { + const merged = filter.merge( + filter.extract(fixedArgs[fixedEquivalentIndex]!)! as any, + filter.extract(arg)! as any + ); - value[fixedEquivalentIndex] = { - arg: filter.create(merged), - removalIndex: fixedEquivalentIndex - }; - } else { - value.push({ - arg, - removalIndex: index - }); + value[fixedEquivalentIndex] = { + arg: filter.create(merged), + removalIndex: fixedEquivalentIndex + }; + } else { + value.push({ + arg, + removalIndex: index + }); + } } } return value; - }, [fixedArgs, searchState.filterArgs]); + }, [fixedArgs, searchParams.filters]); - return { setFixedArgs, fixedArgs, fixedArgsKeys, allFilterArgs }; + useLayoutEffect(() => { + const filters = searchParams.filters; + if (!filters) return; + + updateFilterArgs(() => JSON.parse(filters)); + }, [searchParams.filters]); + + useEffect(() => { + if (!searchState.filterArgs) return; + + setSearchParams( + (p) => ({ + ...p, + filters: JSON.stringify(searchState.filterArgs) + }), + { replace: true } + ); + }, [searchState.filterArgs, setSearchParams]); + + return { + setFixedArgs, + fixedArgs, + fixedArgsKeys, + allFilterArgs, + searchQuery: searchParams.search, + setSearchQuery(value: string) { + setSearchParams((p) => ({ ...p, search: value })); + }, + clearSearchQuery() { + setSearchParams((p) => { + delete p.search; + return { ...p }; + }); + }, + isSearching: searchParams.search !== undefined + }; } export const SearchContextProvider = ({ children }: PropsWithChildren) => { diff --git a/interface/app/$libraryId/Explorer/Search/index.tsx b/interface/app/$libraryId/Explorer/Search/index.tsx index 79af013c1..34c275766 100644 --- a/interface/app/$libraryId/Explorer/Search/index.tsx +++ b/interface/app/$libraryId/Explorer/Search/index.tsx @@ -74,6 +74,7 @@ export const Separator = () => ESC diff --git a/interface/app/$libraryId/Explorer/Search/store.tsx b/interface/app/$libraryId/Explorer/Search/store.tsx index 6502a639b..262c7f2ca 100644 --- a/interface/app/$libraryId/Explorer/Search/store.tsx +++ b/interface/app/$libraryId/Explorer/Search/store.tsx @@ -26,10 +26,8 @@ export interface FilterOptionWithType extends FilterOption { export type AllKeys = T extends any ? keyof T : never; const searchStore = proxy({ - isSearching: false, interactingWithSearchOptions: false, searchType: 'paths' as SearchType, - searchQuery: null as string | null, filterArgs: ref([] as SearchFilterArgs[]), filterArgsKeys: ref(new Set()), filterOptions: ref(new Map()), @@ -41,8 +39,7 @@ export function useSearchFilters( _searchType: T, fixedArgs: SearchFilterArgs[] ) { - const { setFixedArgs, allFilterArgs } = useSearchContext(); - const searchState = useSearchStore(); + const { setFixedArgs, allFilterArgs, searchQuery } = useSearchContext(); // don't want the search bar to pop in after the top bar has loaded! useLayoutEffect(() => { @@ -51,7 +48,7 @@ export function useSearchFilters( }, [fixedArgs]); const searchQueryFilters = useMemo(() => { - const [name, ext] = searchState.searchQuery?.split('.') ?? []; + const [name, ext] = searchQuery?.split('.') ?? []; const filters: SearchFilterArgs[] = []; @@ -59,7 +56,7 @@ export function useSearchFilters( if (ext) filters.push({ filePath: { extension: { in: [ext] } } }); return filters; - }, [searchState.searchQuery]); + }, [searchQuery]); return useMemo( () => [...searchQueryFilters, ...allFilterArgs.map(({ arg }) => arg)], @@ -146,7 +143,6 @@ export const useSearchRegisteredFilters = (query: string) => { }; export const resetSearchStore = () => { - searchStore.searchQuery = null; searchStore.filterArgs = ref([]); searchStore.filterArgsKeys = ref(new Set()); }; diff --git a/interface/app/$libraryId/Explorer/Search/util.tsx b/interface/app/$libraryId/Explorer/Search/util.tsx index 192f04f01..0f6c77cfa 100644 --- a/interface/app/$libraryId/Explorer/Search/util.tsx +++ b/interface/app/$libraryId/Explorer/Search/util.tsx @@ -4,49 +4,6 @@ import clsx from 'clsx'; import { InOrNotIn, Range, TextMatch } from '@sd/client'; import { Icon as SDIcon } from '~/components'; -function isIn(kind: InOrNotIn): kind is { in: T[] } { - return 'in' in kind; -} - -export function inOrNotIn( - kind: InOrNotIn | null | undefined, - value: T, - condition: boolean -): InOrNotIn { - if (condition) { - if (kind && isIn(kind)) { - kind.in.push(value); - return kind; - } else { - return { in: [value] }; - } - } else { - if (kind && !isIn(kind)) { - kind.notIn.push(value); - return kind; - } else { - return { notIn: [value] }; - } - } -} - -export function textMatch(type: 'contains' | 'startsWith' | 'endsWith' | 'equals') { - return (value: string): TextMatch => { - switch (type) { - case 'contains': - return { contains: value }; - case 'startsWith': - return { startsWith: value }; - case 'endsWith': - return { endsWith: value }; - case 'equals': - return { equals: value }; - default: - throw new Error('Invalid TextMatch type.'); - } - }; -} - export const filterTypeCondition = { inOrNotIn: { in: 'is', diff --git a/interface/app/$libraryId/Layout/Sidebar/Contents.tsx b/interface/app/$libraryId/Layout/Sidebar/Contents.tsx index 2ce171c0d..cd47dd3e7 100644 --- a/interface/app/$libraryId/Layout/Sidebar/Contents.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/Contents.tsx @@ -14,10 +14,10 @@ export default () => { const navigate = useNavigate(); const symbols = useKeysMatcher(['Meta', 'Shift']); - useShortcut('navToOverview', (e) => { - e.stopPropagation(); - navigate('overview'); - }); + // useShortcut('navToOverview', (e) => { + // e.stopPropagation(); + // navigate('overview'); + // }); return (
diff --git a/interface/app/$libraryId/Layout/index.tsx b/interface/app/$libraryId/Layout/index.tsx index 35927fc68..90cf8ef23 100644 --- a/interface/app/$libraryId/Layout/index.tsx +++ b/interface/app/$libraryId/Layout/index.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; import { Suspense, useEffect, useMemo, useRef } from 'react'; -import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { Navigate, Outlet, useNavigate } from 'react-router-dom'; import { ClientContextProvider, initPlausible, diff --git a/interface/app/$libraryId/TopBar/SearchBar.tsx b/interface/app/$libraryId/TopBar/SearchBar.tsx index e3348d846..894a43b24 100644 --- a/interface/app/$libraryId/TopBar/SearchBar.tsx +++ b/interface/app/$libraryId/TopBar/SearchBar.tsx @@ -1,53 +1,23 @@ import clsx from 'clsx'; -import { useCallback, useEffect, useRef, useState, useTransition } from 'react'; -import { useLocation, useResolvedPath } from 'react-router'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState, useTransition } from 'react'; import { useDebouncedCallback } from 'use-debounce'; import { Input, ModifierKeys, Shortcut } from '@sd/ui'; import { SearchParamsSchema } from '~/app/route-schemas'; import { useOperatingSystem, useZodSearchParams } from '~/hooks'; import { keybindForOs } from '~/util/keybinds'; -import { getSearchStore, useSearchStore } from '../Explorer/Search/store'; +import { useSearchStore } from '../Explorer/Search/store'; export default () => { const searchRef = useRef(null); const [searchParams, setSearchParams] = useZodSearchParams(SearchParamsSchema); - const location = useLocation(); const searchStore = useSearchStore(); const os = useOperatingSystem(true); const keybind = keybindForOs(os); - // Wrapping param updates in a transition allows us to track whether - // updating the params triggers a Suspense somewhere else, providing a free - // loading state! - const [_isPending, startTransition] = useTransition(); - - const searchPath = useResolvedPath('search'); - - const [value, setValue] = useState(searchParams.search ?? ''); - - const updateParams = useDebouncedCallback((value: string) => { - getSearchStore().searchQuery = value; - startTransition(() => - setSearchParams((p) => ({ ...p, search: value }), { - replace: true - }) - ); - }, 300); - - const updateValue = useCallback( - (value: string) => { - setValue(value); - // TODO: idk that looked important but uncommenting it fixed my bug - // if (searchPath.pathname === location.pathname) - updateParams(value); - }, - [searchPath.pathname, location.pathname, updateParams] - ); - const focusHandler = useCallback( (event: KeyboardEvent) => { if ( @@ -62,6 +32,7 @@ export default () => { ); const blurHandler = useCallback((event: KeyboardEvent) => { + console.log('blurHandler'); if (event.key === 'Escape' && document.activeElement === searchRef.current) { // Check if element is in focus, then remove it event.preventDefault(); @@ -79,50 +50,57 @@ export default () => { }; }, [blurHandler, focusHandler]); + const [localValue, setLocalValue] = useState(searchParams.search ?? ''); + + useLayoutEffect(() => setLocalValue(searchParams.search ?? ''), [searchParams.search]); + + const updateValueDebounced = useDebouncedCallback((value: string) => { + setSearchParams((p) => ({ ...p, search: value }), { replace: true }); + }, 300); + + function updateValue(value: string) { + setLocalValue(value); + updateValueDebounced(value); + } + + function clearValue() { + setSearchParams( + (p) => { + delete p.search; + return { ...p }; + }, + { replace: true } + ); + } + return ( updateValue(e.target.value)} onBlur={() => { - if (value === '' && !searchStore.interactingWithSearchOptions) { - getSearchStore().isSearching = false; - // setSearchParams({}, { replace: true }); - // navigate(-1); - } + if (localValue === '' && !searchStore.interactingWithSearchOptions) clearValue(); }} - onFocus={() => { - getSearchStore().isSearching = true; - // if (searchPath.pathname !== location.pathname) { - // navigate({ - // pathname: 'search', - // search: createSearchParams({ search: value }).toString() - // }); - // } - }} - value={value} + onFocus={() => updateValueDebounced(localValue)} right={ - <> -
- { - - } -
- {/* This indicates whether the search is loading, a spinner could be put here */} - {/* {_isPending &&
} */} - +
+ { + + } +
} /> ); diff --git a/interface/app/$libraryId/TopBar/index.tsx b/interface/app/$libraryId/TopBar/index.tsx index 97d9b2ed5..3e7f015b7 100644 --- a/interface/app/$libraryId/TopBar/index.tsx +++ b/interface/app/$libraryId/TopBar/index.tsx @@ -8,6 +8,7 @@ import { useKeyMatcher, useOperatingSystem, useShowControls } from '~/hooks'; import { useTabsContext } from '~/TabsContext'; import SearchOptions from '../Explorer/Search'; +import { useSearchContext } from '../Explorer/Search/Context'; import { useSearchStore } from '../Explorer/Search/store'; import { useExplorerStore } from '../Explorer/store'; import { useTopBarContext } from './Layout'; @@ -21,7 +22,7 @@ const TopBar = () => { const tabs = useTabsContext(); const ctx = useTopBarContext(); - const searchStore = useSearchStore(); + const searchCtx = useSearchContext(); useResizeObserver({ ref, @@ -37,7 +38,7 @@ const TopBar = () => { useLayoutEffect(() => { const height = ref.current!.getBoundingClientRect().height; ctx.setTopBarHeight.call(undefined, height); - }, [ctx.setTopBarHeight, searchStore.isSearching]); + }, [ctx.setTopBarHeight, searchCtx.isSearching]); return (
{ {tabs && } - {searchStore.isSearching && ( + {searchCtx.isSearching && ( <>
diff --git a/interface/app/$libraryId/index.tsx b/interface/app/$libraryId/index.tsx index a04323cc9..3901e73cb 100644 --- a/interface/app/$libraryId/index.tsx +++ b/interface/app/$libraryId/index.tsx @@ -1,4 +1,5 @@ import type { RouteObject } from 'react-router-dom'; +import { Navigate } from 'react-router-dom'; import settingsRoutes from './settings'; @@ -33,6 +34,12 @@ const topBarRoutes: RouteObject = { }; export default [ + { + index: true, + Component: () => { + return ; + } + }, topBarRoutes, { path: 'settings', diff --git a/interface/app/$libraryId/settings/node/libraries/CreateDialog.tsx b/interface/app/$libraryId/settings/node/libraries/CreateDialog.tsx index 8e80a4010..919ef5de0 100644 --- a/interface/app/$libraryId/settings/node/libraries/CreateDialog.tsx +++ b/interface/app/$libraryId/settings/node/libraries/CreateDialog.tsx @@ -42,7 +42,7 @@ export default (props: UseDialogProps) => { platform.refreshMenuBar && platform.refreshMenuBar(); - navigate(`/${library.uuid}/overview`); + navigate(`/${library.uuid}`); } catch (e) { console.error(e); } diff --git a/interface/app/onboarding/Layout.tsx b/interface/app/onboarding/Layout.tsx index 14da91cef..98455949d 100644 --- a/interface/app/onboarding/Layout.tsx +++ b/interface/app/onboarding/Layout.tsx @@ -17,8 +17,7 @@ export const Component = () => { const ctx = useContextValue(); if (ctx.libraries.isLoading) return null; - if (ctx.library?.uuid !== undefined) - return ; + if (ctx.library?.uuid !== undefined) return ; return ( diff --git a/interface/app/onboarding/context.tsx b/interface/app/onboarding/context.tsx index e09a3af8c..041d79876 100644 --- a/interface/app/onboarding/context.tsx +++ b/interface/app/onboarding/context.tsx @@ -128,7 +128,7 @@ const useFormState = () => { } resetOnboardingStore(); - navigate(`/${library.uuid}/overview`, { replace: true }); + navigate(`/${library.uuid}`, { replace: true }); } catch (e) { if (e instanceof Error) { alert(`Failed to create library. Error: ${e.message}`); diff --git a/interface/app/route-schemas.ts b/interface/app/route-schemas.ts index 0def66d28..c00e0ee79 100644 --- a/interface/app/route-schemas.ts +++ b/interface/app/route-schemas.ts @@ -24,14 +24,14 @@ export const SearchIdParamsSchema = z.object({ id: z.coerce.number() }); export type SearchIdParams = z.infer; export const SearchParamsSchema = PathParamsSchema.extend({ - take: z.coerce.number().default(100), - order: z - .union([ - z.object({ field: z.literal('name'), value: SortOrderSchema }), - z.object({ field: z.literal('dateCreated'), value: SortOrderSchema }) - // z.object({ field: z.literal('sizeInBytes'), value: SortOrderSchema }) - ]) - .optional(), + // take: z.coerce.number().default(100), + // order: z + // .union([ + // z.object({ field: z.literal('name'), value: SortOrderSchema }), + // z.object({ field: z.literal('dateCreated'), value: SortOrderSchema }) + // // z.object({ field: z.literal('sizeInBytes'), value: SortOrderSchema }) + // ]) + // .optional(), search: z.string().optional() }); export type SearchParams = z.infer; diff --git a/interface/hooks/useKeybindEventHandler.ts b/interface/hooks/useKeybindEventHandler.ts index fa9bcb13b..a2713d721 100644 --- a/interface/hooks/useKeybindEventHandler.ts +++ b/interface/hooks/useKeybindEventHandler.ts @@ -16,9 +16,9 @@ export const useKeybindEventHandler = (libraryId?: string) => { case 'open_settings': libraryId && navigate(`/${libraryId}/settings/client/general`); break; - case 'open_overview': - libraryId && navigate(`/${libraryId}/overview`); - break; + // case 'open_overview': + // libraryId && navigate(`/${libraryId}/overview`); + // break; case 'open_search': // somehow emit ctrl/cmd+f break; diff --git a/interface/hooks/useShortcut.ts b/interface/hooks/useShortcut.ts index 0f79eb4ee..13ac68acf 100644 --- a/interface/hooks/useShortcut.ts +++ b/interface/hooks/useShortcut.ts @@ -178,12 +178,12 @@ const state = { all: ['Shift', 'Control', 'KeyT'] } }, - navToOverview: { - keys: { - macOS: ['Shift', 'Meta', 'KeyO'], - all: ['Shift', 'Control', 'KeyO'] - } - }, + // navToOverview: { + // keys: { + // macOS: ['Shift', 'Meta', 'KeyO'], + // all: ['Shift', 'Control', 'KeyO'] + // } + // }, navExpObjects: { keys: { all: ['Control', 'ArrowRight'] diff --git a/interface/hooks/useZodSearchParams.ts b/interface/hooks/useZodSearchParams.ts index 5f26f4108..7592e2150 100644 --- a/interface/hooks/useZodSearchParams.ts +++ b/interface/hooks/useZodSearchParams.ts @@ -1,7 +1,6 @@ import { useCallback, useMemo } from 'react'; import { NavigateOptions, useSearchParams } from 'react-router-dom'; -import { getParams } from 'remix-params-helper'; -import type { z } from 'zod'; +import { z } from 'zod'; export function useZodSearchParams(schema: Z) { // eslint-disable-next-line no-restricted-syntax @@ -36,3 +35,136 @@ export function useZodSearchParams(schema: Z) { ) ] as const; } + +// from https://github.com/kiliman/remix-params-helper/blob/main/src/helper.ts +// original skips empty strings but empty strings are useful sometimes + +export function getParams>( + params: URLSearchParams | FormData | Record, + schema: T +) { + type ParamsType = z.infer; + return getParamsInternal(params, schema); +} + +function isIterable(maybeIterable: unknown): maybeIterable is Iterable { + return Symbol.iterator in Object(maybeIterable); +} + +function getParamsInternal( + params: URLSearchParams | FormData | Record, + schema: any +): + | { success: true; data: T; errors: undefined } + | { success: false; data: undefined; errors: { [key: string]: string } } { + const o: any = {}; + let entries: [string, unknown][] = []; + if (isIterable(params)) { + entries = Array.from(params); + } else { + entries = Object.entries(params); + } + for (const [key, value] of entries) { + parseParams(o, schema, key, value); + } + + const result = schema.safeParse(o); + if (result.success) { + return { success: true, data: result.data as T, errors: undefined }; + } else { + const errors: Record = {}; + const addError = (key: string, message: string) => { + if (!Object.prototype.hasOwnProperty.call(errors, key)) { + errors[key] = message; + } else { + if (!Array.isArray(errors[key])) { + errors[key] = [errors[key]]; + } + errors[key].push(message); + } + }; + for (const issue of result.error.issues) { + const { message, path, code, expected, received } = issue; + const [key, index] = path; + let value = o[key]; + let prop = key; + if (index !== undefined) { + value = value[index]; + prop = `${key}[${index}]`; + } + addError(key, message); + } + return { success: false, data: undefined, errors }; + } +} + +function parseParams(o: any, schema: any, key: string, value: any) { + // find actual shape definition for this key + let shape = schema; + while (shape instanceof z.ZodObject || shape instanceof z.ZodEffects) { + shape = + shape instanceof z.ZodObject + ? shape.shape + : shape instanceof z.ZodEffects + ? shape._def.schema + : null; + if (shape === null) { + throw new Error(`Could not find shape for key ${key}`); + } + } + + if (key.includes('.')) { + const [parentProp, ...rest] = key.split('.') as [string, ...string[]]; + o[parentProp!] = o[parentProp] ?? {}; + parseParams(o[parentProp], shape[parentProp], rest.join('.'), value); + return; + } + let isArray = false; + if (key.includes('[]')) { + isArray = true; + key = key.replace('[]', ''); + } + const def = shape[key]; + if (def) { + processDef(def, o, key, value as string); + } +} + +function processDef(def: z.ZodTypeAny, o: any, key: string, value: string) { + let parsedValue: any; + if (def instanceof z.ZodString || def instanceof z.ZodLiteral) { + parsedValue = value; + } else if (def instanceof z.ZodNumber) { + const num = Number(value); + parsedValue = isNaN(num) ? value : num; + } else if (def instanceof z.ZodDate) { + const date = Date.parse(value); + parsedValue = isNaN(date) ? value : new Date(date); + } else if (def instanceof z.ZodBoolean) { + parsedValue = value === 'true' ? true : value === 'false' ? false : Boolean(value); + } else if (def instanceof z.ZodNativeEnum || def instanceof z.ZodEnum) { + parsedValue = value; + } else if (def instanceof z.ZodOptional || def instanceof z.ZodDefault) { + // def._def.innerType is the same as ZodOptional's .unwrap(), which unfortunately doesn't exist on ZodDefault + processDef(def._def.innerType, o, key, value); + // return here to prevent overwriting the result of the recursive call + return; + } else if (def instanceof z.ZodArray) { + if (o[key] === undefined) { + o[key] = []; + } + processDef(def.element, o, key, value); + // return here since recursive call will add to array + return; + } else if (def instanceof z.ZodEffects) { + processDef(def._def.schema, o, key, value); + return; + } else { + throw new Error(`Unexpected type ${def._def.typeName} for key ${key}`); + } + if (Array.isArray(o[key])) { + o[key].push(parsedValue); + } else { + o[key] = parsedValue; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46c46045bedd6aa7d7d20f14a9d6d3e7c772fa41..b59c2caa7d657adb50eb8d15f97ffa0f40226f88 100644 GIT binary patch delta 126 zcmZqZHT>6OxZwrM*Nq~YfS41Axwabzac8WD MYuJ8)k!OPo0F{g{N&o-= delta 103 zcmey@W7yDZxZwrMFyy8#&*n|b-g8Jl%{+I4&wftU%1nSq#PyN(ZQAM5ma t%sd{`KghH2w@U@F0x=s9vjZ^)5OV@C*LJBO?y~hDb*9_TG4gD10RSdiArk-q