Ship leftover Yimaru login/personas, sidebar sections, filter modal, chart/stat clip fixes, and matching feature docs so the polish work is fully committed. Co-authored-by: Cursor <cursoragent@cursor.com>
331 lines
9.9 KiB
TypeScript
331 lines
9.9 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
import { ListFilter, Search } from "lucide-react";
|
|
import { useTranslations } from "next-intl";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export type TableFilterOption = {
|
|
value: string;
|
|
label: string;
|
|
};
|
|
|
|
/** One select filter in the toolbar (or filter modal when there are many). */
|
|
export type TableFilterDef = {
|
|
id: string;
|
|
label: string;
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
options: TableFilterOption[];
|
|
/** Shown as the “all / any” option. Defaults to translated common.all. */
|
|
allLabel?: string;
|
|
placeholder?: string;
|
|
};
|
|
|
|
type TableToolbarProps = {
|
|
search: string;
|
|
onSearchChange: (value: string) => void;
|
|
searchPlaceholder?: string;
|
|
/**
|
|
* Preferred multi-filter API. When `filters.length > inlineFilterLimit` (default 3),
|
|
* filters open in a modal with draft state + Apply / Reset.
|
|
*/
|
|
filters?: TableFilterDef[];
|
|
/** Legacy single-filter API — still supported (treated as one filter). */
|
|
filterValue?: string;
|
|
onFilterChange?: (value: string) => void;
|
|
filterOptions?: TableFilterOption[];
|
|
filterPlaceholder?: string;
|
|
filterAllLabel?: string;
|
|
/** Switch to modal when filter count exceeds this. Default 3. */
|
|
inlineFilterLimit?: number;
|
|
actions?: ReactNode;
|
|
className?: string;
|
|
resultCount?: number;
|
|
resultLabel?: string;
|
|
};
|
|
|
|
const DEFAULT_INLINE_LIMIT = 3;
|
|
|
|
function FilterSelect({
|
|
filter,
|
|
value,
|
|
onValueChange,
|
|
className,
|
|
}: {
|
|
filter: TableFilterDef;
|
|
value: string;
|
|
onValueChange: (value: string) => void;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<Select value={value || "all"} onValueChange={(v) => onValueChange(v ?? "all")}>
|
|
<SelectTrigger
|
|
className={cn(
|
|
"w-full rounded-xl border-border bg-background shadow-none sm:w-[180px]",
|
|
className,
|
|
)}
|
|
>
|
|
<SelectValue placeholder={filter.placeholder ?? filter.label} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">{filter.allLabel ?? "All"}</SelectItem>
|
|
{filter.options.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
}
|
|
|
|
function countActiveFilters(filters: TableFilterDef[], values: Record<string, string>) {
|
|
return filters.reduce((n, f) => {
|
|
const v = values[f.id] ?? f.value;
|
|
return n + (v && v !== "all" ? 1 : 0);
|
|
}, 0);
|
|
}
|
|
|
|
function valuesFromFilters(filters: TableFilterDef[]): Record<string, string> {
|
|
return Object.fromEntries(filters.map((f) => [f.id, f.value || "all"]));
|
|
}
|
|
|
|
function allCleared(filters: TableFilterDef[]): Record<string, string> {
|
|
return Object.fromEntries(filters.map((f) => [f.id, "all"]));
|
|
}
|
|
|
|
function FilterModal({
|
|
open,
|
|
onOpenChange,
|
|
filters,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
filters: TableFilterDef[];
|
|
}) {
|
|
const t = useTranslations("filters");
|
|
const tCommon = useTranslations("common");
|
|
const [draft, setDraft] = useState<Record<string, string>>(() =>
|
|
valuesFromFilters(filters),
|
|
);
|
|
|
|
// Sync draft from applied values only when the modal opens (not on every parent re-render).
|
|
useEffect(() => {
|
|
if (open) setDraft(valuesFromFilters(filters));
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional open-only sync
|
|
}, [open]);
|
|
|
|
function setDraftValue(id: string, value: string) {
|
|
setDraft((prev) => ({ ...prev, [id]: value }));
|
|
}
|
|
|
|
function handleApply() {
|
|
for (const f of filters) {
|
|
const next = draft[f.id] ?? "all";
|
|
if (next !== f.value) f.onChange(next);
|
|
}
|
|
onOpenChange(false);
|
|
}
|
|
|
|
function handleReset() {
|
|
setDraft(allCleared(filters));
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="gap-0 p-0 sm:max-w-md" showCloseButton>
|
|
<DialogHeader className="border-b border-border px-4 py-3.5">
|
|
<DialogTitle>{t("title")}</DialogTitle>
|
|
<DialogDescription className="sr-only">{t("title")}</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex max-h-[min(60vh,420px)] flex-col gap-4 overflow-y-auto px-4 py-4">
|
|
{filters.map((filter) => (
|
|
<div key={filter.id} className="flex flex-col gap-1.5">
|
|
<Label htmlFor={`filter-${filter.id}`} className="text-xs font-medium">
|
|
{filter.label}
|
|
</Label>
|
|
<FilterSelect
|
|
filter={{
|
|
...filter,
|
|
allLabel: filter.allLabel ?? tCommon("all"),
|
|
}}
|
|
value={draft[filter.id] ?? "all"}
|
|
onValueChange={(v) => setDraftValue(filter.id, v)}
|
|
className="sm:w-full"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<DialogFooter className="sm:justify-between">
|
|
<Button type="button" variant="ghost" onClick={handleReset}>
|
|
{t("reset")}
|
|
</Button>
|
|
<div className="flex flex-col-reverse gap-2 sm:flex-row">
|
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
{tCommon("cancel")}
|
|
</Button>
|
|
<Button type="button" onClick={handleApply}>
|
|
{t("apply")}
|
|
</Button>
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
/** In-panel search + optional filters (inline ≤3, modal when more). */
|
|
export function TableToolbar({
|
|
search,
|
|
onSearchChange,
|
|
searchPlaceholder = "Search…",
|
|
filters: filtersProp,
|
|
filterValue,
|
|
onFilterChange,
|
|
filterOptions,
|
|
filterPlaceholder = "Filter",
|
|
filterAllLabel,
|
|
inlineFilterLimit = DEFAULT_INLINE_LIMIT,
|
|
actions,
|
|
className,
|
|
resultCount,
|
|
resultLabel,
|
|
}: TableToolbarProps) {
|
|
const tCommon = useTranslations("common");
|
|
const tFilters = useTranslations("filters");
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
|
|
const filters = useMemo((): TableFilterDef[] => {
|
|
if (filtersProp?.length) return filtersProp;
|
|
if (filterOptions?.length && onFilterChange) {
|
|
return [
|
|
{
|
|
id: "legacy",
|
|
label: filterPlaceholder,
|
|
value: filterValue ?? "all",
|
|
onChange: onFilterChange,
|
|
options: filterOptions,
|
|
allLabel: filterAllLabel ?? tCommon("all"),
|
|
placeholder: filterPlaceholder,
|
|
},
|
|
];
|
|
}
|
|
return [];
|
|
}, [
|
|
filtersProp,
|
|
filterOptions,
|
|
onFilterChange,
|
|
filterValue,
|
|
filterPlaceholder,
|
|
filterAllLabel,
|
|
tCommon,
|
|
]);
|
|
|
|
const useModal = filters.length > inlineFilterLimit;
|
|
const appliedValues = useMemo(() => valuesFromFilters(filters), [filters]);
|
|
const activeCount = countActiveFilters(filters, appliedValues);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex flex-col gap-3 border-b border-border bg-card px-4 py-3.5 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between",
|
|
className,
|
|
)}
|
|
>
|
|
<div className="flex min-w-0 flex-1 flex-col gap-2.5 sm:flex-row sm:items-center">
|
|
<div className="relative w-full max-w-sm">
|
|
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
value={search}
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
placeholder={searchPlaceholder}
|
|
className="rounded-xl border-border bg-background pl-9 shadow-none"
|
|
aria-label={searchPlaceholder}
|
|
/>
|
|
</div>
|
|
|
|
{useModal ? (
|
|
<>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="gap-2 rounded-xl border-border bg-background shadow-none"
|
|
onClick={() => setModalOpen(true)}
|
|
aria-label={
|
|
activeCount > 0
|
|
? `${tFilters("title")} (${tFilters("active", { count: activeCount })})`
|
|
: tFilters("title")
|
|
}
|
|
>
|
|
<ListFilter className="size-4" />
|
|
{tFilters("title")}
|
|
{activeCount > 0 ? (
|
|
<span
|
|
className="inline-flex min-w-5 items-center justify-center rounded-md bg-foreground/10 px-1.5 py-0.5 text-[11px] font-semibold tabular-nums text-foreground"
|
|
title={tFilters("active", { count: activeCount })}
|
|
>
|
|
{activeCount}
|
|
</span>
|
|
) : null}
|
|
</Button>
|
|
<FilterModal
|
|
open={modalOpen}
|
|
onOpenChange={setModalOpen}
|
|
filters={filters}
|
|
/>
|
|
</>
|
|
) : (
|
|
filters.map((filter) => (
|
|
<FilterSelect
|
|
key={filter.id}
|
|
filter={{
|
|
...filter,
|
|
allLabel: filter.allLabel ?? tCommon("all"),
|
|
}}
|
|
value={filter.value}
|
|
onValueChange={filter.onChange}
|
|
/>
|
|
))
|
|
)}
|
|
|
|
{typeof resultCount === "number" ? (
|
|
<p className="text-xs text-muted-foreground whitespace-nowrap tabular-nums">
|
|
{resultCount}
|
|
{resultLabel ? ` ${resultLabel}` : ""}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
{actions ? (
|
|
<div className="flex flex-wrap items-center gap-2">{actions}</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function matchesSearch(haystacks: Array<string | number | null | undefined>, query: string) {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return true;
|
|
return haystacks.some((h) => String(h ?? "").toLowerCase().includes(q));
|
|
}
|