mirror of
https://github.com/i701/sarlink-portal.git
synced 2025-07-01 09:13:57 +00:00
All checks were successful
Build and Push Docker Images / Build and Push Docker Images (push) Successful in 7m18s
478 lines
15 KiB
TypeScript
478 lines
15 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 { 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[];
|
|
};
|
|
|
|
// 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
|
|
: 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 {
|
|
(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,
|
|
}));
|
|
};
|
|
|
|
// 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<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 === "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[];
|
|
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 {
|
|
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<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>
|
|
);
|
|
}
|
|
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());
|
|
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 (
|
|
<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 "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>
|
|
);
|
|
}
|