src/features/reviews/filter-builder.tsx
Linked repository file from this build.
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Plus, Trash2, ChevronDown, Search } from "lucide-react";
import { fieldClass, Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { searchFiltersSchema, type SearchFilter, type SearchSort } from "./search-filters";
import { searchFields, getSearchField } from "./search-fields";
export type { SearchFilter };
export type SearchField = (typeof searchFields)[number];
export const DEFAULT_FILTER: SearchFilter = { field: "reviewNumber", mode: "contains", term: "" };
export const controlClass = fieldClass;
export const actionClass =
"inline-flex h-8 w-auto shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-md border bg-background px-3 text-xs font-medium hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50";
export function committedFilters(filters: SearchFilter[]): SearchFilter[] {
return filters.filter((filter) => {
if (filter.mode === "empty" || filter.mode === "present") return true;
if (filter.mode === "between" || (!filter.mode && filter.field === "sampleMonth"))
return Boolean(filter.from || filter.through);
if (filter.field === "outcome" && filter.mode === "exact" && filter.term === "") return true;
return Boolean(filter.term?.trim());
});
}
export function parseSearchFilters(raw: string | null): SearchFilter[] {
if (!raw) return [];
try {
const result = searchFiltersSchema.safeParse(JSON.parse(raw));
return result.success ? result.data : [];
} catch {
return [];
}
}
export function searchHref(
query: string,
filters: SearchFilter[],
offset = 0,
sort = "relevance",
direction = "desc",
sorts?: SearchSort[],
) {
const params = new URLSearchParams({ q: query.trim() });
if (filters.length) params.set("filters", JSON.stringify(filters));
if (offset > 0) params.set("offset", String(offset));
if (sorts?.length) params.set("sorts", JSON.stringify(sorts));
if (sort !== "relevance") {
params.set("sort", sort);
params.set("direction", direction);
}
return `/search?${params}`;
}
export function FieldPicker({
value,
onChange,
fields,
label = "Filter field",
prefix,
align = "left",
}: {
value: string;
onChange(value: string): void;
fields: SearchField[];
label?: string;
prefix?: string;
align?: "left" | "right";
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const root = useRef<HTMLDivElement>(null);
const current = fields.find((field) => field.key === value) ?? getSearchField(value);
useEffect(() => {
const close = (event: PointerEvent) => {
if (!root.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", close);
return () => document.removeEventListener("pointerdown", close);
}, []);
const matches = fields.filter((field) =>
`${field.label} ${field.key}`.toLowerCase().includes(query.toLowerCase()),
);
return (
<div
ref={root}
className="relative min-w-0"
onKeyDown={(event) => {
if (event.key === "Escape") setOpen(false);
}}
>
<button
type="button"
aria-label={prefix ? undefined : label}
aria-expanded={open}
aria-haspopup="dialog"
className={`${controlClass} flex w-full items-center justify-between gap-2 text-left`}
onClick={() => {
setQuery("");
setOpen(!open);
}}
>
<span className="truncate" title={current?.label ?? value}>
{prefix}
{(current?.label ?? value).split(" › ").slice(-2).reverse().join(" · ")}
</span>
<ChevronDown className="size-3 shrink-0" aria-hidden />
</button>
{open && (
<div
role="dialog"
aria-label="Choose search field"
className={`absolute ${align === "right" ? "right-0" : "left-0"} top-full z-50 mt-1 w-[min(28rem,80vw)] rounded-lg border bg-popover p-2 text-popover-foreground shadow-xl`}
>
<label className="flex items-center gap-2 border-b px-2 pb-2">
<Search className="size-4" aria-hidden />
<input
autoFocus
aria-label="Find a field"
placeholder="Find any field…"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && matches[0]) {
event.preventDefault();
onChange(matches[0].key);
setOpen(false);
}
if (event.key === "ArrowDown") {
event.preventDefault();
root.current?.querySelector<HTMLButtonElement>("[data-field-option]")?.focus();
}
}}
className="h-8 min-w-0 flex-1 bg-transparent text-sm outline-none"
/>
</label>
<div className="max-h-64 overflow-y-auto pt-1">
{matches.map((field) => (
<button
type="button"
key={field.key}
data-field-option
className="block w-full rounded px-2 py-1.5 text-left text-xs hover:bg-muted focus-visible:bg-muted"
onClick={() => {
onChange(field.key);
setOpen(false);
}}
>
<span className="block font-medium">{field.label}</span>
<span className="block truncate text-[0.625rem] text-muted-foreground">
{field.key}
</span>
</button>
))}
{!matches.length && (
<p className="p-2 text-xs text-muted-foreground">No fields match.</p>
)}
</div>
</div>
)}
</div>
);
}
export function defaultFilter(field: string, fields: SearchField[] = searchFields): SearchFilter {
const definition = fields.find((entry) => entry.key === field) ?? getSearchField(field);
if (
definition?.kind === "month" ||
definition?.kind === "date" ||
definition?.kind === "datetime"
)
return { field, mode: "between", from: "", through: "" };
return {
field,
mode: definition?.choices?.length || definition?.kind === "boolean" ? "exact" : "contains",
term: "",
};
}
export function SearchFilterInputs({
filter,
onChange,
fields,
compact = false,
}: {
filter: SearchFilter;
onChange(value: SearchFilter): void;
fields: SearchField[];
compact?: boolean;
}) {
const field = fields.find((entry) => entry.key === filter.field) ?? getSearchField(filter.field);
const label = field?.label ?? filter.field;
const kind = field?.kind ?? "text";
const mode = filter.mode ?? (filter.field === "sampleMonth" ? "between" : "contains");
const canRange = ["number", "date", "month", "datetime"].includes(kind);
const modes = [
...(!field?.choices?.length &&
kind !== "boolean" &&
!["date", "month", "datetime"].includes(kind)
? [
{ value: "contains", label: "Contains" },
{ value: "starts", label: "Starts with" },
{ value: "ends", label: "Ends with" },
]
: []),
{ value: "exact", label: "Equals" },
{ value: "not:exact", label: "Not equal" },
...(canRange
? [
{ value: "between", label: "Between" },
{ value: "not:between", label: "Outside range" },
]
: []),
...(!field?.choices?.length &&
kind !== "boolean" &&
!["date", "month", "datetime"].includes(kind)
? [
{ value: "not:contains", label: "Excludes" },
{ value: "not:starts", label: "Not starting with" },
{ value: "not:ends", label: "Not ending with" },
]
: []),
{ value: "empty", label: "Empty" },
{ value: "present", label: "Not empty" },
];
const inputType =
kind === "month"
? "month"
: kind === "date"
? "date"
: kind === "datetime"
? "datetime-local"
: kind === "number" && (mode === "between" || mode === "exact")
? "number"
: "text";
const choiceValues = field?.choices?.length
? field.choices
: kind === "boolean"
? [
{ value: "true", label: "Yes" },
{ value: "false", label: "No" },
]
: undefined;
const invalid =
mode === "between" &&
!!filter.from &&
!!filter.through &&
(kind === "number"
? Number(filter.from) > Number(filter.through)
: filter.from > filter.through);
return (
<div className={`flex min-w-0 flex-1 items-center gap-2 ${compact ? "" : "flex-wrap"}`}>
<select
aria-label={`${label} match mode`}
value={
filter.exclude
? mode === "empty"
? "present"
: mode === "present"
? "empty"
: `not:${mode}`
: mode
}
onChange={(event) => {
const exclude = event.target.value.startsWith("not:");
const nextMode = event.target.value.replace("not:", "") as SearchFilter["mode"];
onChange({
...filter,
mode: nextMode,
exclude,
term: nextMode === "exact" ? filter.term || filter.from || "" : filter.term,
from: filter.from ?? "",
through: filter.through ?? "",
});
}}
className={cn(controlClass, "w-36 shrink-0")}
>
{modes.map((entry) => (
<option key={entry.value} value={entry.value}>
{entry.label}
</option>
))}
</select>
{mode === "between" ? (
<div className="flex min-w-0 flex-1 items-center gap-1">
<Input
type={inputType}
aria-label={`${label} from`}
aria-invalid={invalid || undefined}
value={filter.from ?? ""}
onChange={(event) => onChange({ ...filter, from: event.target.value })}
placeholder="From"
className="w-full min-w-28 flex-1"
/>
<span className="text-xs text-muted-foreground">–</span>
<Input
type={inputType}
aria-label={`${label} through`}
aria-invalid={invalid || undefined}
value={filter.through ?? ""}
onChange={(event) => onChange({ ...filter, through: event.target.value })}
placeholder="Through"
className="w-full min-w-28 flex-1"
/>
</div>
) : mode === "empty" || mode === "present" ? (
<span className="flex-1 text-xs text-muted-foreground">No value needed</span>
) : choiceValues ? (
<select
aria-label={`${label} value`}
value={filter.term ?? ""}
onChange={(event) => onChange({ ...filter, term: event.target.value })}
className={`${controlClass} min-w-28 flex-1`}
>
{filter.field !== "outcome" && <option value="">Choose {label.toLowerCase()}</option>}
{choiceValues.map((choice) => (
<option key={choice.value} value={choice.value}>
{choice.label}
</option>
))}
</select>
) : (
<Input
type={inputType}
step={kind === "number" ? "any" : undefined}
aria-label={`${label} term`}
value={filter.term ?? ""}
onChange={(event) => onChange({ ...filter, term: event.target.value })}
placeholder={field?.unit === "cents" ? "Amount in cents" : `Enter ${label.toLowerCase()}`}
className="min-w-28 flex-1"
/>
)}
</div>
);
}
export function FilterBuilder({
filters,
onChange,
fields: providedFields,
actions,
}: {
filters: SearchFilter[];
onChange(filters: SearchFilter[]): void;
fields?: SearchField[];
actions?: ReactNode;
}) {
const fields = providedFields ?? searchFields;
return (
<div className="space-y-2" role="group" aria-label="Search filters">
<ul className="space-y-2">
{filters.map((filter, index) => (
<li key={index} className="rounded-md border bg-muted/20 px-2 py-0.5">
<div className="flex min-w-0 items-center gap-2" data-search-filter-row>
<div className="w-[min(28%,16rem)] min-w-36 shrink-0">
<FieldPicker
value={filter.field}
label={`Filter ${index + 1} field`}
fields={fields}
onChange={(value) =>
onChange(
filters.map((row, i) => (i === index ? defaultFilter(value, fields) : row)),
)
}
/>
</div>
<div className="min-w-0 flex-1 overflow-x-auto">
<SearchFilterInputs
filter={filter}
fields={fields}
compact
onChange={(value) =>
onChange(filters.map((row, i) => (i === index ? value : row)))
}
/>
</div>
<button
type="button"
aria-label={`Remove ${fields.find((entry) => entry.key === filter.field)?.label ?? filter.field} filter`}
title="Remove filter"
className="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-destructive"
onClick={() => onChange(filters.filter((_, i) => i !== index))}
>
<Trash2 className="size-3.5" aria-hidden />
</button>
</div>
</li>
))}
</ul>
<div className="flex items-center gap-3">
<button
type="button"
className={actionClass}
disabled={filters.length >= 100}
onClick={() => onChange([...filters, { ...DEFAULT_FILTER }])}
>
<Plus className="size-3" aria-hidden />
Add filter
</button>
{actions}
</div>
</div>
);
}