Files
prowler/ui/components/findings/table/findings-group-table.tsx
T
2026-03-30 14:17:36 +02:00

243 lines
7.6 KiB
TypeScript

"use client";
import { Row, RowSelectionState } from "@tanstack/react-table";
import { useRouter, useSearchParams } from "next/navigation";
import { useRef, useState } from "react";
import {
resolveFindingIds,
resolveFindingIdsByVisibleGroupResources,
} from "@/actions/findings/findings-by-resource";
import { DataTable } from "@/components/ui/table";
import { hasDateOrScanFilter } from "@/lib";
import { FindingGroupRow, MetaDataProps } from "@/types";
import { FloatingMuteButton } from "../floating-mute-button";
import { getColumnFindingGroups } from "./column-finding-groups";
import { FindingsSelectionContext } from "./findings-selection-context";
import {
InlineResourceContainer,
InlineResourceContainerHandle,
} from "./inline-resource-container";
function buildMuteLabel(groupCount: number, resourceCount: number): string {
const parts: string[] = [];
if (groupCount > 0) {
parts.push(`${groupCount} ${groupCount === 1 ? "Group" : "Groups"}`);
}
if (resourceCount > 0) {
parts.push(
`${resourceCount} ${resourceCount === 1 ? "Resource" : "Resources"}`,
);
}
return `Mute ${parts.join(" and ")}`;
}
interface FindingsGroupTableProps {
data: FindingGroupRow[];
metadata?: MetaDataProps;
}
export function FindingsGroupTable({
data,
metadata,
}: FindingsGroupTableProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [expandedCheckId, setExpandedCheckId] = useState<string | null>(null);
const [expandedGroup, setExpandedGroup] = useState<FindingGroupRow | null>(
null,
);
const [resourceSearch, setResourceSearch] = useState("");
const [resourceSelection, setResourceSelection] = useState<string[]>([]);
const inlineRef = useRef<InlineResourceContainerHandle>(null);
// State resets (selection, drill-down) are handled by the parent via
// key={groupKey} — when data changes, the component remounts with fresh state.
const safeData = data ?? [];
const hasResourceSelection = resourceSelection.length > 0;
const currentParams = Object.fromEntries(searchParams.entries());
const hasDateOrScan = hasDateOrScanFilter(currentParams);
const filters: Record<string, string> = {};
searchParams.forEach((value, key) => {
if (key.startsWith("filter[")) {
filters[key] = value;
}
});
// Get selected group check IDs. When the expanded group has individual resource
// selections, exclude it from group-level mute targets — the resource-level
// FloatingMuteButton handles those.
const selectedCheckIds = Object.keys(rowSelection)
.filter((key) => rowSelection[key])
.map((idx) => safeData[parseInt(idx)]?.checkId)
.filter(Boolean)
.filter(
(checkId) => !(hasResourceSelection && checkId === expandedCheckId),
);
const selectedFindings = Object.keys(rowSelection)
.filter((key) => rowSelection[key])
.map((idx) => safeData[parseInt(idx)])
.filter(Boolean);
// Count of selectable rows (groups where not ALL findings are muted)
const selectableRowCount = safeData.filter(
(g) => !(g.mutedCount > 0 && g.mutedCount === g.resourcesTotal),
).length;
const getRowCanSelect = (row: Row<FindingGroupRow>): boolean => {
const group = row.original;
return !(group.mutedCount > 0 && group.mutedCount === group.resourcesTotal);
};
const clearSelection = () => {
setRowSelection({});
};
const isSelected = (id: string) => {
return selectedCheckIds.includes(id);
};
const resolveGroupMuteIds = async (checkIds: string[]) => {
const results = await Promise.all(
checkIds.map((checkId) =>
resolveFindingIdsByVisibleGroupResources({
checkId,
filters,
hasDateOrScanFilter: hasDateOrScan,
resourceSearch:
checkId === expandedCheckId && resourceSearch
? resourceSearch
: undefined,
}),
),
);
return Array.from(new Set(results.flat()));
};
/** Shared resolver for group row action dropdowns (via context). */
const resolveMuteIds = async (checkIds: string[]) =>
resolveGroupMuteIds(checkIds);
const handleMuteComplete = () => {
clearSelection();
setResourceSelection([]);
inlineRef.current?.clearSelection();
inlineRef.current?.refresh();
router.refresh();
};
const handleDrillDown = (checkId: string, group: FindingGroupRow) => {
// Toggle: same group = collapse, different = switch
if (expandedCheckId === checkId) {
handleCollapse();
return;
}
setExpandedCheckId(checkId);
setExpandedGroup(group);
setResourceSearch("");
setResourceSelection([]);
};
const handleCollapse = () => {
setExpandedCheckId(null);
setExpandedGroup(null);
setResourceSearch("");
setResourceSelection([]);
};
const columns = getColumnFindingGroups({
rowSelection,
selectableRowCount,
onDrillDown: handleDrillDown,
expandedCheckId,
hasResourceSelection,
});
const renderAfterRow = (row: Row<FindingGroupRow>) => {
const group = row.original;
if (group.checkId !== expandedCheckId || !expandedGroup) return null;
return (
<InlineResourceContainer
ref={inlineRef}
key={`${group.checkId}|${searchParams.toString()}|${resourceSearch}`}
group={expandedGroup}
resourceSearch={resourceSearch}
columnCount={columns.length}
onResourceSelectionChange={setResourceSelection}
/>
);
};
return (
<FindingsSelectionContext.Provider
value={{
selectedFindingIds: selectedCheckIds,
selectedFindings,
clearSelection,
isSelected,
resolveMuteIds,
}}
>
<DataTable
columns={columns}
data={safeData}
metadata={metadata}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
getRowCanSelect={getRowCanSelect}
showSearch
searchPlaceholder={
expandedCheckId ? "Search resources..." : "Search by name"
}
controlledSearch={expandedCheckId ? resourceSearch : undefined}
onSearchChange={expandedCheckId ? setResourceSearch : undefined}
searchBadge={
expandedGroup
? { label: expandedGroup.checkTitle, onDismiss: handleCollapse }
: undefined
}
renderAfterRow={renderAfterRow}
/>
{(selectedCheckIds.length > 0 || hasResourceSelection) && (
<FloatingMuteButton
selectedCount={selectedCheckIds.length + resourceSelection.length}
selectedFindingIds={[...selectedCheckIds, ...resourceSelection]}
label={buildMuteLabel(
selectedCheckIds.length,
resourceSelection.length,
)}
onBeforeOpen={async () => {
const [groupIds, resourceIds] = await Promise.all([
selectedCheckIds.length > 0
? resolveGroupMuteIds(selectedCheckIds)
: Promise.resolve([]),
hasResourceSelection && expandedCheckId
? resolveFindingIds({
checkId: expandedCheckId,
resourceUids: resourceSelection,
filters,
hasDateOrScanFilter: hasDateOrScan,
})
: Promise.resolve([]),
]);
return [...groupIds, ...resourceIds];
}}
onComplete={handleMuteComplete}
isBulkOperation={
selectedCheckIds.length > 0 || resourceSelection.length > 1
}
/>
)}
</FindingsSelectionContext.Provider>
);
}