Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement new advanced search by filter-sphere #1

Merged
merged 24 commits into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
cefc649
feat: implement AdvancedFilter component by @fn-sphere/filter
lawvs Jan 29, 2025
537a09d
chore: update package.json
lawvs Jan 29, 2025
30d8851
fix: remove turbopack temporary
lawvs Jan 29, 2025
a277623
feat: implement filter transformation logic for query string generation
lawvs Jan 29, 2025
8356c37
refactor: rename AdvancedFilter to AdvancedFilterBuilder
lawvs Jan 29, 2025
1efcbfa
chore: tweak styles
lawvs Jan 30, 2025
cc6f7cb
refactor: change input component from defaultValue to value prop
lawvs Jan 30, 2025
12b1e2b
refactor: update toggle button with fieldset styles
lawvs Feb 1, 2025
e14329d
fix: add type check for setSelectionRange in input component
lawvs Feb 1, 2025
b1e2a88
feat: enhance date handling in transform functions
lawvs Feb 2, 2025
d101b1c
fix: change content_length type from string to number
lawvs Feb 2, 2025
b0daea6
chore: add testing support with vitest
lawvs Feb 2, 2025
d58a2bc
test: add unit tests for filterRuleToQueryString function
lawvs Feb 2, 2025
aec8e55
chore: enhance filter function sorting
lawvs Feb 2, 2025
50f9c05
feat: add expert mode toggle to advanced search
lawvs Feb 2, 2025
2ca6978
chore: enable turbopack
lawvs Feb 2, 2025
afa14d5
fix: simple caching for advanced filter rules
lawvs Feb 2, 2025
3cd5bc6
fix: add unary filter check to transformSingleFilter function
lawvs Feb 3, 2025
c510acc
refactor: tweak theme
lawvs Feb 3, 2025
e466585
fix: hydration error of div cannot be a descendant of p
lawvs Feb 3, 2025
fc4b060
chore: prevent unnecessary search and input updates
lawvs Feb 3, 2025
74a6694
feat: add serialization and deserialization for FilterGroup with date…
lawvs Feb 3, 2025
43d8c3c
refactor: implement caching for filter rules using localStorage
lawvs Feb 3, 2025
fd67bc5
chore: remove turbopack option
lawvs Feb 3, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions app/components/filter-sphere/filter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {
FilterSphereProvider,
FilterBuilder,
useFilterSphere,
FilterGroup,
} from "@fn-sphere/filter";
import { filterFnList, filterSchema, getLocaleText } from "./schema";
import { filterTheme } from "./theme";
import { filterRuleToQueryString } from "./transform";
import { cacheFilterRule, getCachedFilterRule } from "./utils";

export interface Props {
value?: string;
onChange?: (value: string) => void;
}

const AdvancedFilterBuilder = (props: Props) => {
const defaultRule = getCachedFilterRule(props.value ?? "") ?? undefined;
const { context } = useFilterSphere({
schema: filterSchema,
defaultRule,
filterFnList,
getLocaleText,
onRuleChange: ({ filterRule }) => {
const query = filterRuleToQueryString(filterRule);
props.onChange?.(query);
cacheFilterRule(query, filterRule);
},
});
return (
<FilterSphereProvider context={context} theme={filterTheme}>
<FilterBuilder />
</FilterSphereProvider>
);
};

export default AdvancedFilterBuilder;
4 changes: 4 additions & 0 deletions app/components/filter-sphere/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import AdvancedFilterBuilder from "./filter";
export { getCachedFilterRule } from "./utils";

export default AdvancedFilterBuilder;
51 changes: 51 additions & 0 deletions app/components/filter-sphere/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { defineTypedFn, FnSchema, presetFilter } from "@fn-sphere/filter";
import { z } from "zod";
import { zhCN } from "@fn-sphere/filter/locales";

export const filterSchema = z.object({
title: z.string().describe("文章标题"),
author: z.array(z.string()).describe("文章作者"),
tags: z.array(z.string()).describe("文章标签"),
content: z.string().describe("文章内容"),
date: z.date().describe("发布时间"),
link: z.string().describe("文章链接"),
content_length: z.number().describe("文章字数"),
id: z.string().describe("ID"),
id_feed: z.number().describe("Feed ID"),
});

const notStartsWithFilter = defineTypedFn({
name: "notStartsWith",
define: z.function().args(z.string(), z.coerce.string()).returns(z.boolean()),
// Just a placeholder since we don't need filter data at frontend.
implement: () => false,
});

const filterPriority = [
"contains",
"notContains",
"startsWith",
"notStartsWith",
];

export const filterFnList: FnSchema[] = [
...presetFilter.filter((fn) => fn.name !== "endsWith"),
notStartsWithFilter,
].sort((a, b) => {
const indexA = filterPriority.indexOf(a.name);
const indexB = filterPriority.indexOf(b.name);
return (
(indexA === -1 ? Infinity : indexA) - (indexB === -1 ? Infinity : indexB)
);
});

const locale: Record<string, string> = {
...zhCN,
startsWith: "以...开始",
notStartsWith: "不以...开始",
};

export const getLocaleText = (key: string): string => {
if (!(key in locale)) return key;
return locale[key];
};
135 changes: 135 additions & 0 deletions app/components/filter-sphere/theme.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import {
type FilterTheme,
createFilterTheme,
presetTheme,
useFilterGroup,
useRootRule,
useView,
} from "@fn-sphere/filter";
import { type ChangeEvent, useCallback } from "react";

// See http://www.waterwater.moe/fn-sphere/customization/theme/

const componentsSpec = {
Button: (props) => {
return <Button variant="noShadow" {...props} />;
},
Input: ({ onChange, ...props }) => {
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
onChange?.(e.target.value);
},
[onChange]
);
return <Input className="h-10" onChange={handleChange} {...props} />;
},
Select: ({ value, onChange, options = [], className, disabled }) => {
const selectedIdx = options.findIndex((option) => option.value === value);
const handleChange = useCallback(
(value: string) => {
const index = Number(value);
onChange?.(options[index].value);
},
[options, onChange]
);
return (
<Select
value={String(selectedIdx)}
onValueChange={handleChange}
disabled={disabled}
>
<SelectTrigger className="min-w-28">
<SelectValue className={className} />
</SelectTrigger>
<SelectContent>
{options?.map((option, index) => (
<SelectItem key={option.label} value={String(index)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
},
} satisfies Partial<FilterTheme["components"]>;

const templatesSpec = {
FilterGroupContainer: ({ rule, children, ...props }) => {
const { getLocaleText } = useRootRule();
const {
ruleState: { isRoot, depth },
toggleGroupOp,
appendChildRule,
appendChildGroup,
removeGroup,
} = useFilterGroup(rule);

const text =
rule.op === "or"
? getLocaleText("operatorOr")
: getLocaleText("operatorAnd");

const handleToggleGroupOp = useCallback(() => {
toggleGroupOp();
}, [toggleGroupOp]);

const handleAddCondition = useCallback(() => {
appendChildRule();
}, [appendChildRule]);

const handleAddGroup = useCallback(() => {
appendChildGroup();
}, [appendChildGroup]);

const handleDeleteGroup = useCallback(() => {
removeGroup();
}, [removeGroup]);

return (
<div
className={cn(
"relative flex flex-col items-start rounded-base border-2 border-border px-3 py-2 gap-2 bg-opacity pt-8",
isRoot ? "mt-8" : "mt-6 mb-2"
)}
{...props}
>
<div className="flex gap-2 absolute top-0 -translate-y-1/2">
<Button onClick={handleToggleGroupOp}>{text}</Button>
<Button variant="neutral" onClick={handleAddCondition}>
{getLocaleText("addRule")}
</Button>
{depth < 3 && (
<Button variant="neutral" onClick={handleAddGroup}>
{getLocaleText("addGroup")}
</Button>
)}
{!isRoot && (
<Button variant="neutral" onClick={handleDeleteGroup}>
{getLocaleText("deleteGroup")}
</Button>
)}
</div>
{children}
</div>
);
},
FilterSelect: (props) => {
const PresetFilterSelect = presetTheme.templates.FilterSelect;
return <PresetFilterSelect tryRetainArgs {...props} />;
},
} satisfies Partial<FilterTheme["templates"]>;

export const filterTheme = createFilterTheme({
components: componentsSpec,
templates: templatesSpec,
});
Loading