import { useState } from "react"; import { Button } from "@/src/components/ui/button"; import { Input } from "@/src/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/src/components/ui/select"; import { Popover, PopoverContent, PopoverTrigger, } from "@/src/components/ui/popover"; import { InputCommand, InputCommandEmpty, InputCommandGroup, InputCommandInput, InputCommandItem, InputCommandList, } from "@/src/components/ui/input-command"; import { MultiSelect } from "@/src/features/filters/components/multi-select"; import { Plus, X, Check, ChevronDown } from "lucide-react"; import { cn } from "@/src/utils/tailwind"; import type { KeyValueFilterEntry, NumericKeyValueFilterEntry, StringKeyValueFilterEntry, } from "@/src/features/filters/hooks/useSidebarFilterState"; type KeyValueFilterBuilderProps = | { mode: "categorical"; keyOptions?: string[]; availableValues: Record; activeFilters: KeyValueFilterEntry[]; onChange: (filters: KeyValueFilterEntry[]) => void; keyPlaceholder?: string; } | { mode: "numeric"; keyOptions?: string[]; activeFilters: NumericKeyValueFilterEntry[]; onChange: (filters: NumericKeyValueFilterEntry[]) => void; keyPlaceholder?: string; } | { mode: "string"; keyOptions?: string[]; activeFilters: StringKeyValueFilterEntry[]; onChange: (filters: StringKeyValueFilterEntry[]) => void; keyPlaceholder?: string; }; // Map operators to human-readable labels const NUMERIC_OPERATOR_LABELS = { "=": "equals", ">": "greater than", "<": "less than", ">=": "greater than or equals", "<=": "less than or equals", } as const; const STRING_OPERATOR_LABELS = { "=": "equals", contains: "contains", "does not contain": "does not contain", } as const; export function KeyValueFilterBuilder(props: KeyValueFilterBuilderProps) { const { mode, keyOptions, activeFilters, onChange, keyPlaceholder = "Key", } = props; const availableValues = mode === "categorical" ? props.availableValues : {}; // Track which popover is open (by index) const [openPopoverIndex, setOpenPopoverIndex] = useState(null); // Local UI state for filter rows (includes incomplete filters) // Initialize once from activeFilters but don't sync on every change // This allows incomplete filter rows to persist in the UI while being edited const [localFilters, setLocalFilters] = useState< | KeyValueFilterEntry[] | NumericKeyValueFilterEntry[] | StringKeyValueFilterEntry[] >(() => (activeFilters.length > 0 ? activeFilters : [])); const handleFilterChange = ( index: number, updates: | Partial | Partial | Partial, ) => { // TypeScript can't narrow the union array type automatically, so we narrow explicitly based on mode if (mode === "categorical") { const filters = localFilters as KeyValueFilterEntry[]; const newFilters = [...filters]; newFilters[index] = { ...newFilters[index], ...updates, } as KeyValueFilterEntry; setLocalFilters(newFilters); (onChange as (filters: KeyValueFilterEntry[]) => void)(newFilters); } else if (mode === "numeric") { const filters = localFilters as NumericKeyValueFilterEntry[]; const newFilters = [...filters]; newFilters[index] = { ...newFilters[index], ...updates, } as NumericKeyValueFilterEntry; setLocalFilters(newFilters); (onChange as (filters: NumericKeyValueFilterEntry[]) => void)(newFilters); } else { const filters = localFilters as StringKeyValueFilterEntry[]; const newFilters = [...filters]; newFilters[index] = { ...newFilters[index], ...updates, } as StringKeyValueFilterEntry; setLocalFilters(newFilters); (onChange as (filters: StringKeyValueFilterEntry[]) => void)(newFilters); } }; const handleAddFilter = () => { if (mode === "categorical") { const newFilter: KeyValueFilterEntry = { key: "", operator: "any of" as const, value: [], }; const filters = localFilters as KeyValueFilterEntry[]; const newFilters = [...filters, newFilter]; setLocalFilters(newFilters); } else if (mode === "numeric") { const newFilter: NumericKeyValueFilterEntry = { key: "", operator: "=" as const, value: "", }; const filters = localFilters as NumericKeyValueFilterEntry[]; const newFilters = [...filters, newFilter]; setLocalFilters(newFilters); } else { const newFilter: StringKeyValueFilterEntry = { key: "", operator: "=" as const, value: "", }; const filters = localFilters as StringKeyValueFilterEntry[]; const newFilters = [...filters, newFilter]; setLocalFilters(newFilters); } }; const handleRemoveFilter = (index: number) => { if (mode === "categorical") { const filters = localFilters as KeyValueFilterEntry[]; const newFilters = filters.filter((_, i) => i !== index); setLocalFilters(newFilters); (onChange as (filters: KeyValueFilterEntry[]) => void)(newFilters); } else if (mode === "numeric") { const filters = localFilters as NumericKeyValueFilterEntry[]; const newFilters = filters.filter((_, i) => i !== index); setLocalFilters(newFilters); (onChange as (filters: NumericKeyValueFilterEntry[]) => void)(newFilters); } else { const filters = localFilters as StringKeyValueFilterEntry[]; const newFilters = filters.filter((_, i) => i !== index); setLocalFilters(newFilters); (onChange as (filters: StringKeyValueFilterEntry[]) => void)(newFilters); } }; return (
{/* Filter rows */} {localFilters.map((filter, index) => { const availableValuesForKey = filter.key ? (availableValues[filter.key] ?? []) : []; return (
{/* Key input and delete button row */}
{keyOptions ? ( // Combobox for known keys setOpenPopoverIndex(open ? index : null) } > No keys found. {keyOptions.map((option) => ( { // Only update the key, preserve the existing value handleFilterChange(index, { key: value, }); setOpenPopoverIndex(null); // Close after selection }} > {option} ))} ) : ( // Text input for free-form keys { // Only update the key, preserve the existing value handleFilterChange(index, { key: e.target.value, }); }} className="flex-1" /> )} {/* Delete button */}
{mode === "categorical" ? ( <> {/* Operator select */} {/* Values multi-select */} ({ value: v }))} values={filter.value as string[]} onValueChange={(values) => handleFilterChange(index, { value: values }) } disabled={!filter.key} /> ) : mode === "numeric" ? ( <> {/* Numeric operator select */} {/* Numeric value input */} handleFilterChange(index, { value: e.target.value === "" ? "" : parseFloat(e.target.value), }) } disabled={!filter.key} /> ) : ( <> {/* String operator select */} {/* String value input */} handleFilterChange(index, { value: e.target.value, }) } disabled={!filter.key} /> )}
); })} {/* Add filter button */}
); }