[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:
Brendan Allan
2023-11-18 08:07:33 +11:00
committed by GitHub
parent 8005436a8c
commit a4522d65a7
18 changed files with 306 additions and 181 deletions

View File

@@ -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;

View File

@@ -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) => {

View File

@@ -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

View File

@@ -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());
};

View File

@@ -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',

View File

@@ -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">

View File

@@ -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,

View File

@@ -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>
}
/>
);

View File

@@ -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 />

View File

@@ -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',

View File

@@ -42,7 +42,7 @@ export default (props: UseDialogProps) => {
platform.refreshMenuBar && platform.refreshMenuBar();
navigate(`/${library.uuid}/overview`);
navigate(`/${library.uuid}`);
} catch (e) {
console.error(e);
}

View File

@@ -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}>

View File

@@ -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}`);

View File

@@ -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>;

View File

@@ -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;

View File

@@ -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']

View File

@@ -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
View File

Binary file not shown.