stacked approach working for non-search filters

This commit is contained in:
Brendan Allan 2023-11-10 17:23:07 +08:00
parent 2b2b7a1e4a
commit fa43998afa
14 changed files with 432 additions and 426 deletions

View file

@ -1,18 +1,10 @@
import { MagnifyingGlass, X } from '@phosphor-icons/react';
import { produce } from 'immer';
import { forwardRef, useMemo } from 'react';
import { ref, snapshot } from 'valtio';
import { tw } from '@sd/ui';
import { useTopBarContext } from '~/app/$libraryId/TopBar/Layout';
import { filterRegistry } from './Filters';
import {
deselectFilterOption,
getKey,
getSearchStore,
getSelectedFiltersGrouped,
updateFilterArgs,
useSearchStore
} from './store';
import { getSearchStore, updateFilterArgs, useSearchStore } from './store';
import { RenderIcon } from './util';
export const FilterContainer = tw.div`flex flex-row items-center rounded bg-app-box overflow-hidden`;
@ -21,13 +13,13 @@ export const InteractiveSection = tw.div`flex group flex-row items-center border
export const StaticSection = tw.div`flex flex-row items-center pl-2 pr-1 text-sm`;
const FilterText = tw.span`mx-1 py-0.5 text-sm text-ink-dull`;
export const FilterText = tw.span`mx-1 py-0.5 text-sm text-ink-dull`;
const CloseTab = forwardRef<HTMLDivElement, { onClick: () => void }>(({ onClick }, ref) => {
export const CloseTab = forwardRef<HTMLDivElement, { onClick: () => void }>(({ onClick }, ref) => {
return (
<div
ref={ref}
className="border-app-darkerBox/70 flex h-full items-center rounded-r border-l px-1.5 py-0.5 text-sm hover:bg-app-lightBox/30"
className="flex h-full items-center rounded-r border-l border-app-darkerBox/70 px-1.5 py-0.5 text-sm hover:bg-app-lightBox/30"
onClick={onClick}
>
<RenderIcon className="h-3 w-3" icon={X} />
@ -38,12 +30,45 @@ const CloseTab = forwardRef<HTMLDivElement, { onClick: () => void }>(({ onClick
export const AppliedOptions = () => {
const searchState = useSearchStore();
// turn the above into use memo
const groupedFilters = useMemo(
() => getSelectedFiltersGrouped(),
// eslint-disable-next-line react-hooks/exhaustive-deps
[searchState.selectedFilters.size]
);
const { fixedArgs } = useTopBarContext();
const allArgs = useMemo(() => {
if (!fixedArgs) return [];
const value: { arg: SearchFilterArgs; removalIndex: number | null }[] = fixedArgs.map(
(arg, i) => ({
arg,
removalIndex: null
})
);
for (const [index, arg] of searchState.filterArgs.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]!)!,
filter.extract(arg)
);
value[fixedEquivalentIndex] = {
arg: filter.create(merged),
removalIndex: fixedEquivalentIndex
};
} else {
value.push({
arg,
removalIndex: index
});
}
}
return value;
}, [fixedArgs, searchState.filterArgs]);
return (
<div className="flex flex-row gap-2">
@ -56,18 +81,14 @@ export const AppliedOptions = () => {
<CloseTab onClick={() => (getSearchStore().searchQuery = null)} />
</FilterContainer>
)}
{searchState.filterArgs.map((arg, index) => {
const filter = filterRegistry.find((f) => f.find(arg));
{allArgs.map(({ arg, removalIndex }, index) => {
const filter = filterRegistry.find((f) => f.extract(arg));
if (!filter) return;
const options = searchState.filterOptions.get(filter.name);
if (!options) return;
const isFixed = searchState.fixedFilters.at(index) !== undefined;
const activeOptions =
filter.getActiveOptions &&
filter.getActiveOptions(filter.find(arg)! as any, options);
const activeOptions = filter.argsToOptions(
filter.extract(arg)! as any,
searchState.filterOptions
);
return (
<FilterContainer key={`${filter.name}-${index}`}>
@ -77,16 +98,16 @@ export const AppliedOptions = () => {
</StaticSection>
<InteractiveSection className="border-l">
{/* {Object.entries(filter.conditions).map(([value, displayName]) => (
<div key={value}>{displayName}</div>
))} */}
<div key={value}>{displayName}</div>
))} */}
{
(filter.conditions as any)[
filter.getCondition(filter.find(arg) as any) as any
filter.getCondition(filter.extract(arg) as any) as any
]
}
</InteractiveSection>
<InteractiveSection className="border-app-darkerBox/70 gap-1 border-l py-0.5 pl-1.5 pr-2 text-sm">
<InteractiveSection className="gap-1 border-l border-app-darkerBox/70 py-0.5 pl-1.5 pr-2 text-sm">
{activeOptions && (
<>
{activeOptions.length === 1 ? (
@ -123,11 +144,16 @@ export const AppliedOptions = () => {
)}
</InteractiveSection>
{!isFixed && (
{removalIndex !== null && (
<CloseTab
onClick={() => {
updateFilterArgs((args) => {
args.splice(index);
console.log({
allArgs,
filterArgs: searchState.filterArgs,
removalIndex
});
args.splice(removalIndex, 1);
return args;
});

View file

@ -1,23 +1,12 @@
import { CircleDashed, Cube, Folder, Icon, SelectionSlash, Textbox } from '@phosphor-icons/react';
import { produce } from 'immer';
import { useState } from 'react';
import { ref, snapshot } from 'valtio';
import { InOrNotIn, ObjectKind, SearchFilterArgs, TextMatch, useLibraryQuery } from '@sd/client';
import { Button, Input } from '@sd/ui';
import { useTopBarContext } from '~/app/$libraryId/TopBar/Layout';
import { SearchOptionItem, SearchOptionSubMenu } from '.';
import {
AllKeys,
deselectFilterOption,
FilterOption,
getKey,
getSearchStore,
selectFilterOption,
SetFilter,
updateFilterArgs,
useSearchStore
} from './store';
import { FilterTypeCondition, filterTypeCondition, inOrNotIn, textMatch } from './util';
import { AllKeys, FilterOption, getKey, updateFilterArgs, useSearchStore } from './store';
import { FilterTypeCondition, filterTypeCondition } from './util';
export interface SearchFilter<
TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any
@ -31,15 +20,16 @@ interface SearchFilterCRUD<
TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any,
T = any
> extends SearchFilter<TConditions> {
getCondition: (args: T) => keyof TConditions | undefined;
getCondition: (args: T) => AllKeys<TConditions>;
setCondition: (args: T, condition: keyof TConditions) => void;
getOptionActive: (args: T, option: FilterOption) => boolean;
getActiveOptions: (args: T, allOptions: FilterOption[]) => FilterOption[];
applyAdd: (args: T, option: FilterOption) => void;
applyRemove: (args: T, option: FilterOption) => T | undefined;
argsToOptions: (args: T) => FilterOption[];
find: (arg: SearchFilterArgs) => T | undefined;
argsToOptions: (args: T, options: Map<string, FilterOption[]>) => FilterOption[];
extract: (arg: SearchFilterArgs) => T | undefined;
create: (data: any) => SearchFilterArgs;
merge: (left: T, right: T) => T;
}
export interface RenderSearchFilter<
@ -63,62 +53,63 @@ const FilterOptionList = ({
options: FilterOption[];
}) => {
const store = useSearchStore();
const arg = store.filterArgs.find(filter.find);
const specificArg = arg ? filter.find(arg) : undefined;
const { fixedArgsKeys } = useTopBarContext();
return (
<SearchOptionSubMenu name={filter.name} icon={filter.icon}>
{options?.map((option) => (
<SearchOptionItem
selected={
(specificArg && filter.getOptionActive?.(specificArg, option)) ?? false
}
setSelected={(value) => {
const searchStore = getSearchStore();
{options?.map((option) => {
const optionKey = getKey({
...option,
type: filter.name
});
updateFilterArgs((args) => {
const key = getKey({
type: filter.name,
name: option.name,
value: option.value
return (
<SearchOptionItem
selected={
store.filterArgsKeys.has(optionKey) || fixedArgsKeys?.has(optionKey)
}
setSelected={(value) => {
updateFilterArgs((args) => {
if (fixedArgsKeys?.has(optionKey)) return args;
const rawArg = args.find((arg) => filter.extract(arg));
if (!rawArg) {
const arg = filter.create(option.value);
args.push(arg);
} else {
const rawArgIndex = args.findIndex((arg) =>
filter.extract(arg)
)!;
const arg = filter.extract(rawArg)!;
if (value) {
if (rawArg) filter.applyAdd(arg, option);
} else {
if (!filter.applyRemove(arg, option))
args.splice(rawArgIndex);
}
}
return args;
});
if (searchStore.fixedFilterKeys.has(key)) return;
let rawArg = args.find((arg) => filter.find(arg));
if (!rawArg) {
rawArg = filter.create(option.value);
args.push(rawArg);
}
const rawArgIndex = args.findIndex((arg) => filter.find(arg))!;
const arg = filter.find(rawArg)!;
if (!filter.getCondition?.(arg))
filter.setCondition(arg, Object.keys(filter.conditions)[0]!);
if (value) filter.applyAdd(arg, option);
else {
if (!filter.applyRemove(arg, option)) args.splice(rawArgIndex);
}
});
}}
key={option.value}
icon={option.icon}
>
{option.name}
</SearchOptionItem>
))}
}}
key={option.value}
icon={option.icon}
>
{option.name}
</SearchOptionItem>
);
})}
</SearchOptionSubMenu>
);
};
const FilterOptionText = ({ filter }: { filter: SearchFilterCRUD }) => {
const [value, setValue] = useState('');
const store = useSearchStore();
const { fixedArgsKeys } = useTopBarContext();
return (
<SearchOptionSubMenu name={filter.name} icon={filter.icon}>
@ -126,8 +117,6 @@ const FilterOptionText = ({ filter }: { filter: SearchFilterCRUD }) => {
<Button
variant="accent"
onClick={() => {
const searchStore = getSearchStore();
updateFilterArgs((args) => {
const key = getKey({
type: filter.name,
@ -135,12 +124,12 @@ const FilterOptionText = ({ filter }: { filter: SearchFilterCRUD }) => {
value
});
if (searchStore.fixedFilterKeys.has(key)) return;
if (fixedArgsKeys?.has(key)) return args;
const arg = filter.create(value);
args.push(arg);
filter.applyAdd(filter.find(arg)!, { name: value, value });
return args;
});
}}
>
@ -151,34 +140,36 @@ const FilterOptionText = ({ filter }: { filter: SearchFilterCRUD }) => {
};
const FilterOptionBoolean = ({ filter }: { filter: SearchFilterCRUD }) => {
const { filterArgs } = useSearchStore();
const { filterArgs, filterArgsKeys } = useSearchStore();
const { fixedArgsKeys } = useTopBarContext();
const key = getKey({
type: filter.name,
name: filter.name,
value: true
});
return (
<SearchOptionItem
icon={filter.icon}
selected={filterArgs.find((a) => filter.find(a) !== undefined) !== undefined}
selected={fixedArgsKeys?.has(key) || filterArgsKeys.has(key)}
setSelected={() => {
const searchStore = getSearchStore();
updateFilterArgs((args) => {
const key = getKey({
type: filter.name,
name: filter.name,
value: true
});
if (fixedArgsKeys?.has(key)) return args;
if (searchStore.fixedFilterKeys.has(key)) return;
const index = args.findIndex((f) => filter.find(f) !== undefined);
const index = args.findIndex((f) => filter.extract(f) !== undefined);
if (index !== -1) {
args.splice(index);
} else {
const arg = filter.create();
const arg = filter.create(true);
args.push(arg);
filter.applyAdd(arg, { name: filter.name, value: true });
}
return args;
});
}}
>
@ -207,13 +198,18 @@ function createInOrNotInFilter<T extends string | number>(
| 'create'
> & {
create(value: InOrNotIn<T>): SearchFilterArgs;
argsToOptions(values: T[]): FilterOption[];
argsToOptions(values: T[], options: Map<string, FilterOption[]>): FilterOption[];
}
): ReturnType<typeof createFilter<(typeof filterTypeCondition)['inOrNotIn'], InOrNotIn<T>>> {
return {
...filter,
create: () => {
return filter.create({ in: [] });
create: (data) => {
if (typeof data === 'number' || typeof data === 'string')
return filter.create({
in: [data]
});
else if (data) return filter.create(data);
else return filter.create({ in: [] });
},
conditions: filterTypeCondition.inOrNotIn,
getCondition: (data) => {
@ -237,13 +233,13 @@ function createInOrNotInFilter<T extends string | number>(
return value.map((v) => options.find((o) => o.value === v)!).filter(Boolean);
},
argsToOptions: (data) => {
argsToOptions: (data, options) => {
let values: T[];
if ('in' in data) values = data.in;
else values = data.notIn;
return filter.argsToOptions(values);
return filter.argsToOptions(values, options);
},
applyAdd: (data, option) => {
if ('in' in data) data.in.push(option.value);
@ -263,6 +259,19 @@ function createInOrNotInFilter<T extends string | number>(
}
return data;
},
merge: (left, right) => {
if ('in' in left && 'in' in right) {
return {
in: [...new Set([...left.in, ...right.in])]
};
} else if ('notIn' in left && 'notIn' in right) {
return {
notIn: [...new Set([...left.notIn, ...right.notIn])]
};
}
throw new Error('Cannot merge InOrNotIns with different conditions');
}
};
}
@ -293,7 +302,7 @@ function createTextMatchFilter(
if ('contains' in data) return 'contains';
else if ('startsWith' in data) return 'startsWith';
else if ('endsWith' in data) return 'endsWith';
else if ('equals' in data) return 'equals';
else return 'equals';
},
setCondition: (data, condition) => {
let value: string;
@ -351,7 +360,10 @@ function createTextMatchFilter(
else if ('endsWith' in data) return { endsWith: value };
else if ('equals' in data) return { equals: value };
},
applyRemove: () => undefined
applyRemove: () => undefined,
merge: (left, right) => {
return left;
}
};
}
@ -384,16 +396,12 @@ function createBooleanFilter(
return condition === 'true';
},
argsToOptions: (value) => {
const option = getSearchStore()
.filterOptions.get(filter.name)
?.find((o) => o.value === value);
if (!option) return [];
if (!value) return [];
return [
{
type: filter.name,
name: option.name,
name: filter.name,
value
}
];
@ -407,7 +415,10 @@ function createBooleanFilter(
applyAdd: (_, { value }) => {
return value;
},
applyRemove: () => undefined
applyRemove: () => undefined,
merge: (left, right) => {
return left;
}
};
}
@ -415,23 +426,20 @@ export const filterRegistry = [
createInOrNotInFilter({
name: 'Location',
icon: Folder, // Phosphor folder icon
find: (arg) => {
extract: (arg) => {
if ('filePath' in arg && 'locations' in arg.filePath) return arg.filePath.locations;
},
create: (locations) => ({ filePath: { locations } }),
argsToOptions(values) {
argsToOptions(values, options) {
return values
.map((value) => {
const option = getSearchStore()
.filterOptions.get(this.name)
?.find((o) => o.value === value);
const option = options.get(this.name)?.find((o) => o.value === value);
if (!option) return;
return {
type: this.name,
name: value,
value
...option,
type: this.name
};
})
.filter(Boolean) as any;
@ -452,23 +460,20 @@ export const filterRegistry = [
createInOrNotInFilter({
name: 'Tags',
icon: CircleDashed,
find: (arg) => {
extract: (arg) => {
if ('object' in arg && 'tags' in arg.object) return arg.object.tags;
},
create: (tags) => ({ object: { tags } }),
argsToOptions(values) {
argsToOptions(values, options) {
return values
.map((value) => {
const option = getSearchStore()
.filterOptions.get(this.name)
?.find((o) => o.value === value);
const option = options.get(this.name)?.find((o) => o.value === value);
if (!option) return;
return {
type: this.name,
name: option.name,
value
...option,
type: this.name
};
})
.filter(Boolean) as any;
@ -489,23 +494,20 @@ export const filterRegistry = [
createInOrNotInFilter({
name: 'Kind',
icon: Cube,
find: (arg) => {
extract: (arg) => {
if ('object' in arg && 'kind' in arg.object) return arg.object.kind;
},
create: (kind) => ({ object: { kind } }),
argsToOptions(values) {
argsToOptions(values, options) {
return values
.map((value) => {
const option = getSearchStore()
.filterOptions.get(this.name)
?.find((o) => o.value === value);
const option = options.get(this.name)?.find((o) => o.value === value);
if (!option) return;
return {
type: this.name,
name: value,
value
...option,
type: this.name
};
})
.filter(Boolean) as any;
@ -529,7 +531,7 @@ export const filterRegistry = [
createTextMatchFilter({
name: 'Name',
icon: Textbox,
find: (arg) => {
extract: (arg) => {
if ('filePath' in arg && 'name' in arg.filePath) return arg.filePath.name;
},
create: (name) => ({ filePath: { name } }),
@ -549,7 +551,7 @@ export const filterRegistry = [
createInOrNotInFilter({
name: 'Extension',
icon: Textbox,
find: (arg) => {
extract: (arg) => {
if ('filePath' in arg && 'extension' in arg.filePath) return arg.filePath.extension;
},
create: (extension) => ({ filePath: { extension } }),
@ -576,7 +578,7 @@ export const filterRegistry = [
createBooleanFilter({
name: 'Hidden',
icon: SelectionSlash,
find: (arg) => {
extract: (arg) => {
if ('filePath' in arg && 'hidden' in arg.filePath) return arg.filePath.hidden;
},
create: (hidden) => ({ filePath: { hidden } }),

View file

@ -1,6 +1,6 @@
import { Filter, useLibraryMutation, useLibraryQuery } from '@sd/client';
import { getKey, selectFilterOption, useSearchStore } from './store';
import { getKey, useSearchStore } from './store';
export const useSavedSearches = () => {
const searchStore = useSearchStore();
@ -16,7 +16,6 @@ export const useSavedSearches = () => {
loadSearch: (id: number) => {
const search = searches?.find((search) => search.id === id);
if (search) {
searchStore.selectedFilters.clear();
// TODO
search.filters?.forEach(({ filter_type, name, value, icon }) => {
// const filter: Filter = {
@ -35,7 +34,6 @@ export const useSavedSearches = () => {
removeSavedSearch.mutate(id);
},
saveSearch: (name: string) => {
const filters = Array.from(searchStore.selectedFilters.values());
// createSavedSearch.mutate({
// name,
// description: '',

View file

@ -1,9 +1,9 @@
import { CaretRight, FunnelSimple, Icon, Plus } from '@phosphor-icons/react';
import { IconTypes } from '@sd/assets/util';
import clsx from 'clsx';
import { PropsWithChildren, useDeferredValue, useMemo, useState } from 'react';
import { snapshot } from 'valtio';
import { memo, PropsWithChildren, useDeferredValue, useState } from 'react';
import { Button, ContextMenuDivItem, DropdownMenu, Input, RadixCheckbox, tw } from '@sd/ui';
import { useTopBarContext } from '~/app/$libraryId/TopBar/Layout';
import { useKeybind } from '~/hooks';
import { AppliedOptions } from './AppliedFilters';
@ -11,7 +11,6 @@ import { filterRegistry } from './Filters';
import { useSavedSearches } from './SavedSearches';
import {
getSearchStore,
selectFilterOption,
updateFilterArgs,
useRegisterSearchFilterOptions,
useSearchRegisteredFilters,
@ -78,9 +77,9 @@ const SearchOptions = () => {
const searchState = useSearchStore();
const [newFilterName, setNewFilterName] = useState('');
const [searchValue, setSearchValue] = useState('');
const [_search, setSearch] = useState('');
const deferredSearchValue = useDeferredValue(searchValue);
const search = useDeferredValue(_search);
useKeybind(['Escape'], () => {
getSearchStore().isSearching = false;
@ -88,18 +87,12 @@ const SearchOptions = () => {
const savedSearches = useSavedSearches();
const filtersWithOptions = filterRegistry.map((filter) => {
const options = filter
.useOptions({ search: deferredSearchValue })
.map((o) => ({ ...o, type: filter.name }));
for (const filter of filterRegistry) {
const options = filter.useOptions({ search }).map((o) => ({ ...o, type: filter.name }));
// eslint-disable-next-line react-hooks/rules-of-hooks
useRegisterSearchFilterOptions(filter, options);
return [filter, options] as const;
});
const searchResults = useSearchRegisteredFilters(deferredSearchValue);
}
return (
<div
@ -129,8 +122,8 @@ const SearchOptions = () => {
}
>
<Input
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
value={_search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
autoComplete="off"
autoCorrect="off"
@ -138,76 +131,24 @@ const SearchOptions = () => {
placeholder="Filter..."
/>
<Separator />
{searchValue
? searchResults.map((option) => {
const filter = filterRegistry.find((f) => f.name === option.type);
if (!filter) return;
return (
<SearchOptionItem
selected={searchState.filterArgsKeys.has(option.key)}
setSelected={(value) => {
const searchStore = getSearchStore();
updateFilterArgs((args) => {
if (searchStore.fixedFilterKeys.has(option.key))
return;
let rawArg = args.find((arg) => filter.find(arg));
if (!rawArg) {
rawArg = filter.create(option.value);
args.push(rawArg);
}
const rawArgIndex = args.findIndex((arg) =>
filter.find(arg)
)!;
const arg = filter.find(rawArg)! as any;
if (!filter.getCondition?.(arg))
filter.setCondition(
arg,
Object.keys(filter.conditions)[0] as any
);
if (value) filter.applyAdd(arg, option);
else {
if (!filter.applyRemove(arg, option))
args.splice(rawArgIndex);
}
});
}}
key={option.key}
>
<div className="mr-4 flex flex-row items-center gap-1.5">
<RenderIcon icon={option.icon} />
<span className="text-ink-dull">{filter.name}</span>
<CaretRight
weight="bold"
className="text-ink-dull/70"
/>
<RenderIcon icon={option.icon} />
{option.name}
</div>
</SearchOptionItem>
);
})
: filtersWithOptions.map(([filter, options]) => (
<filter.Render
key={filter.name}
filter={filter as any}
options={options}
/>
))}
{_search === '' ? (
filterRegistry.map((filter) => (
<filter.Render
key={filter.name}
filter={filter as any}
options={searchState.filterOptions.get(filter.name)!}
/>
))
) : (
<SearchResults search={search} />
)}
</DropdownMenu.Root>
</OptionContainer>
{/* We're keeping AppliedOptions to the right of the "Add Filter" button because its not worth rebuilding the dropdown with custom logic to lock the position as the trigger will move if to the right of the applied options and that is bad UX. */}
<AppliedOptions />
<div className="grow" />
{searchState.selectedFilters.size > 0 && (
{searchState.filterArgs.length > 0 && (
<DropdownMenu.Root
className={clsx(MENU_STYLES)}
trigger={
@ -252,3 +193,65 @@ const SearchOptions = () => {
};
export default SearchOptions;
const SearchResults = memo(({ search }: { search: string }) => {
const { fixedArgsKeys } = useTopBarContext();
const searchState = useSearchStore();
const searchResults = useSearchRegisteredFilters(search);
return (
<>
{searchResults.map((option) => {
const filter = filterRegistry.find((f) => f.name === option.type);
if (!filter) return;
return (
<SearchOptionItem
selected={
searchState.filterArgsKeys.has(option.key) ||
fixedArgsKeys?.has(option.key)
}
setSelected={(value) => {
updateFilterArgs((args) => {
if (fixedArgsKeys?.has(option.key)) return args;
let rawArg = args.find((arg) => filter.extract(arg));
if (!rawArg) {
rawArg = filter.create(option.value);
args.push(rawArg);
}
const rawArgIndex = args.findIndex((arg) => filter.extract(arg))!;
const arg = filter.extract(rawArg)! as any;
if (!filter.getCondition?.(arg))
filter.setCondition(
arg,
Object.keys(filter.conditions)[0] as any as never
);
if (value) filter.applyAdd(arg, option);
else {
if (!filter.applyRemove(arg, option)) args.splice(rawArgIndex);
}
return args;
});
}}
key={option.key}
>
<div className="mr-4 flex flex-row items-center gap-1.5">
<RenderIcon icon={option.icon} />
<span className="text-ink-dull">{filter.name}</span>
<CaretRight weight="bold" className="text-ink-dull/70" />
<RenderIcon icon={option.icon} />
{option.name}
</div>
</SearchOptionItem>
);
})}
</>
);
});

View file

@ -1,10 +1,11 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { Icon } from '@phosphor-icons/react';
import { produce } from 'immer';
import { useEffect, useMemo } from 'react';
import { useEffect, useLayoutEffect, useMemo } from 'react';
import { proxy, ref, useSnapshot } from 'valtio';
import { proxyMap } from 'valtio/utils';
import { SearchFilterArgs } from '@sd/client';
import { useTopBarContext } from '~/app/$libraryId/TopBar/Layout';
import { filterRegistry, FilterType, RenderSearchFilter } from './Filters';
import { FilterTypeCondition } from './util';
@ -42,45 +43,26 @@ const searchStore = proxy({
searchQuery: null as string | null,
filterArgs: ref([] as SearchFilterArgs[]),
filterArgsKeys: ref(new Set<string>()),
fixedFilters: ref([] as SearchFilterArgs[]),
fixedFilterKeys: ref(new Set<string>()),
filterOptions: ref(new Map<string, FilterOption[]>()),
// we register filters so we can search them
registeredFilters: proxyMap() as Map<string, FilterOptionWithType>,
// selected filters are applied to the search args
selectedFilters: proxyMap() as Map<string, SetFilter>
registeredFilters: proxyMap() as Map<string, FilterOptionWithType>
});
export function useSearchFilters<T extends SearchType>(
_searchType: T,
fixedArgs: SearchFilterArgs[]
) {
const state = useSearchStore();
const { filterArgs } = useSearchStore();
const topBar = useTopBarContext();
const fixedArgsAsOptions = useMemo(() => {
return argsToOptions(fixedArgs);
// don't want the search bar to pop in after the top bar has loaded!
useLayoutEffect(() => {
topBar.setFixedArgs(fixedArgs);
}, [fixedArgs]);
useEffect(() => {
resetSearchStore();
return fixedArgs;
const keys = new Set(
fixedArgsAsOptions.map(({ arg, filter }) => {
return getKey({
type: filter.name,
name: arg.name,
value: arg.value
});
})
);
searchStore.fixedFilters = ref(fixedArgs);
searchStore.fixedFilterKeys = ref(keys);
searchStore.filterArgs = ref(fixedArgs);
searchStore.filterArgsKeys = ref(keys);
}, [fixedArgsAsOptions, fixedArgs]);
return [...state.filterArgs];
// return [...state.filterArgs];
}
// this makes the filter unique and easily searchable using .includes
@ -98,11 +80,6 @@ export const useRegisterSearchFilterOptions = (
if (options) {
searchStore.filterOptions.set(filter.name, options);
searchStore.filterOptions = ref(new Map(searchStore.filterOptions));
return () => {
searchStore.filterOptions.delete(filter.name);
searchStore.filterOptions = ref(new Map(searchStore.filterOptions));
};
}
},
options?.map(getKey) ?? []
@ -126,11 +103,13 @@ export const useRegisterSearchFilterOptions = (
}, options.map(getKey));
};
export function argsToOptions(args: SearchFilterArgs[]) {
export function argsToOptions(args: SearchFilterArgs[], options: Map<string, FilterOption[]>) {
return args.flatMap((fixedArg) => {
const filter = filterRegistry.find((f) => f.find(fixedArg))!;
const filter = filterRegistry.find((f) => f.extract(fixedArg))!;
return filter.argsToOptions(filter.find(fixedArg) as any).map((arg) => ({ arg, filter }));
return filter
.argsToOptions(filter.extract(fixedArg) as any, options)
.map((arg) => ({ arg, filter }));
});
}
@ -138,36 +117,18 @@ export function updateFilterArgs(fn: (args: SearchFilterArgs[]) => SearchFilterA
searchStore.filterArgs = ref(produce(searchStore.filterArgs, fn));
searchStore.filterArgsKeys = ref(
new Set(
argsToOptions(searchStore.filterArgs).map(({ arg, filter }) =>
getKey({
type: filter.name,
name: arg.name,
value: arg.value
})
argsToOptions(searchStore.filterArgs, searchStore.filterOptions).map(
({ arg, filter }) =>
getKey({
type: filter.name,
name: arg.name,
value: arg.value
})
)
)
);
}
// this is used to render the applied filters
export const getSelectedFiltersGrouped = (): GroupedFilters[] => {
const groupedFilters: GroupedFilters[] = [];
searchStore.selectedFilters.forEach((filter) => {
const group = groupedFilters.find((group) => group.type === filter.type);
if (group) {
group.filters.push(filter);
} else {
groupedFilters.push({
type: filter.type,
filters: [filter]
});
}
});
return groupedFilters;
};
export const useSearchRegisteredFilters = (query: string) => {
const { registeredFilters } = useSearchStore();
@ -184,7 +145,7 @@ export const useSearchRegisteredFilters = (query: string) => {
export const resetSearchStore = () => {
searchStore.searchQuery = null;
searchStore.selectedFilters.clear();
searchStore.filterArgs = ref([]);
};
export const useSearchStore = () => useSnapshot(searchStore);

View file

@ -1,24 +1,46 @@
import { createContext, useContext, useState } from 'react';
import { createContext, useContext, useMemo, useState } from 'react';
import { Outlet } from 'react-router';
import { SearchFilterArgs } from '@sd/client';
import TopBar from '.';
import { argsToOptions, getKey, useSearchStore } from '../Explorer/View/SearchOptions/store';
interface TopBarContext {
left: HTMLDivElement | null;
right: HTMLDivElement | null;
setNoSearch: (value: boolean) => void;
const TopBarContext = createContext<ReturnType<typeof useContextValue> | null>(null);
function useContextValue(props: { left: HTMLDivElement | null; right: HTMLDivElement | null }) {
const [fixedArgs, setFixedArgs] = useState<SearchFilterArgs[] | null>(null);
const searchState = useSearchStore();
const fixedArgsAsOptions = useMemo(() => {
return fixedArgs ? argsToOptions(fixedArgs, searchState.filterOptions) : null;
}, [fixedArgs, searchState.filterOptions]);
const fixedArgsKeys = useMemo(() => {
const keys = fixedArgsAsOptions
? new Set(
fixedArgsAsOptions?.map(({ arg, filter }) => {
return getKey({
type: filter.name,
name: arg.name,
value: arg.value
});
})
)
: null;
return keys;
}, [fixedArgsAsOptions]);
return { ...props, setFixedArgs, fixedArgs, fixedArgsKeys };
}
const TopBarContext = createContext<TopBarContext | null>(null);
export const Component = () => {
const [left, setLeft] = useState<HTMLDivElement | null>(null);
const [right, setRight] = useState<HTMLDivElement | null>(null);
const [noSearch, setNoSearch] = useState(false);
return (
<TopBarContext.Provider value={{ left, right, setNoSearch }}>
<TopBar leftRef={setLeft} rightRef={setRight} noSearch={noSearch} />
<TopBarContext.Provider value={useContextValue({ left, right })}>
<TopBar leftRef={setLeft} rightRef={setRight} />
<Outlet />
</TopBarContext.Provider>
);

View file

@ -1,4 +1,4 @@
import { useEffect, type ReactNode } from 'react';
import { type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useTopBarContext } from './Layout';
@ -6,15 +6,10 @@ import { useTopBarContext } from './Layout';
interface Props {
left?: ReactNode;
right?: ReactNode;
noSearch?: boolean;
}
export const TopBarPortal = ({ left, right, noSearch }: Props) => {
export const TopBarPortal = ({ left, right }: Props) => {
const ctx = useTopBarContext();
useEffect(() => {
ctx.setNoSearch(noSearch ?? false);
}, [ctx, noSearch]);
return (
<>
{left && ctx.left && createPortal(left, ctx.left)}

View file

@ -1,7 +1,6 @@
import clsx from 'clsx';
import { useCallback, useEffect, useRef, useState, useTransition } from 'react';
import { useLocation, useNavigate, useResolvedPath } from 'react-router';
import { createSearchParams } from 'react-router-dom';
import { useDebouncedCallback } from 'use-debounce';
import { Input, ModifierKeys, Shortcut } from '@sd/ui';
import { SearchParamsSchema } from '~/app/route-schemas';
@ -14,7 +13,6 @@ export default () => {
const searchRef = useRef<HTMLInputElement>(null);
const [searchParams, setSearchParams] = useZodSearchParams(SearchParamsSchema);
const navigate = useNavigate();
const location = useLocation();
const searchStore = useSearchStore();

View file

@ -3,6 +3,7 @@ import type { Ref } from 'react';
import { useOperatingSystem, useShowControls } from '~/hooks';
import { useExplorerStore } from '../Explorer/store';
import { useTopBarContext } from './Layout';
import { NavigationButtons } from './NavigationButtons';
import SearchBar from './SearchBar';
@ -11,7 +12,6 @@ export const TOP_BAR_HEIGHT = 46;
interface Props {
leftRef?: Ref<HTMLDivElement>;
rightRef?: Ref<HTMLDivElement>;
noSearch?: boolean;
}
const TopBar = (props: Props) => {
@ -19,6 +19,8 @@ const TopBar = (props: Props) => {
const { isDragging } = useExplorerStore();
const os = useOperatingSystem();
const ctx = useTopBarContext();
return (
<div
data-tauri-drag-region={os === 'macOS'}
@ -38,9 +40,9 @@ const TopBar = (props: Props) => {
<div ref={props.leftRef} className="overflow-hidden" />
</div>
{!props.noSearch && <SearchBar />}
{ctx.fixedArgs && <SearchBar />}
<div ref={props.rightRef} className={clsx(!props.noSearch && 'flex-1')} />
<div ref={props.rightRef} className={clsx(ctx.fixedArgs && 'flex-1')} />
</div>
);
};

View file

@ -221,7 +221,6 @@ const EphemeralExplorer = memo((props: { args: PathParams }) => {
</Tooltip>
}
right={<DefaultTopBarOptions />}
noSearch={true}
/>
<Explorer />
</ExplorerContextProvider>

View file

@ -25,8 +25,8 @@ const explorerRoutes: RouteObject[] = [
{ path: 'location/:id', lazy: () => import('./location/$id') },
{ path: 'node/:id', lazy: () => import('./node/$id') },
{ path: 'tag/:id', lazy: () => import('./tag/$id') },
{ path: 'network/:id', lazy: () => import('./network') },
{ path: 'search/:id', lazy: () => import('./search') }
{ path: 'network/:id', lazy: () => import('./network') }
// { path: 'search/:id', lazy: () => import('./search') }
];
// Routes that should render with the top bar - pretty much everything except

View file

@ -58,7 +58,6 @@ const LocationExplorer = ({ location, path }: { location: Location; path?: strin
const pub_id = location?.pub_id;
if (!pub_id) return false;
return onlineLocations.some((l) => arraysEqual(pub_id, l));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location?.pub_id, onlineLocations]);
const preferences = useLibraryQuery(['preferences.get']);
@ -203,7 +202,9 @@ const useItems = ({
[location.id, explorerSettings.layoutMode]
);
const filters = useSearchFilters('paths', fixedFilters);
const baseFilters = useSearchFilters('paths', fixedFilters);
const filters = [...baseFilters];
filters.push({
filePath: {

View file

@ -55,7 +55,6 @@ const Network = memo((props: { args: PathParams }) => {
</div>
}
right={<DefaultTopBarOptions />}
noSearch={true}
/>
<Explorer
emptyNotice={

View file

@ -1,114 +1,114 @@
import { MagnifyingGlass } from '@phosphor-icons/react';
import { getIcon, iconNames } from '@sd/assets/util';
import { Suspense, useDeferredValue, useEffect, useMemo } from 'react';
import { FilePathFilterArgs, useLibraryContext } from '@sd/client';
import { SearchIdParamsSchema, SearchParams, SearchParamsSchema } from '~/app/route-schemas';
import { useZodRouteParams, useZodSearchParams } from '~/hooks';
// import { MagnifyingGlass } from '@phosphor-icons/react';
// import { getIcon, iconNames } from '@sd/assets/util';
// import { Suspense, useDeferredValue, useEffect, useMemo } from 'react';
// import { FilePathFilterArgs, useLibraryContext } from '@sd/client';
// import { SearchIdParamsSchema, SearchParams, SearchParamsSchema } from '~/app/route-schemas';
// import { useZodRouteParams, useZodSearchParams } from '~/hooks';
import Explorer from './Explorer';
import { ExplorerContextProvider } from './Explorer/Context';
import { usePathsInfiniteQuery } from './Explorer/queries';
import { createDefaultExplorerSettings, filePathOrderingKeysSchema } from './Explorer/store';
import { DefaultTopBarOptions } from './Explorer/TopBarOptions';
import { useExplorer, useExplorerSettings } from './Explorer/useExplorer';
import { EmptyNotice } from './Explorer/View';
import { useSavedSearches } from './Explorer/View/SearchOptions/SavedSearches';
import { getSearchStore, useSearchFilters } from './Explorer/View/SearchOptions/store';
import { TopBarPortal } from './TopBar/Portal';
// import Explorer from './Explorer';
// import { ExplorerContextProvider } from './Explorer/Context';
// import { usePathsInfiniteQuery } from './Explorer/queries';
// import { createDefaultExplorerSettings, filePathOrderingKeysSchema } from './Explorer/store';
// import { DefaultTopBarOptions } from './Explorer/TopBarOptions';
// import { useExplorer, useExplorerSettings } from './Explorer/useExplorer';
// import { EmptyNotice } from './Explorer/View';
// import { useSavedSearches } from './Explorer/View/SearchOptions/SavedSearches';
// import { getSearchStore, useSearchFilters } from './Explorer/View/SearchOptions/store';
// import { TopBarPortal } from './TopBar/Portal';
const useItems = (searchParams: SearchParams, id: number) => {
const { library } = useLibraryContext();
const explorerSettings = useExplorerSettings({
settings: createDefaultExplorerSettings({
order: {
field: 'name',
value: 'Asc'
}
}),
orderingKeys: filePathOrderingKeysSchema
});
// const useItems = (searchParams: SearchParams, id: number) => {
// const { library } = useLibraryContext();
// const explorerSettings = useExplorerSettings({
// settings: createDefaultExplorerSettings({
// order: {
// field: 'name',
// value: 'Asc'
// }
// }),
// orderingKeys: filePathOrderingKeysSchema
// });
const searchFilters = useSearchFilters('paths', []);
// const searchFilters = useSearchFilters('paths', []);
const savedSearches = useSavedSearches();
// const savedSearches = useSavedSearches();
useEffect(() => {
if (id) {
getSearchStore().isSearching = true;
savedSearches.loadSearch(id);
}
}, [id]);
// useEffect(() => {
// if (id) {
// getSearchStore().isSearching = true;
// savedSearches.loadSearch(id);
// }
// }, [id]);
const take = 50; // Specify the number of items to fetch per query
// const take = 50; // Specify the number of items to fetch per query
const query = usePathsInfiniteQuery({
arg: { filter: searchFilters, take },
library,
// @ts-ignore todo: fix
settings: explorerSettings,
suspense: true
});
// const query = usePathsInfiniteQuery({
// arg: { filter: searchFilters, take },
// library,
// // @ts-ignore todo: fix
// settings: explorerSettings,
// suspense: true
// });
const items = useMemo(() => query.data?.pages.flatMap((d) => d.items) ?? [], [query.data]);
// const items = useMemo(() => query.data?.pages.flatMap((d) => d.items) ?? [], [query.data]);
return { items, query };
};
// return { items, query };
// };
const SearchExplorer = ({ id, searchParams }: { id: number; searchParams: SearchParams }) => {
const { items, query } = useItems(searchParams, id);
// const SearchExplorer = ({ id, searchParams }: { id: number; searchParams: SearchParams }) => {
// const { items, query } = useItems(searchParams, id);
const explorerSettings = useExplorerSettings({
settings: createDefaultExplorerSettings({
order: {
field: 'name',
value: 'Asc'
}
}),
orderingKeys: filePathOrderingKeysSchema
});
// const explorerSettings = useExplorerSettings({
// settings: createDefaultExplorerSettings({
// order: {
// field: 'name',
// value: 'Asc'
// }
// }),
// orderingKeys: filePathOrderingKeysSchema
// });
const explorer = useExplorer({
items,
settings: explorerSettings
});
// const explorer = useExplorer({
// items,
// settings: explorerSettings
// });
return (
<ExplorerContextProvider explorer={explorer}>
<TopBarPortal
left={
<div className="flex flex-row items-center gap-2">
<MagnifyingGlass className="text-ink-dull" weight="bold" size={18} />
<span className="truncate text-sm font-medium">Search</span>
</div>
}
right={<DefaultTopBarOptions />}
/>
<Explorer
showFilterBar
emptyNotice={
<EmptyNotice
icon={<img className="h-32 w-32" src={getIcon(iconNames.FolderNoSpace)} />}
message={
searchParams.search
? `No results found for "${searchParams.search}"`
: 'Search for files...'
}
/>
}
/>
</ExplorerContextProvider>
);
};
// return (
// <ExplorerContextProvider explorer={explorer}>
// <TopBarPortal
// left={
// <div className="flex flex-row items-center gap-2">
// <MagnifyingGlass className="text-ink-dull" weight="bold" size={18} />
// <span className="truncate text-sm font-medium">Search</span>
// </div>
// }
// right={<DefaultTopBarOptions />}
// />
// <Explorer
// showFilterBar
// emptyNotice={
// <EmptyNotice
// icon={<img className="h-32 w-32" src={getIcon(iconNames.FolderNoSpace)} />}
// message={
// searchParams.search
// ? `No results found for "${searchParams.search}"`
// : 'Search for files...'
// }
// />
// }
// />
// </ExplorerContextProvider>
// );
// };
export const Component = () => {
const [searchParams] = useZodSearchParams(SearchParamsSchema);
const { id } = useZodRouteParams(SearchIdParamsSchema);
// export const Component = () => {
// const [searchParams] = useZodSearchParams(SearchParamsSchema);
// const { id } = useZodRouteParams(SearchIdParamsSchema);
const search = useDeferredValue(searchParams);
// const search = useDeferredValue(searchParams);
return (
<Suspense>
<SearchExplorer id={id} searchParams={search} />
</Suspense>
);
};
// return (
// <Suspense>
// <SearchExplorer id={id} searchParams={search} />
// </Suspense>
// );
// };