mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 13:01:56 +00:00
235 lines
8.0 KiB
TypeScript
235 lines
8.0 KiB
TypeScript
"use client";
|
|
|
|
import { LoaderCircleIcon, SearchIcon, X } from "lucide-react";
|
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
import { useEffect, useId, useRef, useState } from "react";
|
|
|
|
import { Badge } from "@/components/shadcn/badge/badge";
|
|
import { Input } from "@/components/shadcn/input/input";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/shadcn/tooltip";
|
|
import { useUrlFilters } from "@/hooks/use-url-filters";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const SEARCH_DEBOUNCE_MS = 500;
|
|
|
|
interface DataTableSearchProps {
|
|
/** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsSearch") */
|
|
paramPrefix?: string;
|
|
|
|
/*
|
|
* Controlled mode: Use these props to manage search via React state
|
|
* instead of URL params. Useful for tables in drawers/modals to avoid
|
|
* triggering page re-renders when searching.
|
|
*/
|
|
controlledValue?: string;
|
|
onSearchChange?: (value: string) => void;
|
|
/**
|
|
* Called when the user commits a search (pressing Enter).
|
|
* When provided, the search is only "committed" on Enter, while
|
|
* onSearchChange still fires on every keystroke for responsive display.
|
|
* Use this to avoid remounting child components on every keystroke.
|
|
*/
|
|
onSearchCommit?: (value: string) => void;
|
|
placeholder?: string;
|
|
/** Badge shown inside the search input (e.g., active drill-down group title) */
|
|
badge?: { label: string; onDismiss: () => void };
|
|
}
|
|
|
|
export const DataTableSearch = ({
|
|
paramPrefix = "",
|
|
controlledValue,
|
|
onSearchChange,
|
|
onSearchCommit,
|
|
placeholder = "Search... (press Enter)",
|
|
badge,
|
|
}: DataTableSearchProps) => {
|
|
const searchParams = useSearchParams();
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
const { updateFilter } = useUrlFilters();
|
|
const [internalValue, setInternalValue] = useState("");
|
|
// In controlled mode, track display value separately for immediate feedback
|
|
const [displayValue, setDisplayValue] = useState(controlledValue ?? "");
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isFocused, setIsFocused] = useState(false);
|
|
const id = useId();
|
|
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Use controlled value if provided, otherwise internal state
|
|
const isControlled = controlledValue !== undefined && onSearchChange;
|
|
// For display: use displayValue in controlled mode (for responsive typing), internalValue otherwise
|
|
const value = isControlled ? displayValue : internalValue;
|
|
|
|
// Force expanded when badge is present
|
|
const hasBadge = !!badge;
|
|
|
|
// Sync displayValue when controlledValue changes externally (e.g., clear filters)
|
|
useEffect(() => {
|
|
if (isControlled) {
|
|
setDisplayValue(controlledValue);
|
|
}
|
|
}, [controlledValue, isControlled]);
|
|
|
|
// Determine param names based on prefix
|
|
const searchParam = paramPrefix ? `${paramPrefix}Search` : "filter[search]";
|
|
const pageParam = paramPrefix ? `${paramPrefix}Page` : "page";
|
|
|
|
// Sync with URL on mount (only for uncontrolled mode)
|
|
useEffect(() => {
|
|
if (isControlled) return;
|
|
const searchFromUrl = searchParams.get(searchParam) || "";
|
|
setInternalValue(searchFromUrl);
|
|
}, [searchParams, searchParam, isControlled]);
|
|
|
|
// Handle input change with debounce
|
|
const handleChange = (newValue: string) => {
|
|
if (isControlled) {
|
|
setDisplayValue(newValue);
|
|
|
|
if (debounceTimeoutRef.current) {
|
|
clearTimeout(debounceTimeoutRef.current);
|
|
}
|
|
|
|
if (onSearchCommit) {
|
|
// Enter-to-commit mode: sync parent immediately (no debounce, no loading).
|
|
// The actual search commit happens on Enter via onSearchCommit.
|
|
onSearchChange(newValue);
|
|
return;
|
|
}
|
|
|
|
// Standard controlled mode: debounce the callback
|
|
setIsLoading(true);
|
|
debounceTimeoutRef.current = setTimeout(() => {
|
|
onSearchChange(newValue);
|
|
setIsLoading(false);
|
|
}, SEARCH_DEBOUNCE_MS);
|
|
return;
|
|
}
|
|
|
|
// Uncontrolled mode: only update display value on keystroke.
|
|
// The actual URL update happens on Enter (see onKeyDown handler).
|
|
setInternalValue(newValue);
|
|
};
|
|
|
|
// Cleanup timeout on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (debounceTimeoutRef.current) {
|
|
clearTimeout(debounceTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const handleFocus = () => {
|
|
setIsFocused(true);
|
|
};
|
|
|
|
const handleBlur = () => {
|
|
setIsFocused(false);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"relative flex items-center",
|
|
hasBadge ? "w-[28rem]" : "w-64",
|
|
)}
|
|
>
|
|
<div className="relative w-full">
|
|
<div
|
|
className={cn(
|
|
"border-border-neutral-tertiary bg-bg-neutral-tertiary hover:bg-bg-neutral-secondary flex items-center gap-1.5 rounded-md border transition-colors",
|
|
isFocused && "border-border-input-primary-pressed",
|
|
)}
|
|
>
|
|
<div className="flex shrink-0 items-center pl-3">
|
|
<SearchIcon className="text-text-neutral-tertiary size-4" />
|
|
</div>
|
|
|
|
{hasBadge && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Badge
|
|
variant="tag"
|
|
className="max-w-[200px] shrink-0 cursor-default gap-1 truncate"
|
|
>
|
|
<span className="truncate">{badge.label}</span>
|
|
<button
|
|
type="button"
|
|
aria-label="Dismiss filter"
|
|
className="hover:text-text-neutral-primary ml-0.5 shrink-0"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
badge.onDismiss();
|
|
}}
|
|
>
|
|
<X className="size-3" />
|
|
</button>
|
|
</Badge>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{badge.label}</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
|
|
<Input
|
|
ref={inputRef}
|
|
id={id}
|
|
type="search"
|
|
placeholder={placeholder}
|
|
value={value}
|
|
onChange={(e) => handleChange(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key !== "Enter") return;
|
|
|
|
// Cancel any pending debounce — Enter commits immediately
|
|
if (debounceTimeoutRef.current) {
|
|
clearTimeout(debounceTimeoutRef.current);
|
|
debounceTimeoutRef.current = null;
|
|
}
|
|
setIsLoading(false);
|
|
|
|
// Controlled mode with explicit commit callback
|
|
if (isControlled && onSearchCommit) {
|
|
onSearchCommit(value);
|
|
return;
|
|
}
|
|
|
|
// Uncontrolled mode: immediate URL update (shortcut for debounce)
|
|
if (!isControlled) {
|
|
if (paramPrefix) {
|
|
const params = new URLSearchParams(searchParams.toString());
|
|
if (value) {
|
|
params.set(searchParam, value);
|
|
} else {
|
|
params.delete(searchParam);
|
|
}
|
|
params.set(pageParam, "1");
|
|
router.push(`${pathname}?${params.toString()}`, {
|
|
scroll: false,
|
|
});
|
|
} else {
|
|
updateFilter("search", value || null);
|
|
}
|
|
}
|
|
}}
|
|
onFocus={handleFocus}
|
|
onBlur={handleBlur}
|
|
className="h-9 min-w-0 flex-1 border-0 bg-transparent pr-9 shadow-none hover:bg-transparent focus:border-0 focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none"
|
|
/>
|
|
|
|
{isLoading && (
|
|
<div className="flex shrink-0 items-center pr-3">
|
|
<LoaderCircleIcon className="text-text-neutral-tertiary size-4 animate-spin" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|