broken fixed filters but working inNotIn filters

This commit is contained in:
Brendan Allan 2023-11-03 18:08:44 +08:00
parent f3d2a4feb6
commit ffe3b2e9df
7 changed files with 280 additions and 115 deletions

View file

@ -146,7 +146,7 @@ pub fn mount() -> AlphaRouter<Ctx> {
#[specta(optional)]
order_and_pagination: Option<file_path::OrderAndPagination>,
#[serde(default)]
filter: Vec<SearchFilterArgs>,
filters: Vec<SearchFilterArgs>,
#[serde(default = "default_group_directories")]
group_directories: bool,
}
@ -160,7 +160,7 @@ pub fn mount() -> AlphaRouter<Ctx> {
FilePathSearchArgs {
take,
order_and_pagination,
filter,
filters,
group_directories,
}| async move {
let Library { db, .. } = library.as_ref();
@ -168,7 +168,7 @@ pub fn mount() -> AlphaRouter<Ctx> {
let mut query = db.file_path().find_many({
let mut params = Vec::new();
for filter in filter {
for filter in filters {
params.extend(filter.into_file_path_params(db).await?);
}
@ -230,16 +230,24 @@ pub fn mount() -> AlphaRouter<Ctx> {
#[specta(inline)]
struct Args {
#[specta(default)]
filter: SearchFilterArgs,
filters: Vec<SearchFilterArgs>,
}
R.with2(library())
.query(|(_, library), Args { filter }| async move {
.query(|(_, library), Args { filters }| async move {
let Library { db, .. } = library.as_ref();
Ok(db
.file_path()
.count(filter.into_file_path_params(db).await?)
.count({
let mut params = Vec::new();
for filter in filters {
params.extend(filter.into_file_path_params(db).await?);
}
params
})
.exec()
.await? as u32)
})
@ -252,7 +260,7 @@ pub fn mount() -> AlphaRouter<Ctx> {
#[specta(optional)]
order_and_pagination: Option<object::OrderAndPagination>,
#[serde(default)]
filter: Vec<SearchFilterArgs>,
filters: Vec<SearchFilterArgs>,
}
R.with2(library()).query(
@ -260,7 +268,7 @@ pub fn mount() -> AlphaRouter<Ctx> {
ObjectSearchArgs {
take,
order_and_pagination,
filter,
filters,
}| async move {
let Library { db, .. } = library.as_ref();
@ -271,7 +279,7 @@ pub fn mount() -> AlphaRouter<Ctx> {
.find_many({
let mut params = Vec::new();
for filter in filter {
for filter in filters {
params.extend(filter.into_object_params(db).await?);
}
@ -335,11 +343,11 @@ pub fn mount() -> AlphaRouter<Ctx> {
#[specta(inline)]
struct Args {
#[serde(default)]
filter: Vec<SearchFilterArgs>,
filters: Vec<SearchFilterArgs>,
}
R.with2(library())
.query(|(_, library), Args { filter }| async move {
.query(|(_, library), Args { filters }| async move {
let Library { db, .. } = library.as_ref();
Ok(db
@ -347,7 +355,7 @@ pub fn mount() -> AlphaRouter<Ctx> {
.count({
let mut params = Vec::new();
for filter in filter {
for filter in filters {
params.extend(filter.into_object_params(db).await?);
}

View file

@ -1,10 +1,13 @@
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 { filterRegistry } from './Filters';
import {
deselectFilterOption,
getKey,
getSearchStore,
getSelectedFiltersGrouped,
useSearchStore
@ -23,7 +26,7 @@ const CloseTab = forwardRef<HTMLDivElement, { onClick: () => void }>(({ onClick
return (
<div
ref={ref}
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"
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"
onClick={onClick}
>
<RenderIcon className="h-3 w-3" icon={X} />
@ -32,83 +35,103 @@ const CloseTab = forwardRef<HTMLDivElement, { onClick: () => void }>(({ onClick
});
export const AppliedOptions = () => {
const searchStore = useSearchStore();
const searchState = useSearchStore();
// turn the above into use memo
const groupedFilters = useMemo(
() => getSelectedFiltersGrouped(),
// eslint-disable-next-line react-hooks/exhaustive-deps
[searchStore.selectedFilters.size]
[searchState.selectedFilters.size]
);
return (
<div className="flex flex-row gap-2">
{searchStore.searchQuery && (
{searchState.searchQuery && (
<FilterContainer>
<StaticSection>
<RenderIcon className="h-4 w-4" icon={MagnifyingGlass} />
<FilterText>{searchStore.searchQuery}</FilterText>
<FilterText>{searchState.searchQuery}</FilterText>
</StaticSection>
<CloseTab onClick={() => (getSearchStore().searchQuery = null)} />
</FilterContainer>
)}
{groupedFilters?.map((group) => {
const showRemoveButton = group.filters.some((filter) => filter.canBeRemoved);
const meta = filterRegistry.find((f) => f.name === group.type);
{searchState.filterArgs.map((arg, index) => {
const filter = filterRegistry.find((f) => f.find(arg));
if (!filter) return;
const options = searchState.filterOptions.get(filter);
if (!options) return;
const isFixed = searchState.fixedFilters.at(index) !== undefined;
const activeOptions =
filter.getActiveOptions &&
filter.getActiveOptions(filter.find(arg)! as any, options);
return (
<FilterContainer key={group.type}>
<FilterContainer key={`${filter.name}-${index}`}>
<StaticSection>
<RenderIcon className="h-4 w-4" icon={meta?.icon} />
<FilterText>{meta?.name}</FilterText>
<RenderIcon className="h-4 w-4" icon={filter.icon} />
<FilterText>{filter.name}</FilterText>
</StaticSection>
{meta?.conditions && (
<InteractiveSection className="border-l">
{/* {Object.values(meta.conditions).map((condition) => (
<div key={condition}>{condition}</div>
))} */}
is
</InteractiveSection>
)}
<InteractiveSection className="gap-1 border-l border-app-darkerBox/70 py-0.5 pl-1.5 pr-2 text-sm">
{group.filters.length > 1 && (
<div
className="relative"
style={{ width: `${group.filters.length * 12}px` }}
>
{group.filters.map((filter, index) => (
<div
key={index}
className="absolute -top-2 left-0"
style={{
zIndex: group.filters.length - index,
left: `${index * 10}px`
}}
>
<RenderIcon className="h-4 w-4" icon={filter.icon} />
</div>
))}
</div>
)}
{group.filters.length === 1 && (
<RenderIcon className="h-4 w-4" icon={group.filters[0]?.icon} />
)}
{group.filters.length > 1
? `${group.filters.length} ${pluralize(meta?.name)}`
: group.filters[0]?.name}
<InteractiveSection className="border-l">
{/* {Object.entries(filter.conditions).map(([value, displayName]) => (
<div key={value}>{displayName}</div>
))} */}
in
</InteractiveSection>
{showRemoveButton && (
<InteractiveSection className="border-app-darkerBox/70 gap-1 border-l py-0.5 pl-1.5 pr-2 text-sm">
{activeOptions && (
<>
{activeOptions.length === 1 ? (
<RenderIcon
className="h-4 w-4"
icon={activeOptions[0]!.icon}
/>
) : (
<div
className="relative"
style={{ width: `${activeOptions.length * 12}px` }}
>
{activeOptions.map((option, index) => (
<div
key={index}
className="absolute -top-2 left-0"
style={{
zIndex: activeOptions.length - index,
left: `${index * 10}px`
}}
>
<RenderIcon
className="h-4 w-4"
icon={option.icon}
/>
</div>
))}
</div>
)}
{activeOptions.length > 1
? `${activeOptions.length} ${pluralize(filter.name)}`
: activeOptions[0]?.name}
</>
)}
{/* {group.filters.length > 1
? `${group.filters.length} ${pluralize(meta?.name)}`
: group.filters[0]?.name} */}
</InteractiveSection>
{!isFixed && (
<CloseTab
onClick={() =>
group.filters.forEach((filter) => {
if (filter.canBeRemoved) {
deselectFilterOption(filter);
}
})
}
onClick={() => {
getSearchStore().filterArgs = ref(
produce(getSearchStore().filterArgs, (args) => {
args.splice(index);
return args;
})
);
}}
/>
)}
</FilterContainer>
@ -122,3 +145,67 @@ function pluralize(word?: string) {
if (word?.endsWith('s')) return word;
return `${word}s`;
}
// {
// groupedFilters?.map((group) => {
// const showRemoveButton = group.filters.some((filter) => filter.canBeRemoved);
// const meta = filterRegistry.find((f) => f.name === group.type);
// return (
// <FilterContainer key={group.type}>
// <StaticSection>
// <RenderIcon className="h-4 w-4" icon={meta?.icon} />
// <FilterText>{meta?.name}</FilterText>
// </StaticSection>
// {meta?.conditions && (
// <InteractiveSection className="border-l">
// {/* {Object.values(meta.conditions).map((condition) => (
// <div key={condition}>{condition}</div>
// ))} */}
// is
// </InteractiveSection>
// )}
// <InteractiveSection className="border-app-darkerBox/70 gap-1 border-l py-0.5 pl-1.5 pr-2 text-sm">
// {group.filters.length > 1 && (
// <div
// className="relative"
// style={{ width: `${group.filters.length * 12}px` }}
// >
// {group.filters.map((filter, index) => (
// <div
// key={index}
// className="absolute -top-2 left-0"
// style={{
// zIndex: group.filters.length - index,
// left: `${index * 10}px`
// }}
// >
// <RenderIcon className="h-4 w-4" icon={filter.icon} />
// </div>
// ))}
// </div>
// )}
// {group.filters.length === 1 && (
// <RenderIcon className="h-4 w-4" icon={group.filters[0]?.icon} />
// )}
// {group.filters.length > 1
// ? `${group.filters.length} ${pluralize(meta?.name)}`
// : group.filters[0]?.name}
// </InteractiveSection>
// {showRemoveButton && (
// <CloseTab
// onClick={() =>
// group.filters.forEach((filter) => {
// if (filter.canBeRemoved) {
// deselectFilterOption(filter);
// }
// })
// }
// />
// )}
// </FilterContainer>
// );
// });
// }

View file

@ -1,6 +1,7 @@
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';
@ -8,7 +9,7 @@ import { SearchOptionItem, SearchOptionSubMenu } from '.';
import {
AllKeys,
deselectFilterOption,
FilterArgs,
FilterOption,
getSearchStore,
selectFilterOption,
SetFilter,
@ -30,9 +31,10 @@ interface SearchFilterCRUD<
> extends SearchFilter<TConditions> {
getCondition: (args: T) => keyof TConditions | undefined;
setCondition: (args: T, condition: keyof TConditions) => void;
getOptionActive: (args: T, option: FilterArgs) => boolean;
applyAdd: (args: T, option: FilterArgs) => void;
applyRemove: (args: T, option: FilterArgs) => T | undefined;
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;
find: (arg: SearchFilterArgs) => T | undefined;
create: () => SearchFilterArgs;
}
@ -44,10 +46,10 @@ export interface RenderSearchFilter<
// Render is responsible for fetching the filter options and rendering them
Render: (props: {
filter: SearchFilterCRUD<TConditions>;
options: (FilterArgs & { type: string })[];
options: (FilterOption & { type: string })[];
}) => JSX.Element;
// Apply is responsible for applying the filter to the search args
useOptions: (props: { search: string }) => FilterArgs[];
useOptions: (props: { search: string }) => FilterOption[];
}
const FilterOptionList = ({
@ -55,26 +57,47 @@ const FilterOptionList = ({
options
}: {
filter: SearchFilterCRUD;
options: FilterArgs[];
options: FilterOption[];
}) => {
const store = useSearchStore();
const arg = store.filterArgs.find(filter.find);
const specificArg = arg ? filter.find(arg) : undefined;
return (
<SearchOptionSubMenu name={filter.name} icon={filter.icon}>
{options?.map((option) => (
<SearchOptionItem
selected={filter.getOptionActive?.(store.filterArgs, option) ?? false}
selected={
(specificArg && filter.getOptionActive?.(specificArg, option)) ?? false
}
setSelected={(value) => {
getSearchStore().filterArgs = produce(store.filterArgs, (args) => {
if (!filter.getCondition?.(args))
filter.setCondition(args, Object.keys(filter.conditions)[0]!);
getSearchStore().filterArgs = ref(
produce(store.filterArgs, (args) => {
let rawArg = args.find((arg) => filter.find(arg));
if (value) filter.applyAdd(args, option);
else filter.applyRemove(args, option);
});
if (!rawArg) {
rawArg = filter.create();
args.push(rawArg);
}
if (value) selectFilterOption({ ...option, type: filter.name });
else deselectFilterOption({ ...option, type: filter.name });
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 filter.applyRemove(arg, option);
if (!filter.getActiveOptions?.(arg, options).length) {
args.splice(rawArgIndex);
}
})
);
console.log(snapshot(getSearchStore()).filterArgs);
}}
key={option.value}
icon={option.icon}
@ -110,7 +133,7 @@ function createFilter<TConditions extends FilterTypeCondition[keyof FilterTypeCo
return filter;
}
function createInOrNotInFilter<T>(
function createInOrNotInFilter<T extends string | number>(
filter: Omit<
ReturnType<typeof createFilter<any, InOrNotIn<T>>>,
| 'conditions'
@ -143,6 +166,14 @@ function createInOrNotInFilter<T>(
if ('in' in data) return data.in.includes(option.value);
else return data.notIn.includes(option.value);
},
getActiveOptions: (data, options) => {
let value: T[];
if ('in' in data) value = data.in;
else value = data.notIn;
return value.map((v) => options.find((o) => o.value === v)!).filter(Boolean);
},
applyAdd: (data, option) => {
if ('in' in data) data.in.push(option.value);
else data.notIn.push(option.value);

View file

@ -95,7 +95,7 @@ const SearchOptions = () => {
.map((o) => ({ ...o, type: filter.name }));
// eslint-disable-next-line react-hooks/rules-of-hooks
useRegisterSearchFilterOptions(options);
useRegisterSearchFilterOptions(filter, options);
return [filter, options] as const;
});
@ -108,7 +108,7 @@ const SearchOptions = () => {
onMouseLeave={() => {
getSearchStore().interactingWithSearchOptions = false;
}}
className="flex h-[45px] w-full flex-row items-center gap-4 border-b border-app-line/50 bg-app-darkerBox/90 px-4 backdrop-blur"
className="bg-app-darkerBox/90 flex h-[45px] w-full flex-row items-center gap-4 border-b border-app-line/50 px-4 backdrop-blur"
>
{/* <OptionContainer className="flex flex-row items-center">
<FilterContainer>
@ -168,7 +168,7 @@ const SearchOptions = () => {
: filtersWithOptions.map(([filter, options]) => (
<filter.Render
key={filter.name}
filter={filter}
filter={filter as any}
options={options}
/>
))}

View file

@ -4,20 +4,20 @@ import { proxy, ref, useSnapshot } from 'valtio';
import { proxyMap } from 'valtio/utils';
import { SearchFilterArgs } from '@sd/client';
import { filterRegistry, FilterType } from './Filters';
import { FilterType, RenderSearchFilter } from './Filters';
import { FilterTypeCondition } from './util';
export type SearchType = 'paths' | 'objects';
export type SearchScope = 'directory' | 'location' | 'device' | 'library';
export interface FilterArgs {
export interface FilterOption {
value: string | any;
name: string;
icon?: string; // "Folder" or "#efefef"
}
export interface Filter extends FilterArgs {
export interface Filter extends FilterOption {
type: FilterType;
}
@ -39,13 +39,15 @@ const searchStore = proxy({
searchType: 'paths' as SearchType,
searchQuery: null as string | null,
filterArgs: ref([] as SearchFilterArgs[]),
fixedFilters: ref([] as SearchFilterArgs[]),
filterOptions: ref(new Map<RenderSearchFilter, FilterOption[]>()),
// we register filters so we can search them
registeredFilters: proxyMap() as Map<string, Filter>,
// selected filters are applied to the search args
selectedFilters: proxyMap() as Map<string, SetFilter>
});
export const useSearchFilters = <T extends SearchType>(
export const useSearchFiltersOld = <T extends SearchType>(
searchType: T,
fixedFilters?: Filter[]
): SearchFilterArgs => {
@ -72,15 +74,45 @@ export const useSearchFilters = <T extends SearchType>(
return filters;
};
export function useSearchFilters<T extends SearchType>(
searchType: T,
fixedFilters: SearchFilterArgs[]
) {
const state = useSearchStore();
useEffect(() => {
resetSearchStore();
searchStore.fixedFilters = ref(fixedFilters);
searchStore.filterArgs = ref(fixedFilters);
}, [fixedFilters]);
return [...state.filterArgs];
}
// this makes the filter unique and easily searchable using .includes
export const getKey = (filter: Filter) => `${filter.type}-${filter.name}-${filter.value}`;
// this hook allows us to register filters to the search store
// and returns the filters with the correct type
export const useRegisterSearchFilterOptions = (filters?: (FilterArgs & { type: FilterType })[]) => {
export const useRegisterSearchFilterOptions = (
filter: RenderSearchFilter,
options: (FilterOption & { type: FilterType })[]
) => {
useEffect(
() => {
const keys = filters?.map((filter) => {
if (options) {
searchStore.filterOptions.set(filter, options);
return () => {
searchStore.filterOptions.delete(filter);
};
}
},
options?.map(getKey) ?? []
);
useEffect(
() => {
const keys = options?.map((filter) => {
const key = getKey(filter);
if (!searchStore.registeredFilters.has(key)) {
@ -95,7 +127,7 @@ export const useRegisterSearchFilterOptions = (filters?: (FilterArgs & { type: F
if (key) searchStore.registeredFilters.delete(key);
});
},
filters?.map(getKey) ?? []
options?.map(getKey) ?? []
);
};

View file

@ -192,33 +192,40 @@ const useItems = ({
const explorerSettings = settings.useSettingsSnapshot();
const filter = useSearchFilters(
'paths',
useMemo(
() => [
{ filePath: { locations: { in: [location.id] } } },
...(explorerSettings.layoutMode === 'media'
? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }]
: [])
],
[location.id, explorerSettings.layoutMode]
)
// useMemo lets us embrace immutability and use fixedFilters in useEffects!
const fixedFilters = useMemo(
() => [
{ filePath: { locations: { in: [location.id] } } },
...(explorerSettings.layoutMode === 'media'
? [{ object: { kind: { in: [ObjectKindEnum.Image, ObjectKindEnum.Video] } } }]
: [])
],
[location.id, explorerSettings.layoutMode]
);
(filter.filePath ??= {}).path = [location.id, path ?? ''];
const filters = useSearchFilters('paths', fixedFilters);
if (explorerSettings.layoutMode === 'media' && explorerSettings.mediaViewWithDescendants)
(filter.filePath ??= {}).withDescendants = true;
filters.push({
filePath: {
path: {
location_id: location.id,
path: path ?? '',
include_descendants:
explorerSettings.layoutMode === 'media' &&
explorerSettings.mediaViewWithDescendants
}
}
});
if (!explorerSettings.showHiddenFiles) (filter.filePath ??= {}).hidden = false;
if (!explorerSettings.showHiddenFiles) filters.push({ filePath: { hidden: false } });
const query = usePathsInfiniteQuery({
arg: { filter, take },
arg: { filters, take },
library,
settings
});
const count = useLibraryQuery(['search.pathsCount', { filter }], { enabled: query.isSuccess });
const count = useLibraryQuery(['search.pathsCount', { filters }], { enabled: query.isSuccess });
const items = useMemo(() => query.data?.pages.flatMap((d) => d.items) ?? null, [query.data]);

View file

@ -32,9 +32,9 @@ export type Procedures = {
{ key: "preferences.get", input: LibraryArgs<null>, result: LibraryPreferences } |
{ key: "search.ephemeralPaths", input: LibraryArgs<EphemeralPathSearchArgs>, result: NonIndexedFileSystemEntries } |
{ key: "search.objects", input: LibraryArgs<ObjectSearchArgs>, result: SearchData<ExplorerItem> } |
{ key: "search.objectsCount", input: LibraryArgs<{ filter?: SearchFilterArgs[] }>, result: number } |
{ key: "search.objectsCount", input: LibraryArgs<{ filters?: SearchFilterArgs[] }>, result: number } |
{ key: "search.paths", input: LibraryArgs<FilePathSearchArgs>, result: SearchData<ExplorerItem> } |
{ key: "search.pathsCount", input: LibraryArgs<{ filter?: SearchFilterArgs }>, result: number } |
{ key: "search.pathsCount", input: LibraryArgs<{ filters?: SearchFilterArgs[] }>, result: number } |
{ key: "search.saved.get", input: LibraryArgs<number>, result: SavedSearch | null } |
{ key: "search.saved.list", input: LibraryArgs<null>, result: SavedSearchResponse[] } |
{ key: "sync.messages", input: LibraryArgs<null>, result: CRDTOperation[] } |
@ -196,7 +196,7 @@ export type FilePathObjectCursor = { dateAccessed: CursorOrderItem<string> } | {
export type FilePathOrder = { field: "name"; value: SortOrder } | { field: "sizeInBytes"; value: SortOrder } | { field: "dateCreated"; value: SortOrder } | { field: "dateModified"; value: SortOrder } | { field: "dateIndexed"; value: SortOrder } | { field: "object"; value: ObjectOrder }
export type FilePathSearchArgs = { take?: number | null; orderAndPagination?: OrderAndPagination<number, FilePathOrder, FilePathCursor> | null; filter?: SearchFilterArgs[]; groupDirectories?: boolean }
export type FilePathSearchArgs = { take?: number | null; orderAndPagination?: OrderAndPagination<number, FilePathOrder, FilePathCursor> | null; filters?: SearchFilterArgs[]; groupDirectories?: boolean }
export type FilePathWithObject = { id: number; pub_id: number[]; is_dir: boolean | null; cas_id: string | null; integrity_checksum: string | null; location_id: number | null; materialized_path: string | null; name: string | null; extension: string | null; hidden: boolean | null; size_in_bytes: string | null; size_in_bytes_bytes: number[] | null; inode: number[] | null; object_id: number | null; key_id: number | null; date_created: string | null; date_modified: string | null; date_indexed: string | null; object: Object | null }
@ -336,7 +336,7 @@ export type ObjectHiddenFilter = "exclude" | "include"
export type ObjectOrder = { field: "dateAccessed"; value: SortOrder } | { field: "kind"; value: SortOrder } | { field: "mediaData"; value: MediaDataOrder }
export type ObjectSearchArgs = { take: number; orderAndPagination?: OrderAndPagination<number, ObjectOrder, ObjectCursor> | null; filter?: SearchFilterArgs[] }
export type ObjectSearchArgs = { take: number; orderAndPagination?: OrderAndPagination<number, ObjectOrder, ObjectCursor> | null; filters?: SearchFilterArgs[] }
export type ObjectValidatorArgs = { id: number; path: string }