mirror of
https://github.com/i701/sarlink-portal.git
synced 2025-07-01 02:54:52 +00:00
feat: add dual range slider component and integrate it into dynamic filter for device management
All checks were successful
Build and Push Docker Images / Build and Push Docker Images (push) Successful in 4m49s
All checks were successful
Build and Push Docker Images / Build and Push Docker Images (push) Successful in 4m49s
This commit is contained in:
@ -15,6 +15,7 @@ import {
|
||||
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";
|
||||
@ -89,6 +90,16 @@ type FilterInputConfig<TKey extends string> =
|
||||
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
|
||||
@ -112,6 +123,8 @@ type FilterValues<
|
||||
? string[]
|
||||
: GetConfigForKey<K, TInputs> extends { type: "radio-group" }
|
||||
? string
|
||||
: GetConfigForKey<K, TInputs> extends { type: "dual-range-slider" }
|
||||
? number[]
|
||||
: string;
|
||||
};
|
||||
|
||||
@ -163,6 +176,16 @@ export default function DynamicFilter<
|
||||
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<
|
||||
@ -203,6 +226,15 @@ export default function DynamicFilter<
|
||||
}));
|
||||
};
|
||||
|
||||
// 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
|
||||
@ -218,6 +250,16 @@ export default function DynamicFilter<
|
||||
} 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) {
|
||||
@ -245,6 +287,10 @@ export default function DynamicFilter<
|
||||
(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
|
||||
@ -263,7 +309,7 @@ export default function DynamicFilter<
|
||||
const appliedFilters = useMemo(() => {
|
||||
const filters: Array<{
|
||||
key: TFilterKeys;
|
||||
value: string | string[];
|
||||
value: string | string[] | number[];
|
||||
label: string;
|
||||
config: FilterInputConfig<TFilterKeys>;
|
||||
}> = [];
|
||||
@ -282,6 +328,19 @@ export default function DynamicFilter<
|
||||
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,
|
||||
@ -298,7 +357,7 @@ export default function DynamicFilter<
|
||||
// Dynamic `prettyPrintFilter` for badges
|
||||
const prettyPrintFilter = (
|
||||
_key: TFilterKeys,
|
||||
value: string | string[],
|
||||
value: string | string[] | number[],
|
||||
config: FilterInputConfig<TFilterKeys>,
|
||||
) => {
|
||||
if (config.type === "checkbox-group") {
|
||||
@ -312,6 +371,18 @@ export default function DynamicFilter<
|
||||
</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>
|
||||
@ -322,15 +393,26 @@ export default function DynamicFilter<
|
||||
// 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" ? [] : "",
|
||||
}));
|
||||
|
||||
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()}`);
|
||||
@ -384,6 +466,27 @@ export default function DynamicFilter<
|
||||
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
|
||||
|
50
components/ui/dual-range-slider.tsx
Normal file
50
components/ui/dual-range-slider.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DualRangeSliderProps extends React.ComponentProps<typeof SliderPrimitive.Root> {
|
||||
labelPosition?: 'top' | 'bottom';
|
||||
label?: (value: number | undefined) => React.ReactNode;
|
||||
}
|
||||
|
||||
const DualRangeSlider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
DualRangeSliderProps
|
||||
>(({ className, label, labelPosition = 'top', ...props }, ref) => {
|
||||
const initialValue = Array.isArray(props.value) ? props.value : [props.min, props.max];
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative flex w-full touch-none select-none items-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
{initialValue.map((value, index) => (
|
||||
<React.Fragment key={`${index + 1}`}>
|
||||
<SliderPrimitive.Thumb className="relative block h-4 w-4 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50">
|
||||
{label && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute flex w-full justify-center',
|
||||
labelPosition === 'top' && '-top-7',
|
||||
labelPosition === 'bottom' && 'top-4',
|
||||
)}
|
||||
>
|
||||
{label(value)}
|
||||
</span>
|
||||
)}
|
||||
</SliderPrimitive.Thumb>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
});
|
||||
DualRangeSlider.displayName = 'DualRangeSlider';
|
||||
|
||||
export { DualRangeSlider };
|
Reference in New Issue
Block a user