Files
sarlink-portal/components/generic-filter.tsx
i701 0157eccd57
All checks were successful
Build and Push Docker Images / Build and Push Docker Images (push) Successful in 4m49s
feat: add dual range slider component and integrate it into dynamic filter for device management
2025-06-29 20:46:34 +05:00

581 lines
19 KiB
TypeScript

"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 { DualRangeSlider } from "@/components/ui/dual-range-slider";
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<CheckboxGroupProps> = ({
label,
options,
selectedValues,
onChange,
name,
}) => {
const handleCheckedChange = (value: string, checked: boolean) => {
if (checked) {
onChange([...selectedValues, value]);
} else {
onChange(selectedValues.filter((v) => v !== value));
}
};
return (
<div className="flex flex-col gap-2 p-2 border rounded-md">
<Label className="font-semibold text-sm">{label}</Label>
<div className="flex flex-col gap-2">
{options.map((option) => (
<div
key={`${name}-${option.value}`}
className="flex items-center space-x-2"
>
<Checkbox
id={`${name}-${option.value}`}
checked={selectedValues.includes(option.value)}
onCheckedChange={(checked) =>
handleCheckedChange(option.value, !!checked)
}
/>
<Label htmlFor={`${name}-${option.value}`}>{option.label}</Label>
</div>
))}
</div>
</div>
);
};
type FilterOption = { value: string; label: string };
type FilterInputConfig<TKey extends string> =
| {
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[];
}
| {
name: TKey;
label: string;
type: "dual-range-slider";
min: number;
max: number;
step?: number;
sliderLabel?: string;
formatLabel?: (value: number | undefined) => React.ReactNode;
};
// Utility type to extract the config for a specific key from the array
type GetConfigForKey<
TKey,
TConfigArray extends FilterInputConfig<string>[],
> = TConfigArray extends ReadonlyArray<infer U>
? U extends FilterInputConfig<string> & { 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<string>[],
> = {
[K in TFilterKeys]: GetConfigForKey<K, TInputs> extends {
type: "checkbox-group";
}
? string[]
: GetConfigForKey<K, TInputs> extends { type: "radio-group" }
? string
: GetConfigForKey<K, TInputs> extends { type: "dual-range-slider" }
? number[]
: 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<TFilterKeys>[],
> {
inputs: TInputs;
title?: string;
description?: string;
}
export default function DynamicFilter<
TFilterKeys extends string,
TInputs extends FilterInputConfig<TFilterKeys>[],
>({
inputs,
title = "Filters",
description = "Select your desired filters here",
}: DynamicFilterProps<TFilterKeys, TInputs>) {
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<FilterValues<TFilterKeys, TInputs>> = {};
for (const input of inputs) {
const urlValue = searchParams.get(input.name);
if (input.type === "checkbox-group") {
const deserialize = input.deserialize || defaultDeserialize;
(initialState as FilterValues<TFilterKeys, TInputs>)[input.name] =
deserialize(urlValue || "") as FilterValues<
TFilterKeys,
TInputs
>[typeof input.name];
} else if (input.type === "dual-range-slider") {
const minValue = searchParams.get(`${input.name}_min`);
const maxValue = searchParams.get(`${input.name}_max`);
const parsedMin = minValue ? Number(minValue) : input.min;
const parsedMax = maxValue ? Number(maxValue) : input.max;
(initialState as FilterValues<TFilterKeys, TInputs>)[input.name] =
[parsedMin, parsedMax] as FilterValues<
TFilterKeys,
TInputs
>[typeof input.name];
} else {
(initialState as FilterValues<TFilterKeys, TInputs>)[input.name] =
(urlValue || "") as FilterValues<
TFilterKeys,
TInputs
>[typeof input.name];
}
}
return initialState as FilterValues<TFilterKeys, TInputs>;
}, [inputs, searchParams]); // Re-initialize if inputs config or URL searchParams change
const [inputValues, setInputValues] =
useState<FilterValues<TFilterKeys, TInputs>>(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<HTMLInputElement>) => {
setInputValues((prev) => ({
...prev,
[name]: e.target.value,
}));
};
// Handler for checkbox group changes
const handleCheckboxGroupChange =
(name: TFilterKeys) => (values: string[]) => {
setInputValues((prev) => ({
...prev,
[name]: values,
}));
};
// Handler for dual range slider changes
const handleDualRangeChange =
(name: TFilterKeys) => (values: number[]) => {
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 if (input.type === "dual-range-slider") {
const rangeValues = value as number[];
// Only set params if values are different from the default min/max
if (rangeValues.length === 2 && (rangeValues[0] !== input.min || rangeValues[1] !== input.max)) {
newParams.set(`${input.name}_min`, rangeValues[0].toString());
newParams.set(`${input.name}_max`, rangeValues[1].toString());
} else {
newParams.delete(`${input.name}_min`);
newParams.delete(`${input.name}_max`);
}
} 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<FilterValues<TFilterKeys, TInputs>> = {};
for (const input of inputs) {
if (input.type === "checkbox-group") {
(clearedInputState as FilterValues<TFilterKeys, TInputs>)[
input.name as TFilterKeys
] = [] as FilterValues<TFilterKeys, TInputs>[typeof input.name];
} else if (input.type === "dual-range-slider") {
(clearedInputState as FilterValues<TFilterKeys, TInputs>)[
input.name as TFilterKeys
] = [input.min, input.max] as FilterValues<TFilterKeys, TInputs>[typeof input.name];
} else if (input.type === "radio-group" || input.type === "string" || input.type === "number") {
(clearedInputState as FilterValues<TFilterKeys, TInputs>)[
input.name as TFilterKeys
] = "" as FilterValues<TFilterKeys, TInputs>[typeof input.name];
}
}
setInputValues(clearedInputState as FilterValues<TFilterKeys, TInputs>);
startTransition(() => {
replace(pathname);
setIsOpen(false);
});
};
// Dynamically constructs applied filters for badges
const appliedFilters = useMemo(() => {
const filters: Array<{
key: TFilterKeys;
value: string | string[] | number[];
label: string;
config: FilterInputConfig<TFilterKeys>;
}> = [];
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 if (input.type === "dual-range-slider") {
const minValue = searchParams.get(`${input.name}_min`);
const maxValue = searchParams.get(`${input.name}_max`);
if (minValue && maxValue) {
const parsedMin = Number(minValue);
const parsedMax = Number(maxValue);
filters.push({
key: input.name,
value: [parsedMin, parsedMax],
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[] | number[],
config: FilterInputConfig<TFilterKeys>,
) => {
if (config.type === "checkbox-group") {
const labels = (value as string[])
.map((v) => config.options.find((opt) => opt.value === v)?.label || v)
.join(", ");
return (
<p>
{config.label.replace(/%20/g, " ")}:{" "}
<span className="text-muted-foreground">{labels}</span>
</p>
);
}
if (config.type === "dual-range-slider") {
const rangeValues = value as number[];
const formatLabel = config.formatLabel || ((val: number | undefined) => val?.toString() || "");
return (
<p>
{config.label}:{" "}
<span className="text-muted-foreground">
{formatLabel(rangeValues[0])} - {formatLabel(rangeValues[1])}
</span>
</p>
);
}
return (
<p>
{config.label}: <span className="text-muted-foreground">{value}</span>
</p>
);
};
// Handles removing an individual filter
const handleRemoveFilter = (keyToRemove: TFilterKeys) => {
const newParams = new URLSearchParams(currentParams.toString());
// Clear the specific input's local state
const inputConfig = inputs.find((input) => input.name === keyToRemove);
if (inputConfig?.type === "dual-range-slider") {
newParams.delete(`${keyToRemove}_min`);
newParams.delete(`${keyToRemove}_max`);
setInputValues((prev) => ({
...prev,
[keyToRemove]: [inputConfig.min, inputConfig.max],
}));
} else {
newParams.delete(keyToRemove);
setInputValues((prev) => ({
...prev,
[keyToRemove]: inputConfig?.type === "checkbox-group" ? [] : "",
}));
}
newParams.set("page", "1"); // Reset page after removing a filter
startTransition(() => {
replace(`${pathname}?${newParams.toString()}`);
});
};
return (
<div className="flex flex-col items-start justify-start gap-2 w-full">
<Drawer open={isOpen} onOpenChange={setIsOpen}>
<DrawerTrigger asChild>
<Button
className="w-full font-barlow sm:w-48 flex items-end justify-between"
onClick={() => setIsOpen(!isOpen)}
variant="outline"
>
Filter
<ListFilter />
</Button>
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-3xl">
<DrawerHeader>
<DrawerTitle>{title}</DrawerTitle>
<DrawerDescription asChild>
<div>{description}</div>
</DrawerDescription>
</DrawerHeader>
<div className="grid sm:grid-cols-3 gap-4 p-4">
{inputs.map((input) => {
switch (input.type) {
case "string":
case "number":
return (
<Input
key={input.name}
placeholder={input.placeholder}
value={inputValues[input.name] as string}
onChange={handleTextOrNumberInputChange(input.name)}
type={input.type}
/>
);
case "checkbox-group":
return (
<CheckboxGroup
key={input.name}
name={input.name}
label={input.label}
options={input.options}
selectedValues={inputValues[input.name] as string[]}
onChange={handleCheckboxGroupChange(input.name)}
/>
);
case "dual-range-slider":
return (
<div
key={input.name}
className="px-2 w-full flex flex-col gap-2 p-2 border rounded-md"
>
<Label className="font-semibold text-sm">
{input.label}
</Label>
<div className="px-3 py-2 mt-5">
<DualRangeSlider
label={(value) => <span className="text-xs">{value}{input.sliderLabel}</span>}
value={inputValues[input.name] as number[]}
onValueChange={handleDualRangeChange(input.name)}
min={input.min}
max={input.max}
step={input.step || 1}
/>
</div>
</div>
);
case "radio-group":
return (
<div
key={input.name}
className="flex flex-col gap-2 p-2 border rounded-md"
>
<Label className="font-semibold text-sm">
{input.label}
</Label>
<RadioGroup
value={inputValues[input.name] as string}
onValueChange={(value) =>
setInputValues((prev) => ({
...prev,
[input.name]: value,
}))
}
>
{input.options.map((option) => (
<div
key={`${input.name}-${option.value}`}
className="flex items-center space-x-2"
>
<RadioGroupItem
value={option.value}
id={`${input.name}-${option.value}`}
/>
<Label htmlFor={`${input.name}-${option.value}`}>
{option.label}
</Label>
</div>
))}
</RadioGroup>
</div>
);
default:
return null;
}
})}
</div>
<DrawerFooter className="max-w-sm mx-auto">
<Button onClick={handleApplyFilters} disabled={disabled}>
{disabled ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Applying...
</>
) : (
<>Apply Filters</>
)}
</Button>
<Button variant="secondary" onClick={handleClearFilters}>
Clear Filters
</Button>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
<div className="flex gap-2 w-fit flex-wrap">
{appliedFilters.map((filter) => (
<Badge
aria-disabled={disabled}
variant={"outline"}
className={cn("flex p-2 gap-2 items-center justify-between", {
"opacity-50 pointer-events-none": disabled,
})}
key={filter.key}
>
<span className="text-md">
{prettyPrintFilter(filter.key, filter.value, filter.config)}
</span>
{disabled ? (
<Loader2 className="animate-spin" size={16} />
) : (
<X
className="bg-sarLinkOrange/50 rounded-full p-1 hover:cursor-pointer hover:ring ring-sarLinkOrange"
size={16}
onClick={() => handleRemoveFilter(filter.key)}
>
Remove
</X>
)}
</Badge>
))}
</div>
</div>
);
}