mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-18 21:36:56 -04:00
[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
This commit is contained in:
@@ -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 !== '' && (
|
||||
<FilterContainer>
|
||||
<StaticSection>
|
||||
<RenderIcon className="h-4 w-4" icon={MagnifyingGlass} />
|
||||
<FilterText>{searchState.searchQuery}</FilterText>
|
||||
<FilterText>{searchCtx.searchQuery}</FilterText>
|
||||
</StaticSection>
|
||||
<CloseTab onClick={() => (getSearchStore().searchQuery = null)} />
|
||||
<CloseTab onClick={() => searchCtx.setSearchQuery('')} />
|
||||
</FilterContainer>
|
||||
)}
|
||||
{allFilterArgs.map(({ arg, removalIndex }, index) => {
|
||||
{searchCtx.allFilterArgs.map(({ arg, removalIndex }, index) => {
|
||||
const filter = filterRegistry.find((f) => f.extract(arg));
|
||||
if (!filter) return;
|
||||
|
||||
|
||||
@@ -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<ReturnType<typeof useContextValue> | 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) => {
|
||||
|
||||
@@ -74,6 +74,7 @@ export const Separator = () => <DropdownMenu.Separator className="!border-app-li
|
||||
|
||||
const SearchOptions = () => {
|
||||
const searchState = useSearchStore();
|
||||
const searchCtx = useSearchContext();
|
||||
|
||||
const [newFilterName, setNewFilterName] = useState('');
|
||||
const [_search, setSearch] = useState('');
|
||||
@@ -81,7 +82,7 @@ const SearchOptions = () => {
|
||||
const search = useDeferredValue(_search);
|
||||
|
||||
useKeybind(['Escape'], () => {
|
||||
getSearchStore().isSearching = false;
|
||||
// getSearchStore().isSearching = false;
|
||||
});
|
||||
|
||||
// const savedSearches = useSavedSearches();
|
||||
@@ -187,7 +188,7 @@ const SearchOptions = () => {
|
||||
}
|
||||
|
||||
<kbd
|
||||
onClick={() => (getSearchStore().isSearching = false)}
|
||||
onClick={() => searchCtx.clearSearchQuery()}
|
||||
className="ml-2 rounded-lg border border-app-line bg-app-box px-2 py-1 text-[10.5px] tracking-widest shadow"
|
||||
>
|
||||
ESC
|
||||
|
||||
@@ -26,10 +26,8 @@ export interface FilterOptionWithType extends FilterOption {
|
||||
export type AllKeys<T> = 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<string>()),
|
||||
filterOptions: ref(new Map<string, FilterOptionWithType[]>()),
|
||||
@@ -41,8 +39,7 @@ export function useSearchFilters<T extends SearchType>(
|
||||
_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<T extends SearchType>(
|
||||
}, [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<T extends SearchType>(
|
||||
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());
|
||||
};
|
||||
|
||||
@@ -4,49 +4,6 @@ import clsx from 'clsx';
|
||||
import { InOrNotIn, Range, TextMatch } from '@sd/client';
|
||||
import { Icon as SDIcon } from '~/components';
|
||||
|
||||
function isIn<T>(kind: InOrNotIn<T>): kind is { in: T[] } {
|
||||
return 'in' in kind;
|
||||
}
|
||||
|
||||
export function inOrNotIn<T>(
|
||||
kind: InOrNotIn<T> | null | undefined,
|
||||
value: T,
|
||||
condition: boolean
|
||||
): InOrNotIn<T> {
|
||||
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',
|
||||
|
||||
@@ -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 (
|
||||
<div className="no-scrollbar mask-fade-out flex grow flex-col space-y-5 overflow-x-hidden overflow-y-scroll pb-10">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<HTMLInputElement>(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 (
|
||||
<Input
|
||||
ref={searchRef}
|
||||
placeholder="Search"
|
||||
className="mx-2 w-48 transition-all duration-200 focus-within:w-60"
|
||||
size="sm"
|
||||
value={localValue}
|
||||
onChange={(e) => 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={
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
'pointer-events-none flex h-7 items-center space-x-1 opacity-70 group-focus-within:hidden'
|
||||
)}
|
||||
>
|
||||
{
|
||||
<Shortcut
|
||||
chars={keybind([ModifierKeys.Control], ['F'])}
|
||||
aria-label={`Press ${
|
||||
os === 'macOS' ? 'Command' : ModifierKeys.Control
|
||||
}-F to focus search bar`}
|
||||
className="border-none"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
{/* This indicates whether the search is loading, a spinner could be put here */}
|
||||
{/* {_isPending && <div className="w-8 h-8 bg-red-500" />} */}
|
||||
</>
|
||||
<div
|
||||
className={clsx(
|
||||
'pointer-events-none flex h-7 items-center space-x-1 opacity-70 group-focus-within:hidden'
|
||||
)}
|
||||
>
|
||||
{
|
||||
<Shortcut
|
||||
chars={keybind([ModifierKeys.Control], ['F'])}
|
||||
aria-label={`Press ${
|
||||
os === 'macOS' ? 'Command' : ModifierKeys.Control
|
||||
}-F to focus search bar`}
|
||||
className="border-none"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -71,7 +72,7 @@ const TopBar = () => {
|
||||
|
||||
{tabs && <Tabs />}
|
||||
|
||||
{searchStore.isSearching && (
|
||||
{searchCtx.isSearching && (
|
||||
<>
|
||||
<hr className="w-full border-t border-sidebar-divider bg-sidebar-divider" />
|
||||
<SearchOptions />
|
||||
|
||||
@@ -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 <Navigate to="network" />;
|
||||
}
|
||||
},
|
||||
topBarRoutes,
|
||||
{
|
||||
path: 'settings',
|
||||
|
||||
@@ -42,7 +42,7 @@ export default (props: UseDialogProps) => {
|
||||
|
||||
platform.refreshMenuBar && platform.refreshMenuBar();
|
||||
|
||||
navigate(`/${library.uuid}/overview`);
|
||||
navigate(`/${library.uuid}`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
@@ -17,8 +17,7 @@ export const Component = () => {
|
||||
const ctx = useContextValue();
|
||||
|
||||
if (ctx.libraries.isLoading) return null;
|
||||
if (ctx.library?.uuid !== undefined)
|
||||
return <Navigate to={`/${ctx.library.uuid}/overview`} replace />;
|
||||
if (ctx.library?.uuid !== undefined) return <Navigate to={`/${ctx.library.uuid}`} replace />;
|
||||
|
||||
return (
|
||||
<OnboardingContext.Provider value={ctx}>
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -24,14 +24,14 @@ export const SearchIdParamsSchema = z.object({ id: z.coerce.number() });
|
||||
export type SearchIdParams = z.infer<typeof SearchIdParamsSchema>;
|
||||
|
||||
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<typeof SearchParamsSchema>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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<Z extends z.AnyZodObject>(schema: Z) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
@@ -36,3 +35,136 @@ export function useZodSearchParams<Z extends z.AnyZodObject>(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<T extends z.ZodType<any, any, any>>(
|
||||
params: URLSearchParams | FormData | Record<string, string | undefined>,
|
||||
schema: T
|
||||
) {
|
||||
type ParamsType = z.infer<T>;
|
||||
return getParamsInternal<ParamsType>(params, schema);
|
||||
}
|
||||
|
||||
function isIterable(maybeIterable: unknown): maybeIterable is Iterable<unknown> {
|
||||
return Symbol.iterator in Object(maybeIterable);
|
||||
}
|
||||
|
||||
function getParamsInternal<T>(
|
||||
params: URLSearchParams | FormData | Record<string, string | undefined>,
|
||||
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<string, any> = {};
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
BIN
pnpm-lock.yaml
generated
BIN
pnpm-lock.yaml
generated
Binary file not shown.
Reference in New Issue
Block a user