diff --git a/interface/app/$libraryId/Explorer/Search/AppliedFilters.tsx b/interface/app/$libraryId/Explorer/Search/AppliedFilters.tsx index 99cc94754b3f..4b1495f1f394 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 860512c5d7a7..efbeecb4c5e3 100644 --- a/interface/app/$libraryId/Explorer/Search/Context.tsx +++ b/interface/app/$libraryId/Explorer/Search/Context.tsx @@ -1,5 +1,7 @@ import { createContext, PropsWithChildren, useContext, 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'; @@ -7,7 +9,10 @@ import { argsToOptions, getKey, useSearchStore } from './store'; const Context = createContext | null>(null); +const SEARCH_PARAMS = z.object({ search: z.string().optional(), filters: z.object({}).optional() }); + function useContextValue() { + const [searchParams, setSearchParams] = useZodSearchParams(SEARCH_PARAMS); const searchState = useSearchStore(); const { fixedArgs, setFixedArgs } = useTopBarContext(); @@ -69,7 +74,23 @@ function useContextValue() { return value; }, [fixedArgs, searchState.filterArgs]); - return { setFixedArgs, fixedArgs, fixedArgsKeys, allFilterArgs }; + 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 79af013c1d42..34c275766b1f 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 6502a639b4fd..a252eec18b07 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,7 +39,7 @@ export function useSearchFilters( _searchType: T, fixedArgs: SearchFilterArgs[] ) { - const { setFixedArgs, allFilterArgs } = useSearchContext(); + const { setFixedArgs, allFilterArgs, searchQuery } = useSearchContext(); const searchState = useSearchStore(); // don't want the search bar to pop in after the top bar has loaded! @@ -51,7 +49,7 @@ export function useSearchFilters( }, [fixedArgs]); const searchQueryFilters = useMemo(() => { - const [name, ext] = searchState.searchQuery?.split('.') ?? []; + const [name, ext] = searchQuery?.split('.') ?? []; const filters: SearchFilterArgs[] = []; @@ -59,7 +57,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 +144,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 192f04f01a58..0f6c77cfa0a6 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/TopBar/SearchBar.tsx b/interface/app/$libraryId/TopBar/SearchBar.tsx index e3348d8466b4..894a43b24887 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); - } - }} - onFocus={() => { - getSearchStore().isSearching = true; - // if (searchPath.pathname !== location.pathname) { - // navigate({ - // pathname: 'search', - // search: createSearchParams({ search: value }).toString() - // }); - // } + if (localValue === '' && !searchStore.interactingWithSearchOptions) clearValue(); }} - 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 97d9b2ed5c01..50741e89d7e9 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,6 +22,7 @@ const TopBar = () => { const tabs = useTabsContext(); const ctx = useTopBarContext(); + const searchCtx = useSearchContext(); const searchStore = useSearchStore(); useResizeObserver({ @@ -32,12 +34,14 @@ const TopBar = () => { } }); + const isSearching = searchCtx.searchQuery !== undefined; + // when the component mounts + crucial state changes, we need to update the height _before_ the browser paints // in order to avoid jank. resize observer doesn't fire early enought to account for this. 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/route-schemas.ts b/interface/app/route-schemas.ts index 0def66d28578..c00e0ee796c6 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/useZodSearchParams.ts b/interface/hooks/useZodSearchParams.ts index 5f26f4108b37..7592e215076c 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 46c46045bedd..b59c2caa7d65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,7 +46,7 @@ importers: version: 5.2.2 vite: specifier: ^4.5.0 - version: 4.5.0(@types/node@18.17.19) + version: 4.5.0(less@4.2.0) .github/actions/publish-artifacts: dependencies: @@ -615,7 +615,7 @@ importers: version: 5.2.2 vite: specifier: ^4.5.0 - version: 4.5.0(@types/node@18.17.19) + version: 4.5.0(less@4.2.0) vite-plugin-html: specifier: ^3.2.0 version: 3.2.0(vite@4.5.0) @@ -8952,7 +8952,7 @@ packages: '@babel/plugin-transform-react-jsx-source': 7.22.5(@babel/core@7.23.2) '@types/babel__core': 7.20.3 react-refresh: 0.14.0 - vite: 4.5.0(sass@1.69.5) + vite: 4.5.0(@types/node@18.17.19) transitivePeerDependencies: - supports-color dev: true @@ -21004,7 +21004,7 @@ packages: '@rollup/pluginutils': 5.0.5 '@svgr/core': 8.1.0(typescript@5.2.2) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0) - vite: 4.5.0(sass@1.69.5) + vite: 4.5.0(@types/node@18.17.19) transitivePeerDependencies: - rollup - supports-color