chore: upgrade to tailwind v4 and add a generic filter for dynamic filter handling
All checks were successful
Build and Push Docker Images / Build and Push Docker Images (push) Successful in 7m51s

This commit is contained in:
2025-06-27 14:27:44 +05:00
parent 9e0d2d277b
commit 11ac852762
36 changed files with 1552 additions and 1376 deletions

View File

@ -1,4 +1,7 @@
"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";
@ -13,11 +16,8 @@ import {
DrawerTrigger,
} from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; // Assuming shadcn Label
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { ListFilter, Loader2, X } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
interface CheckboxGroupProps {
@ -48,7 +48,10 @@ const CheckboxGroup: React.FC<CheckboxGroupProps> = ({
<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">
<div
key={`${name}-${option.value}`}
className="flex items-center space-x-2"
>
<Checkbox
id={`${name}-${option.value}`}
checked={selectedValues.includes(option.value)}
@ -64,7 +67,6 @@ const CheckboxGroup: React.FC<CheckboxGroupProps> = ({
);
};
type FilterOption = { value: string; label: string };
type FilterInputConfig<TKey extends string> =
@ -90,8 +92,10 @@ type FilterInputConfig<TKey extends string> =
};
// 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>
type GetConfigForKey<
TKey,
TConfigArray extends FilterInputConfig<string>[],
> = TConfigArray extends ReadonlyArray<infer U>
? U extends FilterInputConfig<string> & { name: TKey }
? U
: never
@ -102,7 +106,9 @@ type FilterValues<
TFilterKeys extends string,
TInputs extends FilterInputConfig<string>[],
> = {
[K in TFilterKeys]: GetConfigForKey<K, TInputs> extends { type: "checkbox-group" }
[K in TFilterKeys]: GetConfigForKey<K, TInputs> extends {
type: "checkbox-group";
}
? string[]
: GetConfigForKey<K, TInputs> extends { type: "radio-group" }
? string
@ -124,7 +130,6 @@ interface DynamicFilterProps<
description?: string;
}
// --- Main DynamicFilter Component ---
export default function DynamicFilter<
TFilterKeys extends string,
TInputs extends FilterInputConfig<TFilterKeys>[],
@ -153,17 +158,24 @@ export default function DynamicFilter<
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];
(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];
(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);
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
@ -173,7 +185,6 @@ export default function DynamicFilter<
}
}, [isOpen, initialInputState]);
// Handler for text/number input changes
const handleTextOrNumberInputChange =
(name: TFilterKeys) => (e: React.ChangeEvent<HTMLInputElement>) => {
@ -217,31 +228,34 @@ export default function DynamicFilter<
}
}
newParams.set("page", "1"); // Always reset page on filter apply
newParams.set("page", "1");
startTransition(() => {
replace(`${pathname}?${newParams.toString()}`);
setIsOpen(false); // Close the drawer after applying filters
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 any;
(clearedInputState as FilterValues<TFilterKeys, TInputs>)[
input.name as TFilterKeys
] = [] as any;
} else {
(clearedInputState as FilterValues<TFilterKeys, TInputs>)[input.name as TFilterKeys] = "" as any;
(clearedInputState as FilterValues<TFilterKeys, TInputs>)[
input.name as TFilterKeys
] = "" as any;
}
}
setInputValues(clearedInputState as FilterValues<TFilterKeys, TInputs>);
startTransition(() => {
replace(pathname); // Navigate to base path
setIsOpen(false); // Close the drawer
replace(pathname);
setIsOpen(false);
});
};
@ -289,13 +303,12 @@ export default function DynamicFilter<
) => {
if (config.type === "checkbox-group") {
const labels = (value as string[])
.map(
(v) => config.options.find((opt) => opt.value === v)?.label || v,
)
.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>
{config.label.replace(/%20/g, " ")}:{" "}
<span className="text-muted-foreground">{labels}</span>
</p>
);
}
@ -373,8 +386,13 @@ export default function DynamicFilter<
);
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>
<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) =>
@ -385,9 +403,17 @@ export default function DynamicFilter<
}
>
{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
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>
@ -424,10 +450,9 @@ export default function DynamicFilter<
<Badge
aria-disabled={disabled}
variant={"outline"}
className={cn(
"flex p-2 gap-2 items-center justify-between",
{ "opacity-50 pointer-events-none": disabled },
)}
className={cn("flex p-2 gap-2 items-center justify-between", {
"opacity-50 pointer-events-none": disabled,
})}
key={filter.key}
>
<span className="text-md">
@ -449,4 +474,4 @@ export default function DynamicFilter<
</div>
</div>
);
}
}