mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-01-25 02:08:11 +00:00
feat(ui): add controlled mode to DataTable components
This commit is contained in:
@@ -26,6 +26,16 @@ interface DataTablePaginationProps {
|
||||
disableScroll?: boolean;
|
||||
/** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsPage") */
|
||||
paramPrefix?: string;
|
||||
|
||||
/*
|
||||
* Controlled mode: Use these props to manage pagination via React state
|
||||
* instead of URL params. Useful for tables in drawers/modals to avoid
|
||||
* triggering page re-renders when paginating.
|
||||
*/
|
||||
controlledPage?: number;
|
||||
controlledPageSize?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
}
|
||||
|
||||
const NAV_BUTTON_STYLES = {
|
||||
@@ -38,16 +48,25 @@ export function DataTablePagination({
|
||||
metadata,
|
||||
disableScroll = false,
|
||||
paramPrefix = "",
|
||||
controlledPage,
|
||||
controlledPageSize,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
}: DataTablePaginationProps) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
// Determine if we're in controlled mode
|
||||
const isControlled = controlledPage !== undefined && onPageChange;
|
||||
|
||||
// Determine param names based on prefix
|
||||
const pageParam = paramPrefix ? `${paramPrefix}Page` : "page";
|
||||
const pageSizeParam = paramPrefix ? `${paramPrefix}PageSize` : "pageSize";
|
||||
|
||||
const initialPageSize = searchParams.get(pageSizeParam) ?? "10";
|
||||
const initialPageSize = isControlled
|
||||
? String(controlledPageSize ?? 10)
|
||||
: (searchParams.get(pageSizeParam) ?? "10");
|
||||
|
||||
const [selectedPageSize, setSelectedPageSize] = useState(initialPageSize);
|
||||
|
||||
@@ -60,10 +79,12 @@ export function DataTablePagination({
|
||||
itemsPerPageOptions,
|
||||
} = getPaginationInfo(metadata);
|
||||
|
||||
// For prefixed pagination, read current page from URL instead of metadata
|
||||
const currentPage = paramPrefix
|
||||
? parseInt(searchParams.get(pageParam) || "1", 10)
|
||||
: metaCurrentPage;
|
||||
// For controlled mode, use controlled values; for prefixed, read from URL; otherwise use metadata
|
||||
const currentPage = isControlled
|
||||
? controlledPage
|
||||
: paramPrefix
|
||||
? parseInt(searchParams.get(pageParam) || "1", 10)
|
||||
: metaCurrentPage;
|
||||
|
||||
const createPageUrl = (pageNumber: number | string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
@@ -87,6 +108,20 @@ export function DataTablePagination({
|
||||
return `${pathname}?${params.toString()}`;
|
||||
};
|
||||
|
||||
// Handle page navigation for controlled mode
|
||||
const handlePageChange = (pageNumber: number) => {
|
||||
if (isControlled) {
|
||||
onPageChange(pageNumber);
|
||||
} else {
|
||||
const url = createPageUrl(pageNumber);
|
||||
if (disableScroll) {
|
||||
router.push(url, { scroll: false });
|
||||
} else {
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isFirstPage = currentPage === 1;
|
||||
const isLastPage = currentPage === totalPages;
|
||||
|
||||
@@ -104,6 +139,12 @@ export function DataTablePagination({
|
||||
onValueChange={(value) => {
|
||||
setSelectedPageSize(value);
|
||||
|
||||
if (isControlled) {
|
||||
onPageSizeChange?.(parseInt(value, 10));
|
||||
onPageChange(1); // Reset to first page
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
// Preserve all important parameters
|
||||
@@ -154,82 +195,145 @@ export function DataTablePagination({
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
aria-label="Go to first page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isFirstPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
href={
|
||||
isFirstPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(1)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isFirstPage}
|
||||
onClick={(e) => isFirstPage && e.preventDefault()}
|
||||
>
|
||||
<ChevronFirst className="size-6" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Go to previous page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isFirstPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
href={
|
||||
isFirstPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(currentPage - 1)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isFirstPage}
|
||||
onClick={(e) => isFirstPage && e.preventDefault()}
|
||||
>
|
||||
<ChevronLeft className="size-6" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Go to next page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isLastPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
href={
|
||||
isLastPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(currentPage + 1)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isLastPage}
|
||||
onClick={(e) => isLastPage && e.preventDefault()}
|
||||
>
|
||||
<ChevronRight className="size-6" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Go to last page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isLastPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
href={
|
||||
isLastPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(totalPages)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isLastPage}
|
||||
onClick={(e) => isLastPage && e.preventDefault()}
|
||||
>
|
||||
<ChevronLast className="size-6" aria-hidden="true" />
|
||||
</Link>
|
||||
{isControlled ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Go to first page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isFirstPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
disabled={isFirstPage}
|
||||
onClick={() => handlePageChange(1)}
|
||||
>
|
||||
<ChevronFirst className="size-6" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Go to previous page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isFirstPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
disabled={isFirstPage}
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
>
|
||||
<ChevronLeft className="size-6" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Go to next page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isLastPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
disabled={isLastPage}
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
>
|
||||
<ChevronRight className="size-6" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Go to last page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isLastPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
disabled={isLastPage}
|
||||
onClick={() => handlePageChange(totalPages)}
|
||||
>
|
||||
<ChevronLast className="size-6" aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
aria-label="Go to first page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isFirstPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
href={
|
||||
isFirstPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(1)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isFirstPage}
|
||||
onClick={(e) => isFirstPage && e.preventDefault()}
|
||||
>
|
||||
<ChevronFirst className="size-6" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Go to previous page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isFirstPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
href={
|
||||
isFirstPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(currentPage - 1)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isFirstPage}
|
||||
onClick={(e) => isFirstPage && e.preventDefault()}
|
||||
>
|
||||
<ChevronLeft className="size-6" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Go to next page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isLastPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
href={
|
||||
isLastPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(currentPage + 1)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isLastPage}
|
||||
onClick={(e) => isLastPage && e.preventDefault()}
|
||||
>
|
||||
<ChevronRight className="size-6" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Go to last page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isLastPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
href={
|
||||
isLastPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(totalPages)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isLastPage}
|
||||
onClick={(e) => isLastPage && e.preventDefault()}
|
||||
>
|
||||
<ChevronLast className="size-6" aria-hidden="true" />
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -13,14 +13,26 @@ 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;
|
||||
}
|
||||
|
||||
export const DataTableSearch = ({ paramPrefix = "" }: DataTableSearchProps) => {
|
||||
export const DataTableSearch = ({
|
||||
paramPrefix = "",
|
||||
controlledValue,
|
||||
onSearchChange,
|
||||
}: DataTableSearchProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { updateFilter } = useUrlFilters();
|
||||
const [value, setValue] = useState("");
|
||||
const [internalValue, setInternalValue] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
@@ -28,6 +40,15 @@ export const DataTableSearch = ({ paramPrefix = "" }: DataTableSearchProps) => {
|
||||
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;
|
||||
const value = isControlled ? controlledValue : internalValue;
|
||||
const setValue = isControlled
|
||||
? (_v: string) => {
|
||||
/* no-op for controlled, handled in handleChange */
|
||||
}
|
||||
: setInternalValue;
|
||||
|
||||
// Determine param names based on prefix
|
||||
const searchParam = paramPrefix ? `${paramPrefix}Search` : "filter[search]";
|
||||
const pageParam = paramPrefix ? `${paramPrefix}Page` : "page";
|
||||
@@ -35,18 +56,35 @@ export const DataTableSearch = ({ paramPrefix = "" }: DataTableSearchProps) => {
|
||||
// Keep expanded if there's a value or input is focused
|
||||
const shouldStayExpanded = value.length > 0 || isFocused;
|
||||
|
||||
// Sync with URL on mount
|
||||
// Sync with URL on mount (only for uncontrolled mode)
|
||||
useEffect(() => {
|
||||
if (isControlled) return;
|
||||
const searchFromUrl = searchParams.get(searchParam) || "";
|
||||
setValue(searchFromUrl);
|
||||
setInternalValue(searchFromUrl);
|
||||
// If there's a search value, start expanded
|
||||
if (searchFromUrl) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [searchParams, searchParam]);
|
||||
}, [searchParams, searchParam, isControlled]);
|
||||
|
||||
// Handle input change with debounce
|
||||
const handleChange = (newValue: string) => {
|
||||
// For controlled mode, update internal display immediately and debounce callback
|
||||
if (isControlled) {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
setIsLoading(true);
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
onSearchChange(newValue);
|
||||
setIsLoading(false);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
// Update display immediately for responsive feel
|
||||
onSearchChange(newValue);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(newValue);
|
||||
|
||||
if (debounceTimeoutRef.current) {
|
||||
|
||||
@@ -44,6 +44,37 @@ interface DataTableProviderProps<TData, TValue> {
|
||||
showSearch?: boolean;
|
||||
/** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsPage") */
|
||||
paramPrefix?: string;
|
||||
|
||||
/*
|
||||
* Controlled Mode Props
|
||||
* ---------------------
|
||||
* By default, DataTable uses URL params for pagination/search (via paramPrefix).
|
||||
* This causes Next.js page re-renders on every interaction.
|
||||
*
|
||||
* For tables inside drawers/modals, use controlled mode instead:
|
||||
* - Pass controlledPage, controlledPageSize, controlledSearch as state values
|
||||
* - Pass onPageChange, onPageSizeChange, onSearchChange as state setters
|
||||
* - This keeps state local, avoiding URL changes and unnecessary page re-renders
|
||||
*
|
||||
* Example:
|
||||
* const [page, setPage] = useState(1);
|
||||
* const [search, setSearch] = useState("");
|
||||
* <DataTable
|
||||
* controlledPage={page}
|
||||
* onPageChange={setPage}
|
||||
* controlledSearch={search}
|
||||
* onSearchChange={setSearch}
|
||||
* isLoading={isLoading}
|
||||
* />
|
||||
*/
|
||||
controlledSearch?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
controlledPage?: number;
|
||||
controlledPageSize?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
/** Show loading state with opacity overlay (for controlled mode) */
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
@@ -57,13 +88,21 @@ export function DataTable<TData, TValue>({
|
||||
getRowCanSelect,
|
||||
showSearch = false,
|
||||
paramPrefix = "",
|
||||
controlledSearch,
|
||||
onSearchChange,
|
||||
controlledPage,
|
||||
controlledPageSize,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
isLoading = false,
|
||||
}: DataTableProviderProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
|
||||
// Get transition state from context for loading indicator
|
||||
const filterTransition = useFilterTransitionOptional();
|
||||
const isPending = filterTransition?.isPending ?? false;
|
||||
// Use either context-based pending state or controlled isLoading prop
|
||||
const isPending = (filterTransition?.isPending ?? false) || isLoading;
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
@@ -107,7 +146,13 @@ export function DataTable<TData, TValue>({
|
||||
{showToolbar && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{showSearch && <DataTableSearch paramPrefix={paramPrefix} />}
|
||||
{showSearch && (
|
||||
<DataTableSearch
|
||||
paramPrefix={paramPrefix}
|
||||
controlledValue={controlledSearch}
|
||||
onSearchChange={onSearchChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{metadata && (
|
||||
<span className="text-text-neutral-secondary text-sm">
|
||||
@@ -163,6 +208,10 @@ export function DataTable<TData, TValue>({
|
||||
metadata={metadata}
|
||||
disableScroll={disableScroll}
|
||||
paramPrefix={paramPrefix}
|
||||
controlledPage={controlledPage}
|
||||
controlledPageSize={controlledPageSize}
|
||||
onPageChange={onPageChange}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user