Feature/1217 (#12087)

### What problem does this PR solve?

feature: Complete metadata functionality

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
chanx
2025-12-22 17:35:12 +08:00
committed by GitHub
parent 993bf7c2c8
commit 51b12841d6
9 changed files with 310 additions and 68 deletions

View File

@ -0,0 +1,170 @@
import { Checkbox } from '@/components/ui/checkbox';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { memo, useState } from 'react';
import { ControllerRenderProps, useFormContext } from 'react-hook-form';
import { FormControl, FormField, FormItem, FormLabel } from '../ui/form';
import { FilterType } from './interface';
const handleCheckChange = ({
checked,
field,
item,
isNestedField = false,
parentId = '',
}: {
checked: boolean;
field: ControllerRenderProps<
{ [x: string]: { [x: string]: any } | string[] },
string
>;
item: FilterType;
isNestedField?: boolean;
parentId?: string;
}) => {
if (isNestedField && parentId) {
const currentValue = field.value || {};
const currentParentValues =
(currentValue as Record<string, string[]>)[parentId] || [];
const newParentValues = checked
? [...currentParentValues, item.id.toString()]
: currentParentValues.filter(
(value: string) => value !== item.id.toString(),
);
const newValue = {
...currentValue,
[parentId]: newParentValues,
};
if (newValue[parentId].length === 0) {
delete newValue[parentId];
}
return field.onChange(newValue);
} else {
const list = checked
? [...(Array.isArray(field.value) ? field.value : []), item.id.toString()]
: (Array.isArray(field.value) ? field.value : []).filter(
(value) => value !== item.id.toString(),
);
return field.onChange(list);
}
};
const FilterItem = memo(
({
item,
field,
level = 0,
}: {
item: FilterType;
field: ControllerRenderProps<
{ [x: string]: { [x: string]: any } | string[] },
string
>;
level: number;
}) => {
return (
<div
className={`flex items-center justify-between text-text-primary text-xs ${level > 0 ? 'ml-4' : ''}`}
>
<FormItem className="flex flex-row space-x-3 space-y-0 items-center">
<FormControl>
<Checkbox
checked={field.value?.includes(item.id.toString())}
onCheckedChange={(checked: boolean) =>
handleCheckChange({ checked, field, item })
}
/>
</FormControl>
<FormLabel onClick={(e) => e.stopPropagation()}>
{item.label}
</FormLabel>
</FormItem>
{item.count !== undefined && (
<span className="text-sm">{item.count}</span>
)}
</div>
);
},
);
export const FilterField = memo(
({
item,
parent,
level = 0,
}: {
item: FilterType;
parent: FilterType;
level?: number;
}) => {
const form = useFormContext();
const [showAll, setShowAll] = useState(false);
const hasNestedList = item.list && item.list.length > 0;
return (
<FormField
key={item.id}
control={form.control}
name={parent.field as string}
render={({ field }) => {
if (hasNestedList) {
return (
<div className={`flex flex-col gap-2 ${level > 0 ? 'ml-4' : ''}`}>
<div
className="flex items-center justify-between cursor-pointer"
onClick={() => {
setShowAll(!showAll);
}}
>
<FormLabel className="text-text-primary">
{item.label}
</FormLabel>
{showAll ? (
<ChevronUp size={12} />
) : (
<ChevronDown size={12} />
)}
</div>
{showAll &&
item.list?.map((child) => (
<FilterField
key={child.id}
item={child}
parent={{
...item,
field: `${parent.field}.${item.field}`,
}}
level={level + 1}
/>
// <FilterItem key={child.id} item={child} field={child.field} level={level+1} />
// <div
// className="flex flex-row space-x-3 space-y-0 items-center"
// key={child.id}
// >
// <FormControl>
// <Checkbox
// checked={field.value?.includes(child.id.toString())}
// onCheckedChange={(checked) =>
// handleCheckChange({ checked, field, item: child })
// }
// />
// </FormControl>
// <FormLabel onClick={(e) => e.stopPropagation()}>
// {child.label}
// </FormLabel>
// </div>
))}
</div>
);
}
return <FilterItem item={item} field={field} level={level} />;
}}
/>
);
},
);
FilterField.displayName = 'FilterField';

View File

@ -4,21 +4,27 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { zodResolver } from '@hookform/resolvers/zod';
import { PropsWithChildren, useCallback, useEffect, useState } from 'react';
import {
PropsWithChildren,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { useForm } from 'react-hook-form';
import { ZodArray, ZodString, z } from 'zod';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { t } from 'i18next';
import { FilterField } from './filter-field';
import { FilterChange, FilterCollection, FilterValue } from './interface';
export type CheckboxFormMultipleProps = {
@ -35,29 +41,71 @@ function CheckboxFormMultiple({
onChange,
setOpen,
}: CheckboxFormMultipleProps) {
const fieldsDict = filters?.reduce<Record<string, Array<any>>>((pre, cur) => {
pre[cur.field] = [];
return pre;
}, {});
const [resolvedFilters, setResolvedFilters] =
useState<FilterCollection[]>(filters);
const FormSchema = z.object(
filters.reduce<Record<string, ZodArray<ZodString, 'many'>>>((pre, cur) => {
pre[cur.field] = z.array(z.string());
useEffect(() => {
if (filters && filters.length > 0) {
setResolvedFilters(filters);
}
}, [filters]);
// .refine((value) => value.some((item) => item), {
// message: 'You have to select at least one item.',
// });
const fieldsDict = useMemo(() => {
if (resolvedFilters.length === 0) {
return {};
}
return resolvedFilters.reduce<Record<string, any>>((pre, cur) => {
const hasNested = cur.list?.some(
(item) => item.list && item.list.length > 0,
);
if (hasNested) {
pre[cur.field] = {};
} else {
pre[cur.field] = [];
}
return pre;
}, {}),
);
}, {});
}, [resolvedFilters]);
const FormSchema = useMemo(() => {
if (resolvedFilters.length === 0) {
return z.object({});
}
return z.object(
resolvedFilters.reduce<
Record<
string,
ZodArray<ZodString, 'many'> | z.ZodObject<any> | z.ZodOptional<any>
>
>((pre, cur) => {
const hasNested = cur.list?.some(
(item) => item.list && item.list.length > 0,
);
if (hasNested) {
pre[cur.field] = z
.record(z.string(), z.array(z.string().optional()).optional())
.optional();
} else {
pre[cur.field] = z.array(z.string().optional()).optional();
}
return pre;
}, {}),
);
}, [resolvedFilters]);
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
resolver: resolvedFilters.length > 0 ? zodResolver(FormSchema) : undefined,
defaultValues: fieldsDict,
});
function onSubmit(data: z.infer<typeof FormSchema>) {
onChange?.(data);
function onSubmit() {
const formValues = form.getValues();
onChange?.({ ...formValues });
setOpen(false);
}
@ -67,8 +115,10 @@ function CheckboxFormMultiple({
}, [fieldsDict, onChange, setOpen]);
useEffect(() => {
form.reset(value);
}, [form, value]);
if (resolvedFilters.length > 0) {
form.reset(value || fieldsDict);
}
}, [form, value, resolvedFilters, fieldsDict]);
return (
<Form {...form}>
@ -85,44 +135,21 @@ function CheckboxFormMultiple({
render={() => (
<FormItem className="space-y-4">
<div>
<FormLabel className="text-base text-text-sub-title-invert">
{x.label}
</FormLabel>
<FormLabel className="text-text-primary">{x.label}</FormLabel>
</div>
{x.list.map((item) => (
<FormField
key={item.id}
control={form.control}
name={x.field}
render={({ field }) => {
return (
<div className="flex items-center justify-between text-text-primary text-xs">
<FormItem
key={item.id}
className="flex flex-row space-x-3 space-y-0 items-center "
>
<FormControl>
<Checkbox
checked={field.value?.includes(item.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, item.id])
: field.onChange(
field.value?.filter(
(value) => value !== item.id,
),
);
}}
/>
</FormControl>
<FormLabel>{item.label}</FormLabel>
</FormItem>
<span className=" text-sm">{item.count}</span>
</div>
);
}}
/>
))}
{x.list.map((item) => {
return (
<FilterField
key={item.id}
item={{ ...item }}
parent={{
...x,
id: x.field,
// field: `${x.field}${item.field ? '.' + item.field : ''}`,
}}
/>
);
})}
<FormMessage />
</FormItem>
)}
@ -137,7 +164,13 @@ function CheckboxFormMultiple({
>
{t('common.clear')}
</Button>
<Button type="submit" size={'sm'}>
<Button
type="submit"
onClick={() => {
console.log(form.formState.errors, form.getValues());
}}
size={'sm'}
>
{t('common.submit')}
</Button>
</div>

View File

@ -17,6 +17,7 @@ interface IProps {
onSearchChange?: ChangeEventHandler<HTMLInputElement>;
showFilter?: boolean;
leftPanel?: ReactNode;
preChildren?: ReactNode;
}
export const FilterButton = React.forwardRef<
@ -46,6 +47,7 @@ export const FilterButton = React.forwardRef<
export default function ListFilterBar({
title,
children,
preChildren,
searchString,
onSearchChange,
showFilter = true,
@ -63,7 +65,18 @@ export default function ListFilterBar({
const filterCount = useMemo(() => {
return typeof value === 'object' && value !== null
? Object.values(value).reduce((pre, cur) => {
return pre + cur.length;
if (Array.isArray(cur)) {
return pre + cur.length;
}
if (typeof cur === 'object') {
return (
pre +
Object.values(cur).reduce((pre, cur) => {
return pre + cur.length;
}, 0)
);
}
return pre;
}, 0)
: 0;
}, [value]);
@ -80,6 +93,7 @@ export default function ListFilterBar({
{leftPanel || title}
</div>
<div className="flex gap-5 items-center">
{preChildren}
{showFilter && (
<FilterPopover
value={value}

View File

@ -1,6 +1,9 @@
export type FilterType = {
id: string;
field?: string;
label: string | JSX.Element;
list?: FilterType[];
value?: string | string[];
count?: number;
};
@ -10,6 +13,9 @@ export type FilterCollection = {
list: FilterType[];
};
export type FilterValue = Record<string, Array<string>>;
export type FilterValue = Record<
string,
Array<string> | Record<string, Array<string>>
>;
export type FilterChange = (value: FilterValue) => void;