mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
394 lines
14 KiB
TypeScript
394 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
ColumnDef,
|
|
ColumnFiltersState,
|
|
ExpandedState,
|
|
flexRender,
|
|
getCoreRowModel,
|
|
getExpandedRowModel,
|
|
getFilteredRowModel,
|
|
getSortedRowModel,
|
|
OnChangeFn,
|
|
Row,
|
|
RowSelectionState,
|
|
SortingState,
|
|
useReactTable,
|
|
} from "@tanstack/react-table";
|
|
import { AnimatePresence } from "framer-motion";
|
|
import type { ReactNode } from "react";
|
|
import { Fragment, useEffect, useRef, useState } from "react";
|
|
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { DataTableAnimatedRow } from "@/components/ui/table/data-table-animated-row";
|
|
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
|
|
import { DataTableSearch } from "@/components/ui/table/data-table-search";
|
|
import { useFilterTransitionOptional } from "@/contexts";
|
|
import { cn } from "@/lib";
|
|
import { FilterOption, MetaDataProps } from "@/types";
|
|
|
|
type DataTableRowAttributes = {
|
|
[key: `data-${string}`]: string | undefined;
|
|
};
|
|
|
|
/**
|
|
* Default column size used by TanStack Table when no explicit size is set.
|
|
* We skip applying inline width styles for columns with this default value
|
|
* to allow them to flex naturally within the table layout.
|
|
*/
|
|
const DEFAULT_COLUMN_SIZE = 150;
|
|
const ACTIONS_COLUMN_ID = "actions";
|
|
const STICKY_ACTION_COLUMN_CLASS = "sticky right-0 z-20 min-w-12";
|
|
const STICKY_ACTION_CELL_CLASS = `${STICKY_ACTION_COLUMN_CLASS} overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary group-data-[state=selected]:bg-bg-neutral-tertiary group-data-[state=selected]:before:to-bg-neutral-tertiary`;
|
|
|
|
const getStickyActionColumnClassName = (
|
|
columnId: string,
|
|
variant: "header" | "cell",
|
|
) => {
|
|
if (columnId !== ACTIONS_COLUMN_ID) return undefined;
|
|
|
|
return variant === "header" ? undefined : STICKY_ACTION_CELL_CLASS;
|
|
};
|
|
|
|
interface DataTableProviderProps<TData, TValue> {
|
|
columns: ColumnDef<TData, TValue>[];
|
|
data: TData[];
|
|
metadata?: MetaDataProps;
|
|
customFilters?: FilterOption[];
|
|
disableScroll?: boolean;
|
|
enableRowSelection?: boolean;
|
|
rowSelection?: RowSelectionState;
|
|
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
|
|
/** Function to determine if a row can be selected */
|
|
getRowCanSelect?: (row: Row<TData>) => boolean;
|
|
/** Show search bar in the table toolbar */
|
|
showSearch?: boolean;
|
|
/** Function to extract sub-rows from a row for hierarchical data */
|
|
getSubRows?: (row: TData) => TData[] | undefined;
|
|
/** Controlled expanded state */
|
|
expanded?: ExpandedState;
|
|
/** Callback when expanded state changes */
|
|
onExpandedChange?: OnChangeFn<ExpandedState>;
|
|
/** Auto-select children when parent selected (default: true) */
|
|
enableSubRowSelection?: boolean;
|
|
/** Expand all rows by default, or provide specific expanded state */
|
|
defaultExpanded?: boolean | ExpandedState;
|
|
/** 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;
|
|
/**
|
|
* Called when the user commits a search by pressing Enter.
|
|
* Use this alongside onSearchChange to implement "search on Enter" behavior.
|
|
*/
|
|
onSearchCommit?: (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;
|
|
/** Custom placeholder text for the search input */
|
|
searchPlaceholder?: string;
|
|
/** Render additional content after each row (e.g., inline expansion) */
|
|
renderAfterRow?: (row: Row<TData>) => ReactNode;
|
|
/** Badge shown inside the search input (e.g., active drill-down group) */
|
|
searchBadge?: { label: string; onDismiss: () => void };
|
|
/** Optional click handler for top-level rows. */
|
|
onRowClick?: (row: Row<TData>) => void;
|
|
/** Optional data attributes applied to each top-level row. */
|
|
getRowAttributes?: (row: Row<TData>) => DataTableRowAttributes;
|
|
/** Optional header rendered inside the table container, above the toolbar. */
|
|
header?: ReactNode;
|
|
/** Optional content rendered in the toolbar before the total entries count. */
|
|
toolbarRightContent?: ReactNode;
|
|
}
|
|
|
|
export function DataTable<TData, TValue>({
|
|
columns,
|
|
data,
|
|
metadata,
|
|
disableScroll = false,
|
|
enableRowSelection = false,
|
|
rowSelection,
|
|
onRowSelectionChange,
|
|
getRowCanSelect,
|
|
showSearch = false,
|
|
getSubRows,
|
|
expanded: controlledExpanded,
|
|
onExpandedChange,
|
|
enableSubRowSelection = true,
|
|
defaultExpanded,
|
|
paramPrefix = "",
|
|
controlledSearch,
|
|
onSearchChange,
|
|
onSearchCommit,
|
|
controlledPage,
|
|
controlledPageSize,
|
|
onPageChange,
|
|
onPageSizeChange,
|
|
isLoading = false,
|
|
searchPlaceholder,
|
|
renderAfterRow,
|
|
searchBadge,
|
|
onRowClick,
|
|
getRowAttributes,
|
|
header,
|
|
toolbarRightContent,
|
|
}: DataTableProviderProps<TData, TValue>) {
|
|
const [sorting, setSorting] = useState<SortingState>([]);
|
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
|
// ExpandedState should be a Record<string, boolean> for individual row control
|
|
// Note: We don't use `true` (boolean) as it makes rows permanently expanded
|
|
const [expanded, setExpanded] = useState<ExpandedState>(() => {
|
|
if (typeof defaultExpanded === "object") return defaultExpanded;
|
|
return {};
|
|
});
|
|
|
|
// Get transition state from context for loading indicator
|
|
const filterTransition = useFilterTransitionOptional();
|
|
// Use either context-based pending state or controlled isLoading prop
|
|
const isPending = (filterTransition?.isPending ?? false) || isLoading;
|
|
|
|
const table = useReactTable({
|
|
data,
|
|
columns,
|
|
enableSorting: true,
|
|
enableRowSelection: getRowCanSelect ?? enableRowSelection,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
onSortingChange: setSorting,
|
|
getSortedRowModel: getSortedRowModel(),
|
|
onColumnFiltersChange: setColumnFilters,
|
|
getFilteredRowModel: getFilteredRowModel(),
|
|
onRowSelectionChange,
|
|
manualPagination: true,
|
|
// Expansion support for hierarchical data
|
|
getSubRows,
|
|
getExpandedRowModel: getSubRows ? getExpandedRowModel() : undefined,
|
|
enableSubRowSelection,
|
|
onExpandedChange: onExpandedChange ?? setExpanded,
|
|
state: {
|
|
sorting,
|
|
columnFilters,
|
|
rowSelection: rowSelection ?? {},
|
|
expanded: controlledExpanded ?? expanded,
|
|
},
|
|
});
|
|
|
|
// Track whether initial expansion has been applied
|
|
const hasInitiallyExpanded = useRef(false);
|
|
|
|
// Expand all rows on mount when defaultExpanded={true}
|
|
useEffect(() => {
|
|
if (
|
|
!hasInitiallyExpanded.current &&
|
|
defaultExpanded === true &&
|
|
getSubRows
|
|
) {
|
|
table.toggleAllRowsExpanded(true);
|
|
hasInitiallyExpanded.current = true;
|
|
}
|
|
}, [defaultExpanded, getSubRows, table]);
|
|
|
|
// Calculate selection key to force header re-render when selection changes
|
|
const selectionKey = rowSelection
|
|
? Object.keys(rowSelection).filter((k) => rowSelection[k]).length
|
|
: 0;
|
|
|
|
// Calculate expansion key to force header re-render when expansion changes
|
|
const currentExpanded = controlledExpanded ?? expanded;
|
|
const expansionKey =
|
|
currentExpanded === true
|
|
? "all"
|
|
: typeof currentExpanded === "object"
|
|
? Object.keys(currentExpanded).filter((k) => currentExpanded[k]).length
|
|
: 0;
|
|
|
|
// Format total entries count
|
|
const totalEntries = metadata?.pagination?.count ?? 0;
|
|
const formattedTotal = totalEntries.toLocaleString();
|
|
const showToolbar = showSearch || metadata || toolbarRightContent;
|
|
|
|
const rows = table.getRowModel().rows;
|
|
|
|
const handleRowClick = (row: Row<TData>, target: HTMLElement | null) => {
|
|
if (!onRowClick) {
|
|
return;
|
|
}
|
|
|
|
if (target?.closest("a, button, input, [role=menuitem]")) {
|
|
return;
|
|
}
|
|
|
|
onRowClick(row);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col justify-between gap-4 overflow-auto border p-4 transition-opacity duration-200",
|
|
isPending && "pointer-events-none opacity-60",
|
|
)}
|
|
>
|
|
{header && <div className="w-full">{header}</div>}
|
|
{/* Table Toolbar */}
|
|
{showToolbar && (
|
|
<div
|
|
data-testid="data-table-toolbar"
|
|
className="flex flex-col items-start gap-3 md:flex-row md:items-center md:justify-between"
|
|
>
|
|
<div className="w-full md:w-auto">
|
|
{showSearch && (
|
|
<DataTableSearch
|
|
paramPrefix={paramPrefix}
|
|
controlledValue={controlledSearch}
|
|
onSearchChange={onSearchChange}
|
|
onSearchCommit={onSearchCommit}
|
|
placeholder={searchPlaceholder}
|
|
badge={searchBadge}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div
|
|
data-testid="data-table-toolbar-right"
|
|
className="flex w-full flex-col items-start gap-2 md:ml-auto md:w-auto md:flex-row md:items-center md:gap-4"
|
|
>
|
|
{toolbarRightContent}
|
|
{metadata && (
|
|
<span className="text-text-neutral-secondary text-sm whitespace-nowrap">
|
|
{formattedTotal} Total Entries
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<Table className={getSubRows ? "table-fixed" : undefined}>
|
|
<TableHeader>
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
<TableRow key={`${headerGroup.id}-${selectionKey}-${expansionKey}`}>
|
|
{headerGroup.headers.map((header) => {
|
|
const size = header.getSize();
|
|
const isActionsHeader = header.column.id === ACTIONS_COLUMN_ID;
|
|
return (
|
|
<TableHead
|
|
key={header.id}
|
|
className={getStickyActionColumnClassName(
|
|
header.column.id,
|
|
"header",
|
|
)}
|
|
style={
|
|
getSubRows && size !== DEFAULT_COLUMN_SIZE
|
|
? { width: `${size}px` }
|
|
: undefined
|
|
}
|
|
>
|
|
{header.isPlaceholder || isActionsHeader
|
|
? null
|
|
: flexRender(
|
|
header.column.columnDef.header,
|
|
header.getContext(),
|
|
)}
|
|
</TableHead>
|
|
);
|
|
})}
|
|
</TableRow>
|
|
))}
|
|
</TableHeader>
|
|
<TableBody>
|
|
<AnimatePresence initial={false}>
|
|
{rows?.length ? (
|
|
rows.map((row) =>
|
|
getSubRows && row.depth > 0 ? (
|
|
<DataTableAnimatedRow
|
|
key={row.id}
|
|
row={row}
|
|
isSelected={row.getIsSelected()}
|
|
isSomeSelected={row.getIsSomeSelected()}
|
|
/>
|
|
) : (
|
|
<Fragment key={row.id}>
|
|
<TableRow
|
|
{...getRowAttributes?.(row)}
|
|
data-state={row.getIsSelected() && "selected"}
|
|
className={cn("group", onRowClick && "cursor-pointer")}
|
|
onClick={(event) =>
|
|
handleRowClick(row, event.target as HTMLElement)
|
|
}
|
|
>
|
|
{row.getVisibleCells().map((cell) => (
|
|
<TableCell
|
|
key={cell.id}
|
|
className={getStickyActionColumnClassName(
|
|
cell.column.id,
|
|
"cell",
|
|
)}
|
|
>
|
|
{flexRender(
|
|
cell.column.columnDef.cell,
|
|
cell.getContext(),
|
|
)}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
{renderAfterRow?.(row)}
|
|
</Fragment>
|
|
),
|
|
)
|
|
) : (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={columns.length}
|
|
className="h-24 text-center"
|
|
>
|
|
No results.
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</AnimatePresence>
|
|
</TableBody>
|
|
</Table>
|
|
{metadata && (
|
|
<DataTablePagination
|
|
metadata={metadata}
|
|
disableScroll={disableScroll}
|
|
paramPrefix={paramPrefix}
|
|
controlledPage={controlledPage}
|
|
controlledPageSize={controlledPageSize}
|
|
onPageChange={onPageChange}
|
|
onPageSizeChange={onPageSizeChange}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|