Files
prowler/ui/components/findings/table/findings-group-drill-down.tsx
T

307 lines
10 KiB
TypeScript

"use client";
import {
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { ChevronLeft } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import {
loadLatestFindingTriageNote,
updateFindingTriage,
} from "@/actions/findings";
import { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
SeverityBadge,
StatusFindingBadge,
} from "@/components/shadcn/table";
import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state";
import { cn, hasHistoricalFindingFilter } from "@/lib";
import { isGroupedJiraDispatchEnabled } from "@/lib/deployment";
import {
getFilteredFindingGroupDelta,
getFindingGroupImpactedCounts,
isFindingGroupMuted,
} from "@/lib/findings-groups";
import { createJiraTargetSelection } from "@/lib/jira-dispatch-selection";
import { FindingGroupRow } from "@/types";
import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations";
import { FloatingMuteButton } from "../floating-mute-button";
import { getColumnFindingResources } from "./column-finding-resources";
import { FindingsSelectionContext } from "./findings-selection-context";
import { ImpactedResourcesCell } from "./impacted-resources-cell";
import { getFindingGroupEmptyStateMessage } from "./inline-resource-container.utils";
import { NotificationIndicator } from "./notification-indicator";
import { ResourceDetailDrawer } from "./resource-detail-drawer";
interface FindingsGroupDrillDownProps {
group: FindingGroupRow;
onCollapse: () => void;
}
export function FindingsGroupDrillDown({
group,
onCollapse,
}: FindingsGroupDrillDownProps) {
const searchParams = useSearchParams();
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
const groupedJiraDispatchEnabled = isGroupedJiraDispatchEnabled();
// Keep drill-down endpoint selection aligned with the grouped findings page.
const currentParams = Object.fromEntries(searchParams.entries());
const hasHistoricalFilterActive = hasHistoricalFindingFilter(currentParams);
// Extract filter params from search params
const filters: Record<string, string> = {};
searchParams.forEach((value, key) => {
if (key.startsWith("filter[") || key.includes("__in")) {
filters[key] = value;
}
});
const {
rowSelection,
resources,
isLoading,
sentinelRef,
drawer,
handleDrawerMuteComplete,
selectedFindingIds,
selectableRowCount,
getRowCanSelect,
clearSelection,
isSelected,
handleMuteComplete,
handleRowSelectionChange,
resolveSelectedFindingIds,
updateTriageOptimistically,
} = useFindingGroupResourceState({
group,
filters,
hasHistoricalData: hasHistoricalFilterActive,
});
const columns = getColumnFindingResources({
rowSelection,
selectableRowCount,
findingTitle: group.checkTitle,
onTriageUpdateAction: (input) =>
updateTriageOptimistically(input, updateFindingTriage),
onTriageNoteLoadAction: loadLatestFindingTriageNote,
});
const table = useReactTable({
data: resources,
columns,
enableRowSelection: getRowCanSelect,
getCoreRowModel: getCoreRowModel(),
onRowSelectionChange: handleRowSelectionChange,
manualPagination: true,
state: {
rowSelection,
},
});
// Delta for the sticky header
const deltaKey = getFilteredFindingGroupDelta(group, filters);
const allMuted = isFindingGroupMuted(group);
const impactedCounts = getFindingGroupImpactedCounts(group);
const rows = table.getRowModel().rows;
const canSendSelectedFindingsToJira =
selectedFindingIds.length === 1 || groupedJiraDispatchEnabled;
const jiraSelection = createJiraTargetSelection(
selectedFindingIds,
JIRA_DISPATCH_TARGET.FINDING_ID,
);
return (
<FindingsSelectionContext.Provider
value={{
selectedFindingIds,
selectedFindings: [],
clearSelection,
isSelected,
resolveMuteIds: resolveSelectedFindingIds,
onMuteComplete: handleMuteComplete,
}}
>
<div
className={cn(
"minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary",
"flex w-full flex-col overflow-auto border",
)}
>
{/* Sticky header — expanded finding group summary */}
<div className="bg-bg-neutral-secondary border-border-neutral-secondary sticky top-0 z-10 border-b p-4">
<div className="flex items-center gap-3">
{/* Back button */}
<button
type="button"
aria-label="Collapse and go back to findings"
className="hover:bg-bg-neutral-tertiary flex size-8 items-center justify-center rounded-md transition-colors"
onClick={onCollapse}
>
<ChevronLeft className="text-text-neutral-secondary size-5" />
</button>
{/* Notification indicator */}
<NotificationIndicator
delta={deltaKey}
isMuted={allMuted}
showDeltaWhenMuted
/>
{/* Status badge */}
<StatusFindingBadge status={group.status} />
{/* Finding title */}
<div className="min-w-0 flex-1">
<p className="text-text-neutral-primary truncate text-sm font-medium">
{group.checkTitle}
</p>
</div>
{/* Severity */}
<SeverityBadge severity={group.severity} />
{/* Impacted resources count */}
<ImpactedResourcesCell
impacted={impactedCounts.impacted}
total={impactedCounts.total}
/>
</div>
</div>
{/* Resources table */}
<div className="p-4 pt-0">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{rows?.length ? (
rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer"
onClick={() => drawer.openDrawer(row.index)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : !isLoading ? (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{getFindingGroupEmptyStateMessage(group, filters)}
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
{/* Loading indicator */}
{isLoading && <LoadingState label="Loading resources..." />}
{/* Sentinel for infinite scroll */}
<div ref={sentinelRef} className="h-1" />
</div>
</div>
{selectedFindingIds.length > 0 && (
<FloatingMuteButton
selectedCount={selectedFindingIds.length}
selectedFindingIds={selectedFindingIds}
muteLabel={`Mute ${selectedFindingIds.length} ${
selectedFindingIds.length === 1 ? "Finding" : "Findings"
}`}
onBeforeOpen={async () => {
return resolveSelectedFindingIds(selectedFindingIds);
}}
onComplete={handleMuteComplete}
isBulkOperation={selectedFindingIds.length > 1}
showSendToJira
canSendToJira={canSendSelectedFindingsToJira}
sendToJiraLabel={`Send ${selectedFindingIds.length} ${
selectedFindingIds.length === 1 ? "Finding" : "Findings"
} to Jira`}
onSendToJira={() => setIsJiraModalOpen(true)}
/>
)}
{jiraSelection && (
<SendToJiraModal
isOpen={isJiraModalOpen}
onOpenChange={setIsJiraModalOpen}
selection={jiraSelection}
findingTitle={group.checkTitle}
defaultDispatchMode={
selectedFindingIds.length > 1
? JIRA_DISPATCH_MODE.GROUPED
: JIRA_DISPATCH_MODE.INDIVIDUAL
}
canChooseGroupedDispatch={
selectedFindingIds.length > 1 && groupedJiraDispatchEnabled
}
/>
)}
<ResourceDetailDrawer
open={drawer.isOpen}
onOpenChange={(open) => {
if (!open) drawer.closeDrawer();
}}
isLoading={drawer.isLoading}
isNavigating={drawer.isNavigating}
checkMeta={drawer.checkMeta}
currentIndex={drawer.currentIndex}
totalResources={drawer.totalResources}
currentResource={drawer.currentResource}
currentFinding={drawer.currentFinding}
otherFindings={drawer.otherFindings}
showSyntheticResourceHint={group.resourcesTotal === 0}
onNavigatePrev={drawer.navigatePrev}
onNavigateNext={drawer.navigateNext}
onMuteComplete={handleDrawerMuteComplete}
onTriageUpdate={drawer.patchTriageUpdate}
/>
</FindingsSelectionContext.Provider>
);
}