"use client"; import { ListFilter, Loader2, X } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState, useTransition } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger, } from "@/components/ui/drawer"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { cn } from "@/lib/utils"; import { RadioGroup, RadioGroupItem } from "./ui/radio-group"; interface CheckboxGroupProps { label: string; options: Array<{ value: string; label: string }>; selectedValues: string[]; onChange: (values: string[]) => void; name: string; } const CheckboxGroup: React.FC = ({ label, options, selectedValues, onChange, name, }) => { const handleCheckedChange = (value: string, checked: boolean) => { if (checked) { onChange([...selectedValues, value]); } else { onChange(selectedValues.filter((v) => v !== value)); } }; return (
{options.map((option) => (
handleCheckedChange(option.value, !!checked) } />
))}
); }; type FilterOption = { value: string; label: string }; type FilterInputConfig = | { name: TKey; label: string; placeholder: string; type: "string" | "number"; } | { name: TKey; label: string; type: "checkbox-group"; options: FilterOption[]; serialize?: (values: string[]) => string; deserialize?: (urlValue: string) => string[]; } | { name: TKey; label: string; type: "radio-group"; options: FilterOption[]; }; // Utility type to extract the config for a specific key from the array type GetConfigForKey< TKey, TConfigArray extends FilterInputConfig[], > = TConfigArray extends ReadonlyArray ? U extends FilterInputConfig & { name: TKey } ? U : never : never; // This type maps filter keys to their appropriate value types (string or string[]) type FilterValues< TFilterKeys extends string, TInputs extends FilterInputConfig[], > = { [K in TFilterKeys]: GetConfigForKey extends { type: "checkbox-group"; } ? string[] : GetConfigForKey extends { type: "radio-group" } ? string : string; }; // Default serialization/deserialization for checkbox-group if not provided const defaultSerialize = (values: string[]) => values.join(","); const defaultDeserialize = (urlValue: string) => urlValue ? urlValue.split(",") : []; // --- Component Props --- interface DynamicFilterProps< TFilterKeys extends string, TInputs extends FilterInputConfig[], > { inputs: TInputs; title?: string; description?: string; } export default function DynamicFilter< TFilterKeys extends string, TInputs extends FilterInputConfig[], >({ inputs, title = "Filters", description = "Select your desired filters here", }: DynamicFilterProps) { const { replace } = useRouter(); const [isOpen, setIsOpen] = useState(false); const [disabled, startTransition] = useTransition(); const searchParams = useSearchParams(); const pathname = usePathname(); // Use a ref or memoized value for URLSearchParams to avoid re-creations // and ensure it's always based on the latest searchParams. const currentParams = useMemo( () => new URLSearchParams(searchParams.toString()), [searchParams], ); // Initialize local state for input values based on URL const initialInputState = useMemo(() => { const initialState: Partial> = {}; for (const input of inputs) { const urlValue = searchParams.get(input.name); if (input.type === "checkbox-group") { const deserialize = input.deserialize || defaultDeserialize; (initialState as FilterValues)[input.name] = deserialize(urlValue || "") as FilterValues< TFilterKeys, TInputs >[typeof input.name]; } else { (initialState as FilterValues)[input.name] = (urlValue || "") as FilterValues< TFilterKeys, TInputs >[typeof input.name]; } } return initialState as FilterValues; }, [inputs, searchParams]); // Re-initialize if inputs config or URL searchParams change const [inputValues, setInputValues] = useState>(initialInputState); // Update local state if URL searchParams change while drawer is closed // This is important if filters are applied externally or on page load useEffect(() => { if (!isOpen) { setInputValues(initialInputState); } }, [isOpen, initialInputState]); // Handler for text/number input changes const handleTextOrNumberInputChange = (name: TFilterKeys) => (e: React.ChangeEvent) => { setInputValues((prev) => ({ ...prev, [name]: e.target.value, })); }; // Handler for checkbox group changes const handleCheckboxGroupChange = (name: TFilterKeys) => (values: string[]) => { setInputValues((prev) => ({ ...prev, [name]: values, })); }; // Handles applying all filters const handleApplyFilters = () => { const newParams = new URLSearchParams(currentParams.toString()); // Start fresh with current params for (const input of inputs) { const value = inputValues[input.name]; if (input.type === "checkbox-group") { const serialize = input.serialize || defaultSerialize; const serializedValue = serialize(value as string[]); if (serializedValue) { newParams.set(input.name, serializedValue); } else { newParams.delete(input.name); } } else { // String/Number inputs if (value) { newParams.set(input.name, value as string); } else { newParams.delete(input.name); } } } newParams.set("page", "1"); startTransition(() => { replace(`${pathname}?${newParams.toString()}`); setIsOpen(false); }); }; // Handles clearing all filters const handleClearFilters = () => { // Reset local input values const clearedInputState: Partial> = {}; for (const input of inputs) { if (input.type === "checkbox-group") { (clearedInputState as FilterValues)[ input.name as TFilterKeys ] = [] as any; } else { (clearedInputState as FilterValues)[ input.name as TFilterKeys ] = "" as any; } } setInputValues(clearedInputState as FilterValues); startTransition(() => { replace(pathname); setIsOpen(false); }); }; // Dynamically constructs applied filters for badges const appliedFilters = useMemo(() => { const filters: Array<{ key: TFilterKeys; value: string | string[]; label: string; config: FilterInputConfig; }> = []; for (const input of inputs) { const urlValue = searchParams.get(input.name); if (urlValue) { if (input.type === "checkbox-group") { const deserialize = input.deserialize || defaultDeserialize; const deserialized = deserialize(urlValue); if (deserialized.length > 0) { filters.push({ key: input.name, value: deserialized, label: input.label, config: input, }); } } else { filters.push({ key: input.name, value: urlValue, label: input.label, config: input, }); } } } return filters; }, [searchParams, inputs]); // Dynamic `prettyPrintFilter` for badges const prettyPrintFilter = ( key: TFilterKeys, value: string | string[], config: FilterInputConfig, ) => { if (config.type === "checkbox-group") { const labels = (value as string[]) .map((v) => config.options.find((opt) => opt.value === v)?.label || v) .join(", "); return (

{config.label.replace(/%20/g, " ")}:{" "} {labels}

); } return (

{config.label}: {value}

); }; // Handles removing an individual filter const handleRemoveFilter = (keyToRemove: TFilterKeys) => { const newParams = new URLSearchParams(currentParams.toString()); newParams.delete(keyToRemove); newParams.set("page", "1"); // Reset page after removing a filter // Clear the specific input's local state const inputConfig = inputs.find((input) => input.name === keyToRemove); setInputValues((prev) => ({ ...prev, [keyToRemove]: inputConfig?.type === "checkbox-group" ? [] : "", })); startTransition(() => { replace(`${pathname}?${newParams.toString()}`); }); }; return (
{title}
{description}
{inputs.map((input) => { switch (input.type) { case "string": case "number": return ( ); case "checkbox-group": return ( ); case "radio-group": return (
setInputValues((prev) => ({ ...prev, [input.name]: value, })) } > {input.options.map((option) => (
))}
); default: return null; } })}
{appliedFilters.map((filter) => ( {prettyPrintFilter(filter.key, filter.value, filter.config)} {disabled ? ( ) : ( handleRemoveFilter(filter.key)} > Remove )} ))}
); }