mirror of
https://github.com/i701/sarlink-portal.git
synced 2025-07-01 15:23:58 +00:00
refactor: streamline package.json and tailwind.config.ts
Some checks failed
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 1m56s
Some checks failed
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 1m56s
- Simplified dependencies in package.json by removing unused packages and organizing existing ones. - Updated tailwind.config.ts for better readability and maintainability, ensuring consistent formatting and structure.
This commit is contained in:
452
components/generic-filter.tsx
Normal file
452
components/generic-filter.tsx
Normal file
@ -0,0 +1,452 @@
|
||||
"use client";
|
||||
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"; // Assuming shadcn 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 {
|
||||
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;
|
||||
}
|
||||
|
||||
// --- Main DynamicFilter Component ---
|
||||
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"); // Always reset page on filter apply
|
||||
|
||||
startTransition(() => {
|
||||
replace(`${pathname}?${newParams.toString()}`);
|
||||
setIsOpen(false); // Close the drawer after applying filters
|
||||
});
|
||||
};
|
||||
|
||||
// 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;
|
||||
} else {
|
||||
(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
|
||||
});
|
||||
};
|
||||
|
||||
// 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 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>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user