From 2a160a10df392f22190c7ff08b4ef3ac82a3b5fd Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:55:57 +0200 Subject: [PATCH] refactor(ui): remove legacy side drawers and clean code (#10692) --- ui/CHANGELOG.md | 5 + .../finding-groups/finding-groups.test.ts | 20 +- ui/actions/finding-groups/finding-groups.ts | 6 +- ui/actions/findings/findings-by-resource.ts | 1 - ui/actions/resources/index.ts | 1 + ui/actions/resources/resources.ts | 57 ++ .../findings-view/findings-view.ssr.test.ts | 16 + .../findings-view/findings-view.ssr.tsx | 4 +- .../node-detail/node-detail-panel.test.tsx | 93 +++ .../node-detail/node-detail-panel.tsx | 46 +- .../_components/node-detail/node-findings.tsx | 28 +- .../node-detail/node-remediation.tsx | 31 +- .../query-builder/attack-paths-page.tsx | 48 +- ui/app/(prowler)/findings/page.tsx | 3 - .../client-accordion-content.test.ts | 16 + .../client-accordion-content.tsx | 10 +- ...ngs.tsx => column-standalone-findings.tsx} | 153 ++--- .../findings/table/data-table-row-details.tsx | 14 - .../table/finding-detail-drawer.test.ts | 22 + .../findings/table/finding-detail-drawer.tsx | 98 +++ .../findings/table/finding-detail.test.tsx | 299 --------- .../findings/table/finding-detail.tsx | 502 --------------- .../table/findings-group-drill-down.test.ts | 16 + .../table/findings-group-drill-down.tsx | 120 +--- ui/components/findings/table/index.ts | 9 +- .../table/inline-resource-container.test.ts | 16 + .../table/inline-resource-container.tsx | 157 ++--- .../resource-detail-drawer-content.tsx | 6 +- .../use-resource-detail-drawer.test.ts | 51 +- .../use-resource-detail-drawer.ts | 33 +- .../table/column-latest-findings.tsx | 11 + .../table/column-new-findings-to-date.tsx | 13 - .../new-findings-table/table/index.ts | 2 +- .../resources/resource-details-sheet.test.ts | 16 + .../resources/resource-details-sheet.tsx | 4 +- .../skeleton/skeleton-finding-details.tsx | 76 --- .../skeleton/skeleton-finding-summary.tsx | 16 - ui/components/resources/table/index.ts | 1 - .../table/resource-detail-content.test.ts | 29 + .../table/resource-detail-content.tsx | 256 ++------ .../resources/table/resource-detail.tsx | 85 --- .../resources/table/use-finding-details.ts | 72 +++ .../table/use-resource-drawer-bootstrap.ts | 115 ++++ .../shadcn/spinner/loading-state.test.tsx | 36 ++ .../shadcn/spinner/loading-state.tsx | 28 + .../events-timeline/events-timeline.test.ts | 17 + .../events-timeline/events-timeline.tsx | 59 +- .../use-resource-events-timeline.ts | 85 +++ ui/components/ui/custom/custom-link.test.ts | 17 + ui/components/ui/custom/custom-link.tsx | 42 +- ui/hooks/use-finding-group-resource-state.ts | 159 +++++ ui/hooks/use-finding-group-resources.test.ts | 206 +++++++ ...rces.ts => use-finding-group-resources.ts} | 50 +- ui/hooks/use-infinite-resources.test.ts | 570 ------------------ ui/lib/finding-detail.ts | 62 ++ 55 files changed, 1624 insertions(+), 2284 deletions(-) create mode 100644 ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.test.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.test.tsx create mode 100644 ui/components/compliance/compliance-accordion/client-accordion-content.test.ts rename ui/components/findings/table/{column-findings.tsx => column-standalone-findings.tsx} (55%) delete mode 100644 ui/components/findings/table/data-table-row-details.tsx create mode 100644 ui/components/findings/table/finding-detail-drawer.test.ts create mode 100644 ui/components/findings/table/finding-detail-drawer.tsx delete mode 100644 ui/components/findings/table/finding-detail.test.tsx delete mode 100644 ui/components/findings/table/finding-detail.tsx create mode 100644 ui/components/findings/table/findings-group-drill-down.test.ts create mode 100644 ui/components/findings/table/inline-resource-container.test.ts create mode 100644 ui/components/overview/new-findings-table/table/column-latest-findings.tsx delete mode 100644 ui/components/overview/new-findings-table/table/column-new-findings-to-date.tsx create mode 100644 ui/components/resources/resource-details-sheet.test.ts delete mode 100644 ui/components/resources/skeleton/skeleton-finding-details.tsx delete mode 100644 ui/components/resources/skeleton/skeleton-finding-summary.tsx create mode 100644 ui/components/resources/table/resource-detail-content.test.ts delete mode 100644 ui/components/resources/table/resource-detail.tsx create mode 100644 ui/components/resources/table/use-finding-details.ts create mode 100644 ui/components/resources/table/use-resource-drawer-bootstrap.ts create mode 100644 ui/components/shadcn/spinner/loading-state.test.tsx create mode 100644 ui/components/shadcn/spinner/loading-state.tsx create mode 100644 ui/components/shared/events-timeline/events-timeline.test.ts create mode 100644 ui/components/shared/events-timeline/use-resource-events-timeline.ts create mode 100644 ui/components/ui/custom/custom-link.test.ts create mode 100644 ui/hooks/use-finding-group-resource-state.ts create mode 100644 ui/hooks/use-finding-group-resources.test.ts rename ui/hooks/{use-infinite-resources.ts => use-finding-group-resources.ts} (64%) delete mode 100644 ui/hooks/use-infinite-resources.test.ts create mode 100644 ui/lib/finding-detail.ts diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 4affd8594b..b66da755fc 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -13,6 +13,11 @@ All notable changes to the **Prowler UI** are documented in this file. - Attack Paths scan selection: contextual button labels based on graph availability, tooltips on disabled actions, green dot indicator for selectable scans, and a warning banner when viewing data from a previous scan cycle [(#10685)](https://github.com/prowler-cloud/prowler/pull/10685) +### 🔄 Changed + +- Remove legacy finding detail sheet, row-details wrapper, and resource detail panel; unify findings and resources around new side drawers [(#10692)](https://github.com/prowler-cloud/prowler/pull/10692) +- Attack Paths "View Finding" now opens the finding drawer inline over the graph instead of navigating to `/findings` in a new tab, preserving graph zoom, selection, and filter state + ### 🐞 Fixed - Findings group resource filters now strip unsupported scan parameters, display scan name instead of provider alias in filter badges, migrate mute modal from HeroUI to shadcn, and add searchable accounts/provider type selectors [(#10662)](https://github.com/prowler-cloud/prowler/pull/10662) diff --git a/ui/actions/finding-groups/finding-groups.test.ts b/ui/actions/finding-groups/finding-groups.test.ts index f71fe989fb..709469b416 100644 --- a/ui/actions/finding-groups/finding-groups.test.ts +++ b/ui/actions/finding-groups/finding-groups.test.ts @@ -70,7 +70,7 @@ describe("getFindingGroups — default sort for muted and non-muted rows", () => const calledUrl = fetchMock.mock.calls[0][0] as string; const url = new URL(calledUrl); expect(url.searchParams.get("sort")).toBe( - "-status,-new_fail_count,-changed_fail_count,-severity,-fail_count,-last_seen_at", + "-status,-severity,-new_fail_count,-changed_fail_count,-fail_count,-last_seen_at", ); }); @@ -84,7 +84,7 @@ describe("getFindingGroups — default sort for muted and non-muted rows", () => const calledUrl = fetchMock.mock.calls[0][0] as string; const url = new URL(calledUrl); expect(url.searchParams.get("sort")).toBe( - "-status,-new_fail_count,-changed_fail_count,-severity,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at", + "-status,-severity,-new_fail_count,-changed_fail_count,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at", ); }); }); @@ -106,7 +106,7 @@ describe("getLatestFindingGroups — default sort for muted and non-muted rows", const calledUrl = fetchMock.mock.calls[0][0] as string; const url = new URL(calledUrl); expect(url.searchParams.get("sort")).toBe( - "-status,-new_fail_count,-changed_fail_count,-severity,-fail_count,-last_seen_at", + "-status,-severity,-new_fail_count,-changed_fail_count,-fail_count,-last_seen_at", ); }); @@ -120,7 +120,7 @@ describe("getLatestFindingGroups — default sort for muted and non-muted rows", const calledUrl = fetchMock.mock.calls[0][0] as string; const url = new URL(calledUrl); expect(url.searchParams.get("sort")).toBe( - "-status,-new_fail_count,-changed_fail_count,-severity,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at", + "-status,-severity,-new_fail_count,-changed_fail_count,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at", ); }); }); @@ -262,7 +262,7 @@ describe("getFindingGroupResources — Blocker 1: FAIL-first sort", () => { const calledUrl = fetchMock.mock.calls[0][0] as string; const url = new URL(calledUrl); expect(url.searchParams.get("sort")).toBe( - "-status,-delta,-severity,-last_seen_at", + "-status,-severity,-delta,-last_seen_at", ); }); @@ -300,7 +300,7 @@ describe("getLatestFindingGroupResources — Blocker 1: FAIL-first sort", () => const calledUrl = fetchMock.mock.calls[0][0] as string; const url = new URL(calledUrl); expect(url.searchParams.get("sort")).toBe( - "-status,-delta,-severity,-last_seen_at", + "-status,-severity,-delta,-last_seen_at", ); }); @@ -344,7 +344,7 @@ describe("getFindingGroupResources — triangulation: params coexist", () => { expect(url.searchParams.get("page[number]")).toBe("2"); expect(url.searchParams.get("page[size]")).toBe("50"); expect(url.searchParams.get("sort")).toBe( - "-status,-delta,-severity,-last_seen_at", + "-status,-severity,-delta,-last_seen_at", ); expect(url.searchParams.get("filter[status]")).toBeNull(); }); @@ -372,7 +372,7 @@ describe("getLatestFindingGroupResources — triangulation: params coexist", () expect(url.searchParams.get("page[number]")).toBe("3"); expect(url.searchParams.get("page[size]")).toBe("20"); expect(url.searchParams.get("sort")).toBe( - "-status,-delta,-severity,-last_seen_at", + "-status,-severity,-delta,-last_seen_at", ); expect(url.searchParams.get("filter[status]")).toBeNull(); }); @@ -443,7 +443,7 @@ describe("getFindingGroupResources — caller filters are preserved", () => { const calledUrl = fetchMock.mock.calls[0][0] as string; const url = new URL(calledUrl); expect(url.searchParams.get("sort")).toBe( - "-status,-delta,-severity,-last_seen_at", + "-status,-severity,-delta,-last_seen_at", ); expect(url.searchParams.get("filter[name__icontains]")).toBe("bucket-prod"); expect(url.searchParams.get("filter[severity__in]")).toBe("high"); @@ -533,7 +533,7 @@ describe("getLatestFindingGroupResources — caller filters are preserved", () = const calledUrl = fetchMock.mock.calls[0][0] as string; const url = new URL(calledUrl); expect(url.searchParams.get("sort")).toBe( - "-status,-delta,-severity,-last_seen_at", + "-status,-severity,-delta,-last_seen_at", ); expect(url.searchParams.get("filter[name__icontains]")).toBe( "instance-prod", diff --git a/ui/actions/finding-groups/finding-groups.ts b/ui/actions/finding-groups/finding-groups.ts index c409d804b7..30798ea3e6 100644 --- a/ui/actions/finding-groups/finding-groups.ts +++ b/ui/actions/finding-groups/finding-groups.ts @@ -83,13 +83,13 @@ function normalizeFindingGroupResourceFilters( } const DEFAULT_FINDING_GROUPS_SORT = - "-status,-new_fail_count,-changed_fail_count,-severity,-fail_count,-last_seen_at"; + "-status,-severity,-new_fail_count,-changed_fail_count,-fail_count,-last_seen_at"; const DEFAULT_FINDING_GROUPS_SORT_WITH_MUTED = - "-status,-new_fail_count,-changed_fail_count,-severity,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at"; + "-status,-severity,-new_fail_count,-changed_fail_count,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at"; const DEFAULT_FINDING_GROUP_RESOURCES_SORT = - "-status,-delta,-severity,-last_seen_at"; + "-status,-severity,-delta,-last_seen_at"; interface FetchFindingGroupsParams { page?: number; diff --git a/ui/actions/findings/findings-by-resource.ts b/ui/actions/findings/findings-by-resource.ts index 344c5607a0..5a848fff4b 100644 --- a/ui/actions/findings/findings-by-resource.ts +++ b/ui/actions/findings/findings-by-resource.ts @@ -262,7 +262,6 @@ export const getLatestFindingsByResourceUid = async ({ ); url.searchParams.append("filter[resource_uid]", resourceUid); - url.searchParams.append("filter[status]", "FAIL"); url.searchParams.append("filter[muted]", "include"); url.searchParams.append("sort", "-severity,-updated_at"); if (page) url.searchParams.append("page[number]", page.toString()); diff --git a/ui/actions/resources/index.ts b/ui/actions/resources/index.ts index afaf91a381..600346e6c6 100644 --- a/ui/actions/resources/index.ts +++ b/ui/actions/resources/index.ts @@ -3,6 +3,7 @@ export { getLatestResources, getMetadataInfo, getResourceById, + getResourceDrawerData, getResourceEvents, getResources, } from "./resources"; diff --git a/ui/actions/resources/resources.ts b/ui/actions/resources/resources.ts index 36f9761959..b0831bdb26 100644 --- a/ui/actions/resources/resources.ts +++ b/ui/actions/resources/resources.ts @@ -2,9 +2,12 @@ import { redirect } from "next/navigation"; +import { getLatestFindings } from "@/actions/findings"; +import { listOrganizationsSafe } from "@/actions/organizations/organizations"; import { apiBaseUrl, getAuthHeaders } from "@/lib"; import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; +import { OrganizationResource } from "@/types/organizations"; export const getResources = async ({ page = 1, @@ -255,3 +258,57 @@ export const getResourceById = async ( return undefined; } }; + +export const getResourceDrawerData = async ({ + resourceId, + resourceUid, + providerId, + providerType, + page = 1, + pageSize = 10, + query = "", +}: { + resourceId: string; + resourceUid: string; + providerId: string; + providerType: string; + page?: number; + pageSize?: number; + query?: string; +}) => { + const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + + const [resourceData, findingsResponse, organizationsResponse] = + await Promise.all([ + getResourceById(resourceId, { fields: ["tags"] }), + getLatestFindings({ + page, + pageSize, + query, + sort: "severity,-inserted_at", + filters: { + "filter[resource_uid]": resourceUid, + "filter[status]": "FAIL", + }, + }), + isCloudEnv && providerType === "aws" + ? listOrganizationsSafe() + : Promise.resolve({ data: [] }), + ]); + + const providerOrg = + providerType === "aws" + ? (organizationsResponse.data.find((organization: OrganizationResource) => + organization.relationships?.providers?.data?.some( + (provider: { id: string }) => provider.id === providerId, + ), + ) ?? null) + : null; + + return { + findings: findingsResponse?.data ?? [], + findingsMeta: findingsResponse?.meta ?? null, + providerOrg, + resourceTags: resourceData?.data?.attributes.tags ?? {}, + }; +}; diff --git a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.test.ts b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.test.ts new file mode 100644 index 0000000000..03b990c1d5 --- /dev/null +++ b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("findings view overview SSR", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const filePath = path.join(currentDir, "findings-view.ssr.tsx"); + const source = readFileSync(filePath, "utf8"); + + it("uses the non-legacy latest findings columns", () => { + expect(source).toContain("ColumnLatestFindings"); + expect(source).not.toContain("ColumnNewFindingsToDate"); + }); +}); diff --git a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx index be2aec7770..9554869dce 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx @@ -3,7 +3,7 @@ import { getLatestFindings } from "@/actions/findings/findings"; import { LighthouseBanner } from "@/components/lighthouse/banner"; import { LinkToFindings } from "@/components/overview"; -import { ColumnNewFindingsToDate } from "@/components/overview/new-findings-table/table/column-new-findings-to-date"; +import { ColumnLatestFindings } from "@/components/overview/new-findings-table/table"; import { DataTable } from "@/components/ui/table"; import { createDict } from "@/lib/helper"; import { FindingProps, SearchParamsProps } from "@/types"; @@ -73,7 +73,7 @@ export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) { diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.test.tsx new file mode 100644 index 0000000000..d7209c68c8 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.test.tsx @@ -0,0 +1,93 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import type { GraphNode } from "@/types/attack-paths"; + +import { NodeDetailPanel } from "./node-detail-panel"; + +vi.mock("@/components/ui/sheet/sheet", () => ({ + Sheet: ({ children }: { children: ReactNode }) =>
{children}
, + SheetContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + SheetDescription: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + SheetHeader: ({ children }: { children: ReactNode }) =>
{children}
, + SheetTitle: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock("./node-overview", () => ({ + NodeOverview: () =>
Node overview
, +})); + +vi.mock("./node-findings", () => ({ + NodeFindings: () =>
Node findings
, +})); + +vi.mock("./node-resources", () => ({ + NodeResources: () =>
Node resources
, +})); + +const findingNode: GraphNode = { + id: "graph-node-id", + labels: ["ProwlerFinding"], + properties: { + id: "finding-123", + check_title: "Open S3 bucket", + name: "Open S3 bucket", + }, +}; + +const resourceNode: GraphNode = { + id: "resource-node-id", + labels: ["S3Bucket"], + properties: { + id: "bucket-123", + name: "bucket-123", + }, +}; + +describe("NodeDetailPanel", () => { + it("renders the view finding button only for finding nodes", () => { + const { rerender } = render(); + + expect( + screen.getByRole("button", { name: /view finding finding-123/i }), + ).toBeInTheDocument(); + + rerender(); + + expect( + screen.queryByRole("button", { name: /view finding/i }), + ).not.toBeInTheDocument(); + }); + + it("calls onViewFinding with the node finding id", async () => { + const user = userEvent.setup(); + const onViewFinding = vi.fn(); + + render( + , + ); + + await user.click( + screen.getByRole("button", { name: /view finding finding-123/i }), + ); + + expect(onViewFinding).toHaveBeenCalledWith("finding-123"); + }); + + it("disables the button and shows the spinner while loading", () => { + render(); + + const button = screen.getByRole("button", { + name: /view finding finding-123/i, + }); + + expect(button).toBeDisabled(); + expect(screen.getByLabelText("Loading")).toHaveClass("size-4"); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx index 4d8885e65c..97d8657bfd 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-detail-panel.tsx @@ -1,6 +1,7 @@ "use client"; import { Button, Card, CardContent } from "@/components/shadcn"; +import { Spinner } from "@/components/shadcn/spinner/spinner"; import { Sheet, SheetContent, @@ -18,6 +19,8 @@ interface NodeDetailPanelProps { node: GraphNode | null; allNodes?: GraphNode[]; onClose?: () => void; + onViewFinding?: (findingId: string) => void; + viewFindingLoading?: boolean; } /** @@ -26,9 +29,13 @@ interface NodeDetailPanelProps { export const NodeDetailContent = ({ node, allNodes = [], + onViewFinding, + viewFindingLoading = false, }: { node: GraphNode; allNodes?: GraphNode[]; + onViewFinding?: (findingId: string) => void; + viewFindingLoading?: boolean; }) => { const isProwlerFinding = node?.labels.some((label) => label.toLowerCase().includes("finding"), @@ -56,7 +63,12 @@ export const NodeDetailContent = ({
Findings connected to this node
- + )} @@ -88,12 +100,15 @@ export const NodeDetailPanel = ({ node, allNodes = [], onClose, + onViewFinding, + viewFindingLoading = false, }: NodeDetailPanelProps) => { const isOpen = node !== null; const isProwlerFinding = node?.labels.some((label) => label.toLowerCase().includes("finding"), ); + const findingId = node ? String(node.properties?.id || node.id) : ""; return ( !open && onClose?.()}> @@ -107,15 +122,19 @@ export const NodeDetailPanel = ({ {node && isProwlerFinding && ( - )} @@ -123,7 +142,12 @@ export const NodeDetailPanel = ({ {node && (
- +
)} diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-findings.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-findings.tsx index bb424a818c..253401fa97 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-findings.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-findings.tsx @@ -1,5 +1,7 @@ "use client"; +import { Button } from "@/components/shadcn"; +import { Spinner } from "@/components/shadcn/spinner/spinner"; import { SeverityBadge } from "@/components/ui/table/severity-badge"; import type { GraphNode } from "@/types/attack-paths"; @@ -16,13 +18,20 @@ type Severity = (typeof SEVERITY_LEVELS)[keyof typeof SEVERITY_LEVELS]; interface NodeFindingsProps { node: GraphNode; allNodes?: GraphNode[]; + onViewFinding?: (findingId: string) => void; + viewFindingLoading?: boolean; } /** * Node findings section showing related findings for the selected node * Displays findings that are connected to the node via HAS_FINDING edges */ -export const NodeFindings = ({ node, allNodes = [] }: NodeFindingsProps) => { +export const NodeFindings = ({ + node, + allNodes = [], + onViewFinding, + viewFindingLoading = false, +}: NodeFindingsProps) => { // Get finding IDs from the node's findings array (populated by adapter) const findingIds = node.findings || []; @@ -79,15 +88,20 @@ export const NodeFindings = ({ node, allNodes = [] }: NodeFindingsProps) => { ID: {findingId}

- onViewFinding?.(findingId)} + disabled={viewFindingLoading} aria-label={`View full finding for ${findingName}`} className="text-text-info dark:text-text-info h-auto shrink-0 p-0 text-xs font-medium hover:underline" > - View Full Finding → - + {viewFindingLoading ? ( + + ) : ( + "View Full Finding →" + )} + {finding.properties?.description && (
diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-remediation.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-remediation.tsx index e3421e5a43..3ece3184f0 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-remediation.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-remediation.tsx @@ -1,8 +1,8 @@ "use client"; -import Link from "next/link"; - import { Badge } from "@/components/shadcn/badge/badge"; +import { Button } from "@/components/shadcn/button/button"; +import { Spinner } from "@/components/shadcn/spinner/spinner"; interface Finding { id: string; @@ -13,12 +13,18 @@ interface Finding { interface NodeRemediationProps { findings: Finding[]; + onViewFinding?: (findingId: string) => void; + viewFindingLoading?: boolean; } /** * Node remediation section showing related Prowler findings */ -export const NodeRemediation = ({ findings }: NodeRemediationProps) => { +export const NodeRemediation = ({ + findings, + onViewFinding, + viewFindingLoading = false, +}: NodeRemediationProps) => { const getSeverityVariant = (severity: string) => { switch (severity) { case "critical": @@ -66,15 +72,20 @@ export const NodeRemediation = ({ findings }: NodeRemediationProps) => {
- onViewFinding?.(finding.id)} + disabled={viewFindingLoading} aria-label={`View full finding for ${finding.title}`} - className="text-text-info dark:text-text-info text-sm transition-all hover:opacity-80 dark:hover:opacity-80" + className="text-text-info dark:text-text-info h-auto p-0 text-sm transition-all hover:opacity-80 dark:hover:opacity-80" > - View Full Finding → - + {viewFindingLoading ? ( + + ) : ( + "View Full Finding →" + )} +
))} diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx index 4a9cfe84b7..548301d3df 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx @@ -14,6 +14,8 @@ import { getAvailableQueries, } from "@/actions/attack-paths"; import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter"; +import { FindingDetailDrawer } from "@/components/findings/table"; +import { useFindingDetails } from "@/components/resources/table/use-finding-details"; import { AutoRefresh } from "@/components/scans"; import { Alert, @@ -30,6 +32,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/shadcn/dialog"; +import { Spinner } from "@/components/shadcn/spinner/spinner"; import { useToast } from "@/components/ui"; import type { AttackPathQuery, @@ -65,6 +68,7 @@ export default function AttackPathsPage() { const searchParams = useSearchParams(); const scanId = searchParams.get("scanId"); const graphState = useGraphState(); + const finding = useFindingDetails(); const { toast } = useToast(); const [scansLoading, setScansLoading] = useState(true); @@ -311,6 +315,14 @@ export default function AttackPathsPage() { graphState.selectNode(null); }; + const getFindingId = (node: GraphNode | null) => + node ? String(node.properties?.id || node.id) : ""; + + const handleViewFinding = (findingId: string) => { + if (!findingId) return; + void finding.navigateToFinding(findingId); + }; + const handleGraphExport = (svgElement: SVGSVGElement | null) => { try { if (svgElement) { @@ -684,15 +696,20 @@ export default function AttackPathsPage() { {graphState.selectedNode.labels.some((label) => label.toLowerCase().includes("finding"), ) && ( - )} + ) : null} + + + ); +} diff --git a/ui/components/findings/table/finding-detail.test.tsx b/ui/components/findings/table/finding-detail.test.tsx deleted file mode 100644 index ec2e84e2fb..0000000000 --- a/ui/components/findings/table/finding-detail.test.tsx +++ /dev/null @@ -1,299 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; - -import { FindingProps } from "@/types"; - -import { FindingDetail } from "./finding-detail"; - -// Mock next/navigation -const mockRefresh = vi.fn(); -vi.mock("next/navigation", () => ({ - useRouter: () => ({ refresh: mockRefresh }), - usePathname: () => "/findings", - useSearchParams: () => new URLSearchParams(), -})); - -// Mock @/components/shadcn to avoid next-auth import chain -vi.mock("@/components/shadcn", () => { - const Slot = ({ children }: { children: React.ReactNode }) => <>{children}; - return { - Button: ({ - children, - ...props - }: React.ButtonHTMLAttributes & { - variant?: string; - size?: string; - }) => , - Drawer: ({ children }: { children: React.ReactNode }) => <>{children}, - DrawerClose: ({ children }: { children: React.ReactNode }) => ( - <>{children} - ), - DrawerContent: ({ children }: { children: React.ReactNode }) => ( - <>{children} - ), - DrawerDescription: ({ children }: { children: React.ReactNode }) => ( - <>{children} - ), - DrawerHeader: ({ children }: { children: React.ReactNode }) => ( - <>{children} - ), - DrawerTitle: ({ children }: { children: React.ReactNode }) => ( - <>{children} - ), - DrawerTrigger: Slot, - InfoField: ({ - children, - label, - }: { - children: React.ReactNode; - label: string; - variant?: string; - }) => ( -
- {label} - {children} -
- ), - Tabs: ({ children }: { children: React.ReactNode }) => <>{children}, - TabsContent: ({ children }: { children: React.ReactNode }) => ( - <>{children} - ), - TabsList: ({ children }: { children: React.ReactNode }) => <>{children}, - TabsTrigger: ({ children }: { children: React.ReactNode }) => ( - <>{children} - ), - Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, - TooltipContent: ({ children }: { children: React.ReactNode }) => ( - <>{children} - ), - TooltipTrigger: Slot, - }; -}); - -vi.mock("@/components/ui/code-snippet/code-snippet", () => ({ - CodeSnippet: ({ value }: { value: string }) => {value}, -})); - -vi.mock("@/components/ui/custom/custom-link", () => ({ - CustomLink: ({ children }: { children: React.ReactNode }) => ( - {children} - ), -})); - -vi.mock("@/components/ui/entities", () => ({ - EntityInfo: () =>
, -})); - -vi.mock("@/components/ui/entities/date-with-time", () => ({ - DateWithTime: ({ dateTime }: { dateTime: string }) => {dateTime}, -})); - -vi.mock("@/components/ui/table/severity-badge", () => ({ - SeverityBadge: ({ severity }: { severity: string }) => ( - {severity} - ), -})); - -vi.mock("@/components/ui/table/status-finding-badge", () => ({ - FindingStatus: {}, - StatusFindingBadge: ({ status }: { status: string }) => {status}, -})); - -vi.mock("@/lib/iac-utils", () => ({ - buildGitFileUrl: () => null, - extractLineRangeFromUid: () => null, -})); - -vi.mock("@/lib/utils", () => ({ - cn: (...args: string[]) => args.filter(Boolean).join(" "), -})); - -// Mock child components that are not under test -vi.mock("../mute-findings-modal", () => ({ - MuteFindingsModal: ({ - isOpen, - findingIds, - }: { - isOpen: boolean; - findingIds: string[]; - }) => - isOpen ? ( -
Muting {findingIds.length} finding(s)
- ) : null, -})); - -vi.mock("../muted", () => ({ - Muted: ({ isMuted }: { isMuted: boolean }) => - isMuted ? Muted : null, -})); - -vi.mock("./delta-indicator", () => ({ - DeltaIndicator: () => null, -})); - -vi.mock("@/components/shared/events-timeline/events-timeline", () => ({ - EventsTimeline: () =>
, -})); - -vi.mock("react-markdown", () => ({ - default: ({ children }: { children: string }) => {children}, -})); - -const baseFinding: FindingProps = { - type: "findings", - id: "finding-123", - attributes: { - uid: "uid-123", - delta: null, - status: "FAIL", - status_extended: "S3 bucket is publicly accessible", - severity: "high", - check_id: "s3_bucket_public_access", - muted: false, - check_metadata: { - risk: "Public access risk", - notes: "", - checkid: "s3_bucket_public_access", - provider: "aws", - severity: "high", - checktype: [], - dependson: [], - relatedto: [], - categories: ["security"], - checktitle: "S3 Bucket Public Access Check", - compliance: null, - relatedurl: "", - description: "Checks if S3 buckets are publicly accessible", - remediation: { - code: { cli: "", other: "", nativeiac: "", terraform: "" }, - recommendation: { url: "", text: "" }, - }, - servicename: "s3", - checkaliases: [], - resourcetype: "AwsS3Bucket", - subservicename: "", - resourceidtemplate: "", - }, - raw_result: null, - inserted_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-02T00:00:00Z", - first_seen_at: "2024-01-01T00:00:00Z", - }, - relationships: { - resources: { data: [{ type: "resources", id: "res-1" }] }, - scan: { - data: { type: "scans", id: "scan-1" }, - attributes: { - name: "Daily Scan", - trigger: "scheduled", - state: "completed", - unique_resource_count: 50, - progress: 100, - scanner_args: { checks_to_execute: [] }, - duration: 120, - started_at: "2024-01-01T00:00:00Z", - inserted_at: "2024-01-01T00:00:00Z", - completed_at: "2024-01-01T00:02:00Z", - scheduled_at: null, - next_scan_at: "2024-01-02T00:00:00Z", - }, - }, - resource: { - data: [{ type: "resources", id: "res-1" }], - id: "res-1", - attributes: { - uid: "arn:aws:s3:::my-bucket", - name: "my-bucket", - region: "us-east-1", - service: "s3", - tags: {}, - type: "AwsS3Bucket", - inserted_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - details: null, - partition: "aws", - }, - relationships: { - provider: { data: { type: "providers", id: "prov-1" } }, - findings: { - meta: { count: 1 }, - data: [{ type: "findings", id: "finding-123" }], - }, - }, - links: { self: "/resources/res-1" }, - }, - provider: { - data: { type: "providers", id: "prov-1" }, - attributes: { - provider: "aws", - uid: "123456789012", - alias: "my-account", - connection: { - connected: true, - last_checked_at: "2024-01-01T00:00:00Z", - }, - inserted_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - }, - relationships: { - secret: { data: { type: "provider-secrets", id: "secret-1" } }, - }, - links: { self: "/providers/prov-1" }, - }, - }, - links: { self: "/findings/finding-123" }, -}; - -describe("FindingDetail", () => { - it("shows the Mute button for non-muted findings", () => { - render(); - - expect(screen.getByRole("button", { name: /mute/i })).toBeInTheDocument(); - }); - - it("hides the Mute button for muted findings", () => { - const mutedFinding: FindingProps = { - ...baseFinding, - attributes: { ...baseFinding.attributes, muted: true }, - }; - - render(); - - expect(screen.queryByRole("button", { name: /mute/i })).toBeNull(); - }); - - it("opens the mute modal when clicking the Mute button", async () => { - const user = userEvent.setup(); - - render(); - - expect(screen.queryByTestId("mute-modal")).toBeNull(); - - await user.click(screen.getByRole("button", { name: /mute/i })); - - expect(screen.getByTestId("mute-modal")).toBeInTheDocument(); - }); - - it("does not render the mute modal for muted findings", () => { - const mutedFinding: FindingProps = { - ...baseFinding, - attributes: { ...baseFinding.attributes, muted: true }, - }; - - render(); - - expect(screen.queryByTestId("mute-modal")).toBeNull(); - }); - - it("shows the muted badge for muted findings", () => { - const mutedFinding: FindingProps = { - ...baseFinding, - attributes: { ...baseFinding.attributes, muted: true }, - }; - - render(); - - expect(screen.getByTestId("muted-badge")).toBeInTheDocument(); - }); -}); diff --git a/ui/components/findings/table/finding-detail.tsx b/ui/components/findings/table/finding-detail.tsx deleted file mode 100644 index 1b95a6653b..0000000000 --- a/ui/components/findings/table/finding-detail.tsx +++ /dev/null @@ -1,502 +0,0 @@ -// TODO: Legacy component — used by /resources page and overview dashboard. -// Migrate those consumers to the new resource-detail-drawer, then delete this file. -"use client"; - -import { ExternalLink, Link, VolumeX, X } from "lucide-react"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { type ReactNode, useState } from "react"; - -import { - Button, - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerHeader, - DrawerTitle, - DrawerTrigger, - InfoField, - Tabs, - TabsContent, - TabsList, - TabsTrigger, - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/shadcn"; -import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline"; -import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; -import { CustomLink } from "@/components/ui/custom/custom-link"; -import { EntityInfo } from "@/components/ui/entities"; -import { DateWithTime } from "@/components/ui/entities/date-with-time"; -import { SeverityBadge } from "@/components/ui/table/severity-badge"; -import { - FindingStatus, - StatusFindingBadge, -} from "@/components/ui/table/status-finding-badge"; -import { formatDuration } from "@/lib/date-utils"; -import { buildGitFileUrl, extractLineRangeFromUid } from "@/lib/iac-utils"; -import { cn } from "@/lib/utils"; -import { FindingProps, ProviderType } from "@/types"; - -import { MarkdownContainer } from "../markdown-container"; -import { MuteFindingsModal } from "../mute-findings-modal"; -import { Muted } from "../muted"; -import { DeltaIndicator } from "./delta-indicator"; - -const renderValue = (value: string | null | undefined) => { - return value && value.trim() !== "" ? value : "-"; -}; - -interface FindingDetailProps { - findingDetails: FindingProps; - trigger?: ReactNode; - open?: boolean; - defaultOpen?: boolean; - onOpenChange?: (open: boolean) => void; -} - -export const FindingDetail = ({ - findingDetails, - trigger, - open, - defaultOpen = false, - onOpenChange, -}: FindingDetailProps) => { - const finding = findingDetails; - const attributes = finding.attributes; - const resource = finding.relationships?.resource?.attributes; - const scan = finding.relationships?.scan?.attributes; - const providerDetails = finding.relationships?.provider?.attributes; - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - const [isMuteModalOpen, setIsMuteModalOpen] = useState(false); - - const copyFindingUrl = () => { - const params = new URLSearchParams(searchParams.toString()); - params.set("id", findingDetails.id); - const url = `${window.location.origin}${pathname}?${params.toString()}`; - navigator.clipboard.writeText(url); - }; - - // Build Git URL for IaC findings - const gitUrl = - providerDetails?.provider === "iac" && resource - ? buildGitFileUrl( - providerDetails.uid, - resource.name, - extractLineRangeFromUid(attributes.uid) || "", - resource.region, - ) - : null; - - const handleMuteComplete = () => { - setIsMuteModalOpen(false); - onOpenChange?.(false); - router.refresh(); - }; - - const muteModal = !attributes.muted && ( - - ); - - const content = ( -
- {/* Header */} -
- {/* Row 1: Status badges */} -
- - - {attributes.delta && ( -
- - - {attributes.delta} - -
- )} - -
- - {/* Row 2: Title with copy link */} -

- {renderValue(attributes.check_metadata.checktitle)} - - - - - Copy finding link to clipboard - -

- - {/* Row 3: First Seen */} -
- Time: - -
-
- - {/* Tabs */} - -
- - General - Resources - Scans - Events - - - {!attributes.muted && ( - - )} -
- - {/* General Tab */} - -

- Here is an overview of this finding: -

-
- {providerDetails && ( - - )} - - {attributes.check_metadata.servicename} - - {resource?.region ?? "-"} -
- -
- - - - - - - - - - - - -
- - {attributes.status === "FAIL" && ( - -
- - {attributes.check_metadata.risk} - -
-
- )} - - - - {attributes.check_metadata.description} - - - - - {renderValue(attributes.status_extended)} - - - {attributes.check_metadata.remediation && ( -
-

- Remediation Details -

- - {/* Recommendation section */} - {attributes.check_metadata.remediation.recommendation.text && ( - -
- - { - attributes.check_metadata.remediation.recommendation - .text - } - - - {attributes.check_metadata.remediation.recommendation - .url && ( - - Learn more - - )} -
-
- )} - - {/* CLI Command section */} - {attributes.check_metadata.remediation.code.cli && ( - -
- - {attributes.check_metadata.remediation.code.cli} - -
-
- )} - - {/* Remediation Steps section */} - {attributes.check_metadata.remediation.code.other && ( - - - {attributes.check_metadata.remediation.code.other} - - - )} - - {/* Additional URLs section */} - {attributes.check_metadata.additionalurls && - attributes.check_metadata.additionalurls.length > 0 && ( - -
    - {attributes.check_metadata.additionalurls.map( - (link, idx) => ( -
  • - - {link} - -
  • - ), - )} -
-
- )} -
- )} - - - {attributes.check_metadata.categories?.join(", ") || "none"} - -
- - {/* Resources Tab */} - - {resource ? ( - <> - {providerDetails?.provider === "iac" && gitUrl && ( -
- - - - - View in Repository - - - - Go to Resource in the Repository - - -
- )} - -
- - {renderValue(resource.name)} - - - {renderValue(resource.type)} - -
- -
- - {renderValue(resource.service)} - - - {renderValue(resource.region)} - -
- -
- - {renderValue(resource.partition)} - - - {renderValue(resource.details)} - -
- - - - - - {resource.tags && Object.entries(resource.tags).length > 0 && ( -
-

- Tags -

-
- {Object.entries(resource.tags).map(([key, value]) => ( - - {renderValue(value)} - - ))} -
-
- )} - -
- - - - - - -
- - ) : ( -

- Resource information is not available. -

- )} -
- - {/* Scans Tab */} - - {scan ? ( - <> -
- {scan.name || "N/A"} - - {scan.unique_resource_count} - - {scan.progress}% -
- -
- {scan.trigger} - {scan.state} - - {formatDuration(scan.duration)} - -
- -
- - - - - - -
- -
- - - - {scan.scheduled_at && ( - - - - )} -
- - ) : ( -

- Scan information is not available. -

- )} -
- - {/* Events Tab */} - - - -
-
- ); - - // If no trigger, render content directly (inline mode) - if (!trigger) { - return ( - <> - {muteModal} - {content} - - ); - } - - // With trigger, wrap in Drawer — modal rendered outside to avoid nested overlay issues - return ( - <> - {muteModal} - - {trigger} - - - Finding Details - View the finding details - - - - Close - - {content} - - - - ); -}; diff --git a/ui/components/findings/table/findings-group-drill-down.test.ts b/ui/components/findings/table/findings-group-drill-down.test.ts new file mode 100644 index 0000000000..816e894e0a --- /dev/null +++ b/ui/components/findings/table/findings-group-drill-down.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("findings group drill down", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const filePath = path.join(currentDir, "findings-group-drill-down.tsx"); + const source = readFileSync(filePath, "utf8"); + + it("uses the shared finding-group resource state hook", () => { + expect(source).toContain("useFindingGroupResourceState"); + expect(source).not.toContain("useInfiniteResources"); + }); +}); diff --git a/ui/components/findings/table/findings-group-drill-down.tsx b/ui/components/findings/table/findings-group-drill-down.tsx index 3d35b20115..445adb6ec7 100644 --- a/ui/components/findings/table/findings-group-drill-down.tsx +++ b/ui/components/findings/table/findings-group-drill-down.tsx @@ -3,15 +3,12 @@ import { flexRender, getCoreRowModel, - Row, - RowSelectionState, useReactTable, } from "@tanstack/react-table"; import { ChevronLeft } from "lucide-react"; import { useSearchParams } from "next/navigation"; -import { useState } from "react"; -import { Spinner } from "@/components/shadcn/spinner/spinner"; +import { LoadingState } from "@/components/shadcn/spinner/loading-state"; import { Table, TableBody, @@ -21,24 +18,20 @@ import { TableRow, } from "@/components/ui/table"; import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table"; -import { useInfiniteResources } from "@/hooks/use-infinite-resources"; +import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state"; import { cn, hasHistoricalFindingFilter } from "@/lib"; import { getFilteredFindingGroupDelta, isFindingGroupMuted, } from "@/lib/findings-groups"; -import { FindingGroupRow, FindingResourceRow } from "@/types"; +import { FindingGroupRow } from "@/types"; import { FloatingMuteButton } from "../floating-mute-button"; import { getColumnFindingResources } from "./column-finding-resources"; -import { canMuteFindingResource } from "./finding-resource-selection"; import { FindingsSelectionContext } from "./findings-selection-context"; import { ImpactedResourcesCell } from "./impacted-resources-cell"; import { DeltaValues, NotificationIndicator } from "./notification-indicator"; -import { - ResourceDetailDrawer, - useResourceDetailDrawer, -} from "./resource-detail-drawer"; +import { ResourceDetailDrawer } from "./resource-detail-drawer"; interface FindingsGroupDrillDownProps { group: FindingGroupRow; @@ -50,9 +43,6 @@ export function FindingsGroupDrillDown({ onCollapse, }: FindingsGroupDrillDownProps) { const searchParams = useSearchParams(); - const [rowSelection, setRowSelection] = useState({}); - const [resources, setResources] = useState([]); - const [isLoading, setIsLoading] = useState(true); // Keep drill-down endpoint selection aligned with the grouped findings page. const currentParams = Object.fromEntries(searchParams.entries()); @@ -66,78 +56,27 @@ export function FindingsGroupDrillDown({ } }); - const handleSetResources = ( - newResources: FindingResourceRow[], - _hasMore: boolean, - ) => { - setResources(newResources); - setIsLoading(false); - }; - - const handleAppendResources = ( - newResources: FindingResourceRow[], - _hasMore: boolean, - ) => { - setResources((prev) => [...prev, ...newResources]); - setIsLoading(false); - }; - - const handleSetLoading = (loading: boolean) => { - setIsLoading(loading); - }; - - const { sentinelRef, refresh, loadMore, totalCount } = useInfiniteResources({ - checkId: group.checkId, - hasDateOrScanFilter: hasHistoricalFilterActive, - filters, - onSetResources: handleSetResources, - onAppendResources: handleAppendResources, - onSetLoading: handleSetLoading, - }); - - // Resource detail drawer - const drawer = useResourceDetailDrawer({ + const { + rowSelection, resources, - checkId: group.checkId, - totalResourceCount: totalCount ?? group.resourcesTotal, - onRequestMoreResources: loadMore, + isLoading, + sentinelRef, + drawer, + handleDrawerMuteComplete, + selectedFindingIds, + selectableRowCount, + getRowCanSelect, + clearSelection, + isSelected, + handleMuteComplete, + handleRowSelectionChange, + resolveSelectedFindingIds, + } = useFindingGroupResourceState({ + group, + filters, + hasHistoricalData: hasHistoricalFilterActive, }); - const handleDrawerMuteComplete = () => { - drawer.refetchCurrent(); - refresh(); - }; - - // Selection logic — tracks by findingId (resource_id) for checkbox consistency - const selectedFindingIds = Object.keys(rowSelection) - .filter((key) => rowSelection[key]) - .map((idx) => resources[parseInt(idx)]?.findingId) - .filter((id): id is string => id !== null && id !== undefined && id !== ""); - - /** findingId values are already real finding UUIDs — no resolution needed. */ - const resolveResourceIds = async (ids: string[]) => { - return ids.filter(Boolean); - }; - - const selectableRowCount = resources.filter(canMuteFindingResource).length; - - const getRowCanSelect = (row: Row): boolean => { - return canMuteFindingResource(row.original); - }; - - const clearSelection = () => { - setRowSelection({}); - }; - - const isSelected = (id: string) => { - return selectedFindingIds.includes(id); - }; - - const handleMuteComplete = () => { - clearSelection(); - refresh(); - }; - const columns = getColumnFindingResources({ rowSelection, selectableRowCount, @@ -148,7 +87,7 @@ export function FindingsGroupDrillDown({ columns, enableRowSelection: getRowCanSelect, getCoreRowModel: getCoreRowModel(), - onRowSelectionChange: setRowSelection, + onRowSelectionChange: handleRowSelectionChange, manualPagination: true, state: { rowSelection, @@ -175,7 +114,7 @@ export function FindingsGroupDrillDown({ selectedFindings: [], clearSelection, isSelected, - resolveMuteIds: resolveResourceIds, + resolveMuteIds: resolveSelectedFindingIds, onMuteComplete: handleMuteComplete, }} > @@ -280,14 +219,7 @@ export function FindingsGroupDrillDown({ {/* Loading indicator */} - {isLoading && ( -
- - - Loading resources... - -
- )} + {isLoading && } {/* Sentinel for infinite scroll */}
@@ -299,7 +231,7 @@ export function FindingsGroupDrillDown({ selectedCount={selectedFindingIds.length} selectedFindingIds={selectedFindingIds} onBeforeOpen={async () => { - return resolveResourceIds(selectedFindingIds); + return resolveSelectedFindingIds(selectedFindingIds); }} onComplete={handleMuteComplete} isBulkOperation diff --git a/ui/components/findings/table/index.ts b/ui/components/findings/table/index.ts index b3b063a6ed..f8b6b4fdbe 100644 --- a/ui/components/findings/table/index.ts +++ b/ui/components/findings/table/index.ts @@ -1,16 +1,11 @@ export * from "./column-finding-groups"; export * from "./column-finding-resources"; -export * from "./column-findings"; +export * from "./column-standalone-findings"; export * from "./data-table-row-actions"; -export * from "./data-table-row-details"; -export * from "./finding-detail"; +export * from "./finding-detail-drawer"; export * from "./findings-group-drill-down"; export * from "./findings-group-table"; export * from "./findings-selection-context"; -// TODO: Remove legacy exports once /resources and overview dashboard migrate to grouped view components -// export * from "./column-findings"; -// export * from "./data-table-row-details"; -// export * from "./finding-detail"; export * from "./impacted-resources-cell"; export * from "./notification-indicator"; export * from "./provider-icon-cell"; diff --git a/ui/components/findings/table/inline-resource-container.test.ts b/ui/components/findings/table/inline-resource-container.test.ts new file mode 100644 index 0000000000..3acafa8ee5 --- /dev/null +++ b/ui/components/findings/table/inline-resource-container.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("inline resource container", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const filePath = path.join(currentDir, "inline-resource-container.tsx"); + const source = readFileSync(filePath, "utf8"); + + it("uses the shared finding-group resource state hook", () => { + expect(source).toContain("useFindingGroupResourceState"); + expect(source).not.toContain("useInfiniteResources"); + }); +}); diff --git a/ui/components/findings/table/inline-resource-container.tsx b/ui/components/findings/table/inline-resource-container.tsx index c2f40e189f..47e6d0eb06 100644 --- a/ui/components/findings/table/inline-resource-container.tsx +++ b/ui/components/findings/table/inline-resource-container.tsx @@ -3,32 +3,26 @@ import { flexRender, getCoreRowModel, - Row, - RowSelectionState, useReactTable, } from "@tanstack/react-table"; import { AnimatePresence, motion } from "framer-motion"; import { ChevronsDown } from "lucide-react"; -import { useImperativeHandle, useRef, useState } from "react"; +import { useImperativeHandle, useRef } from "react"; import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; -import { Spinner } from "@/components/shadcn/spinner/spinner"; +import { LoadingState } from "@/components/shadcn/spinner/loading-state"; import { TableCell, TableRow } from "@/components/ui/table"; -import { useInfiniteResources } from "@/hooks/use-infinite-resources"; +import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state"; import { useScrollHint } from "@/hooks/use-scroll-hint"; -import { FindingGroupRow, FindingResourceRow } from "@/types"; +import { FindingGroupRow } from "@/types"; import { getColumnFindingResources } from "./column-finding-resources"; -import { canMuteFindingResource } from "./finding-resource-selection"; import { FindingsSelectionContext } from "./findings-selection-context"; import { getFilteredFindingGroupResourceCount, getFindingGroupSkeletonCount, } from "./inline-resource-container.utils"; -import { - ResourceDetailDrawer, - useResourceDetailDrawer, -} from "./resource-detail-drawer"; +import { ResourceDetailDrawer } from "./resource-detail-drawer"; export interface InlineResourceContainerHandle { /** Soft-refresh resources (re-fetch page 1 without skeletons). */ @@ -140,22 +134,6 @@ export function InlineResourceContainer({ ref, }: InlineResourceContainerProps) { const scrollContainerRef = useRef(null); - const [rowSelection, setRowSelection] = useState({}); - const [resources, setResources] = useState([]); - const [isLoading, setIsLoading] = useState(true); - // Scroll hint: shows "scroll for more" when content overflows - const { - containerRef: scrollHintContainerRef, - sentinelRef: scrollHintSentinelRef, - showScrollHint, - } = useScrollHint({ refreshToken: resources.length }); - - // Combine scrollContainerRef (for IntersectionObserver root) with scrollHintContainerRef - const combinedScrollRef = (node: HTMLDivElement | null) => { - scrollContainerRef.current = node; - scrollHintContainerRef(node); - }; - const filters: Record = { ...resolvedFilters }; if (resourceSearch) { filters["filter[name__icontains]"] = resourceSearch; @@ -171,99 +149,45 @@ export function InlineResourceContainer({ filters, ); - const handleSetResources = ( - newResources: FindingResourceRow[], - _hasMore: boolean, - ) => { - setResources(newResources); - setIsLoading(false); - }; - - const handleAppendResources = ( - newResources: FindingResourceRow[], - _hasMore: boolean, - ) => { - setResources((prev) => [...prev, ...newResources]); - setIsLoading(false); - }; - - const handleSetLoading = (loading: boolean) => { - setIsLoading(loading); - }; - - const { sentinelRef, refresh, loadMore, totalCount } = useInfiniteResources({ - checkId: group.checkId, - hasDateOrScanFilter: hasHistoricalData, + const { + rowSelection, + resources, + isLoading, + sentinelRef, + refresh, + drawer, + handleDrawerMuteComplete, + selectedFindingIds, + selectableRowCount, + getRowCanSelect, + clearSelection, + isSelected, + handleMuteComplete, + handleRowSelectionChange, + resolveSelectedFindingIds, + } = useFindingGroupResourceState({ + group, filters, - onSetResources: handleSetResources, - onAppendResources: handleAppendResources, - onSetLoading: handleSetLoading, + hasHistoricalData, + onResourceSelectionChange, scrollContainerRef, }); - // Resource detail drawer - const drawer = useResourceDetailDrawer({ - resources, - checkId: group.checkId, - totalResourceCount: totalCount ?? group.resourcesTotal, - onRequestMoreResources: loadMore, - }); + // Scroll hint: shows "scroll for more" when content overflows + const { + containerRef: scrollHintContainerRef, + sentinelRef: scrollHintSentinelRef, + showScrollHint, + } = useScrollHint({ refreshToken: resources.length }); - const handleDrawerMuteComplete = () => { - drawer.refetchCurrent(); - refresh(); - }; - - // Selection logic - const selectedFindingIds = Object.keys(rowSelection) - .filter((key) => rowSelection[key]) - .map((idx) => resources[parseInt(idx)]?.findingId) - .filter(Boolean); - - const resolveResourceIds = async (ids: string[]) => { - // findingId values are already real finding UUIDs (from the group - // resources endpoint), so no second resolution round-trip is needed. - return ids.filter(Boolean); - }; - - const selectableRowCount = resources.filter(canMuteFindingResource).length; - - const getRowCanSelect = (row: Row): boolean => { - return canMuteFindingResource(row.original); - }; - - const clearSelection = () => { - setRowSelection({}); - onResourceSelectionChange([]); + // Combine scrollContainerRef (for IntersectionObserver root) with scrollHintContainerRef + const combinedScrollRef = (node: HTMLDivElement | null) => { + scrollContainerRef.current = node; + scrollHintContainerRef(node); }; useImperativeHandle(ref, () => ({ refresh, clearSelection })); - const isSelected = (id: string) => { - return selectedFindingIds.includes(id); - }; - - const handleMuteComplete = () => { - clearSelection(); - refresh(); - }; - - const handleRowSelectionChange = ( - updater: - | RowSelectionState - | ((prev: RowSelectionState) => RowSelectionState), - ) => { - const newSelection = - typeof updater === "function" ? updater(rowSelection) : updater; - setRowSelection(newSelection); - - const newFindingIds = Object.keys(newSelection) - .filter((key) => newSelection[key]) - .map((idx) => resources[parseInt(idx)]?.findingId) - .filter(Boolean); - onResourceSelectionChange(newFindingIds); - }; - const columns = getColumnFindingResources({ rowSelection, selectableRowCount, @@ -290,7 +214,7 @@ export function InlineResourceContainer({ selectedFindings: [], clearSelection, isSelected, - resolveMuteIds: resolveResourceIds, + resolveMuteIds: resolveSelectedFindingIds, onMuteComplete: handleMuteComplete, }} > @@ -363,14 +287,9 @@ export function InlineResourceContainer({ - {/* Spinner for infinite scroll (subsequent pages only) */} + {/* Loading state for infinite scroll (subsequent pages only) */} {isLoading && rows.length > 0 && ( -
- - - Loading resources... - -
+ )} {/* Sentinel for scroll hint detection */} diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx index e075c89b2b..d488462b14 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx @@ -37,7 +37,7 @@ import { ActionDropdownItem, } from "@/components/shadcn/dropdown"; import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; -import { Spinner } from "@/components/shadcn/spinner/spinner"; +import { LoadingState } from "@/components/shadcn/spinner/loading-state"; import { Tooltip, TooltipContent, @@ -873,9 +873,7 @@ export function ResourceDetailDrawerContent({ className="minimal-scrollbar flex flex-col gap-2 overflow-y-auto" > {!f || isNavigating ? ( -
- -
+ ) : ( <>
diff --git a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts index 3e4d1ef540..797676562f 100644 --- a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts +++ b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts @@ -191,7 +191,7 @@ describe("useResourceDetailDrawer — other findings filtering", () => { id: "other-1", checkId: "check-other-1", checkTitle: "Other 1", - status: "PASS", + status: "FAIL", severity: "critical", }), makeDrawerFinding({ @@ -221,6 +221,55 @@ describe("useResourceDetailDrawer — other findings filtering", () => { ]); }); + it("should exclude non-FAIL findings from otherFindings", async () => { + const resources = [makeResource()]; + + getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] }); + adaptFindingsByResourceResponseMock.mockReturnValue([ + makeDrawerFinding({ + id: "current", + checkId: "s3_check", + status: "MANUAL", + severity: "informational", + }), + makeDrawerFinding({ + id: "other-pass", + checkId: "check-pass", + status: "PASS", + severity: "low", + }), + makeDrawerFinding({ + id: "other-manual", + checkId: "check-manual", + status: "MANUAL", + severity: "low", + }), + makeDrawerFinding({ + id: "other-fail", + checkId: "check-fail", + status: "FAIL", + severity: "high", + }), + ]); + + const { result } = renderHook(() => + useResourceDetailDrawer({ + resources, + checkId: "s3_check", + }), + ); + + await act(async () => { + result.current.openDrawer(0); + await Promise.resolve(); + }); + + expect(result.current.currentFinding?.id).toBe("current"); + expect(result.current.otherFindings.map((f) => f.id)).toEqual([ + "other-fail", + ]); + }); + it("should keep isNavigating true for a cached resource long enough to render skeletons", async () => { vi.useFakeTimers(); diff --git a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts index f092c606dc..687d9ac739 100644 --- a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts +++ b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.ts @@ -46,6 +46,7 @@ interface UseResourceDetailDrawerOptions { checkId: string; totalResourceCount?: number; onRequestMoreResources?: () => void; + initialIndex?: number | null; } interface UseResourceDetailDrawerReturn { @@ -77,10 +78,11 @@ export function useResourceDetailDrawer({ checkId, totalResourceCount, onRequestMoreResources, + initialIndex = null, }: UseResourceDetailDrawerOptions): UseResourceDetailDrawerReturn { - const [isOpen, setIsOpen] = useState(false); + const [isOpen, setIsOpen] = useState(initialIndex !== null); const [isLoading, setIsLoading] = useState(false); - const [currentIndex, setCurrentIndex] = useState(0); + const [currentIndex, setCurrentIndex] = useState(initialIndex ?? 0); const [findings, setFindings] = useState([]); const [isNavigating, setIsNavigating] = useState(false); @@ -190,6 +192,22 @@ export function useResourceDetailDrawer({ } }; + useEffect(() => { + if (initialIndex === null) { + return; + } + + const resource = resources[initialIndex]; + if (!resource) { + return; + } + + fetchFindings(resource.resourceUid); + // Only initialize once on mount for deep-link/inline entry points. + // User-driven navigations use openDrawer/navigateTo afterwards. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const openDrawer = (index: number) => { const resource = resources[index]; if (!resource) return; @@ -251,10 +269,13 @@ export function useResourceDetailDrawer({ const currentFinding = findings.find((f) => f.checkId === checkId) ?? findings[0] ?? null; - // All other findings for this resource - const otherFindings = currentFinding - ? findings.filter((f) => f.id !== currentFinding.id) - : findings; + // "Other Findings For This Resource" intentionally shows only FAIL entries, + // while currentFinding (the drilled-down one) can be any status (FAIL, MANUAL, PASS…). + const otherFindings = ( + currentFinding + ? findings.filter((f) => f.id !== currentFinding.id) + : findings + ).filter((f) => f.status === "FAIL"); return { isOpen, diff --git a/ui/components/overview/new-findings-table/table/column-latest-findings.tsx b/ui/components/overview/new-findings-table/table/column-latest-findings.tsx new file mode 100644 index 0000000000..6310e2dc40 --- /dev/null +++ b/ui/components/overview/new-findings-table/table/column-latest-findings.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; + +import { getStandaloneFindingColumns } from "@/components/findings/table/column-standalone-findings"; +import { FindingProps } from "@/types"; + +export const ColumnLatestFindings: ColumnDef[] = + getStandaloneFindingColumns({ + includeUpdatedAt: true, + }); diff --git a/ui/components/overview/new-findings-table/table/column-new-findings-to-date.tsx b/ui/components/overview/new-findings-table/table/column-new-findings-to-date.tsx deleted file mode 100644 index 94eeca391d..0000000000 --- a/ui/components/overview/new-findings-table/table/column-new-findings-to-date.tsx +++ /dev/null @@ -1,13 +0,0 @@ -"use client"; - -import { ColumnDef, RowSelectionState } from "@tanstack/react-table"; - -import { getColumnFindings } from "@/components/findings/table/column-findings"; -import { FindingProps } from "@/types"; - -const baseColumns: ColumnDef[] = getColumnFindings( - {} as RowSelectionState, - 0, -).filter((column) => column.id !== "select" && column.id !== "actions"); - -export const ColumnNewFindingsToDate: ColumnDef[] = baseColumns; diff --git a/ui/components/overview/new-findings-table/table/index.ts b/ui/components/overview/new-findings-table/table/index.ts index 5f8e56989e..7065a82bf8 100644 --- a/ui/components/overview/new-findings-table/table/index.ts +++ b/ui/components/overview/new-findings-table/table/index.ts @@ -1,2 +1,2 @@ -export * from "./column-new-findings-to-date"; +export * from "./column-latest-findings"; export * from "./skeleton-table-new-findings"; diff --git a/ui/components/resources/resource-details-sheet.test.ts b/ui/components/resources/resource-details-sheet.test.ts new file mode 100644 index 0000000000..9f630ee352 --- /dev/null +++ b/ui/components/resources/resource-details-sheet.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("resource details sheet", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const filePath = path.join(currentDir, "resource-details-sheet.tsx"); + const source = readFileSync(filePath, "utf8"); + + it("forces a remount when switching resources so local drawer state resets without effects", () => { + expect(source).toContain("key={resource.id}"); + expect(source).toContain("resourceDetails={resource}"); + }); +}); diff --git a/ui/components/resources/resource-details-sheet.tsx b/ui/components/resources/resource-details-sheet.tsx index 1581f36520..1ae3ec0e74 100644 --- a/ui/components/resources/resource-details-sheet.tsx +++ b/ui/components/resources/resource-details-sheet.tsx @@ -36,7 +36,9 @@ export const ResourceDetailsSheet = ({ Close - {open && } + {open && ( + + )} ); diff --git a/ui/components/resources/skeleton/skeleton-finding-details.tsx b/ui/components/resources/skeleton/skeleton-finding-details.tsx deleted file mode 100644 index 062a5fb7c7..0000000000 --- a/ui/components/resources/skeleton/skeleton-finding-details.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React from "react"; - -export const SkeletonFindingDetails = () => { - return ( -
- {/* Header */} -
-
-
-
-
-
-
- - {/* Metadata Section */} -
- {Array.from({ length: 6 }).map((_, index) => ( -
-
-
-
- ))} -
- - {/* InfoField Blocks */} - {Array.from({ length: 3 }).map((_, index) => ( -
-
-
-
- ))} - - {/* Risk and Description Sections */} -
-
-
-
- -
- -
-
-
-
-
- -
-
-
-
- - {/* Additional Resources */} -
-
-
-
- - {/* Categories */} -
-
-
-
- - {/* Provider Info Section */} -
-
-
-
-
-
-
-
-
-
- ); -}; diff --git a/ui/components/resources/skeleton/skeleton-finding-summary.tsx b/ui/components/resources/skeleton/skeleton-finding-summary.tsx deleted file mode 100644 index dde0fee56d..0000000000 --- a/ui/components/resources/skeleton/skeleton-finding-summary.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from "react"; - -export const SkeletonFindingSummary = () => { - return ( -
-
-
-
-
-
-
-
-
-
- ); -}; diff --git a/ui/components/resources/table/index.ts b/ui/components/resources/table/index.ts index a03bacf648..65e520c5ce 100644 --- a/ui/components/resources/table/index.ts +++ b/ui/components/resources/table/index.ts @@ -1,5 +1,4 @@ export * from "../skeleton/skeleton-table-resources"; export * from "./column-resources"; -export * from "./resource-detail"; export * from "./resource-findings-columns"; export * from "./resources-table-with-selection"; diff --git a/ui/components/resources/table/resource-detail-content.test.ts b/ui/components/resources/table/resource-detail-content.test.ts new file mode 100644 index 0000000000..a60c3bfb9f --- /dev/null +++ b/ui/components/resources/table/resource-detail-content.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("resource detail content", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const filePath = path.join(currentDir, "resource-detail-content.tsx"); + const source = readFileSync(filePath, "utf8"); + + it("renders the new finding detail drawer flow instead of the legacy finding detail component", () => { + expect(source).toContain("FindingDetailDrawer"); + expect(source).not.toContain("FindingDetail findingDetails"); + }); + + it("loads the drawer bootstrap data through a single shared resource action", () => { + expect(source).toContain("useResourceDrawerBootstrap"); + expect(source).not.toContain("getResourceDrawerData"); + expect(source).not.toContain("listOrganizationsSafe"); + expect(source).not.toContain("getResourceById"); + expect(source).not.toContain("getLatestFindings"); + }); + + it("does not import useEffect directly and relies on hooks/keyed remounts instead", () => { + expect(source).not.toContain("useEffect"); + expect(source).not.toContain("useEffect("); + }); +}); diff --git a/ui/components/resources/table/resource-detail-content.tsx b/ui/components/resources/table/resource-detail-content.tsx index ac1800acc6..6492f7f685 100644 --- a/ui/components/resources/table/resource-detail-content.tsx +++ b/ui/components/resources/table/resource-detail-content.tsx @@ -8,16 +8,12 @@ import { CornerDownRight, ExternalLink, Link, - Loader2, } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; -import { getFindingById, getLatestFindings } from "@/actions/findings"; -import { listOrganizationsSafe } from "@/actions/organizations/organizations"; -import { getResourceById } from "@/actions/resources"; import { FloatingMuteButton } from "@/components/findings/floating-mute-button"; -import { FindingDetail } from "@/components/findings/table/finding-detail"; +import { FindingDetailDrawer } from "@/components/findings/table"; import { Card, Tabs, @@ -32,56 +28,23 @@ import { InfoField, InfoTooltip, } from "@/components/shadcn/info-field/info-field"; +import { LoadingState } from "@/components/shadcn/spinner/loading-state"; import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline"; import { BreadcrumbNavigation, CustomBreadcrumbItem } from "@/components/ui"; import { DateWithTime } from "@/components/ui/entities/date-with-time"; import { EntityInfo } from "@/components/ui/entities/entity-info"; import { DataTable } from "@/components/ui/table"; -import { createDict } from "@/lib"; import { getGroupLabel } from "@/lib/categories"; import { buildGitFileUrl } from "@/lib/iac-utils"; import { getRegionFlag } from "@/lib/region-flags"; -import { - FindingProps, - MetaDataProps, - ProviderType, - ResourceProps, -} from "@/types"; -import { OrganizationResource } from "@/types/organizations"; +import { ProviderType, ResourceProps } from "@/types"; import { getResourceFindingsColumns, ResourceFinding, } from "./resource-findings-columns"; - -function useProviderOrganization( - providerId: string, - providerType: string, -): OrganizationResource | null { - const [org, setOrg] = useState(null); - const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; - - useEffect(() => { - if (!isCloudEnv || providerType !== "aws") { - setOrg(null); - return; - } - - const loadOrg = async () => { - const response = await listOrganizationsSafe(); - const found = response.data.find((o: OrganizationResource) => - o.relationships?.providers?.data?.some( - (p: { id: string }) => p.id === providerId, - ), - ); - setOrg(found ?? null); - }; - - loadOrg(); - }, [isCloudEnv, providerType, providerId]); - - return org; -} +import { useFindingDetails } from "./use-finding-details"; +import { useResourceDrawerBootstrap } from "./use-resource-drawer-bootstrap"; const renderValue = (value: string | null | undefined) => { return value && value.trim() !== "" ? value : "-"; @@ -146,27 +109,16 @@ interface ResourceDetailContentProps { export const ResourceDetailContent = ({ resourceDetails, }: ResourceDetailContentProps) => { - const [findingsData, setFindingsData] = useState([]); - const [findingsMetadata, setFindingsMetadata] = - useState(null); - const [resourceTags, setResourceTags] = useState>({}); - const [findingsLoading, setFindingsLoading] = useState(true); - const [hasInitiallyLoaded, setHasInitiallyLoaded] = useState(false); const [findingsReloadNonce, setFindingsReloadNonce] = useState(0); const [selectedFindingId, setSelectedFindingId] = useState( null, ); - const [findingDetails, setFindingDetails] = useState( - null, - ); - const [findingDetailLoading, setFindingDetailLoading] = useState(false); const [rowSelection, setRowSelection] = useState({}); const [activeTab, setActiveTab] = useState("findings"); const [metadataCopied, setMetadataCopied] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(10); const [searchQuery, setSearchQuery] = useState(""); - const findingFetchRef = useRef(null); const router = useRouter(); const resource = resourceDetails; @@ -174,22 +126,29 @@ export const ResourceDetailContent = ({ const attributes = resource.attributes; const providerData = resource.relationships.provider.data.attributes; const providerId = resource.relationships.provider.data.id; - const providerOrg = useProviderOrganization( + const { + findingsData, + findingsMetadata, + findingsLoading, + hasInitiallyLoaded, + providerOrg, + resourceTags, + } = useResourceDrawerBootstrap({ + resourceId, + resourceUid: attributes.uid, providerId, - providerData.provider, - ); - - // Reset to overview tab when switching resources - useEffect(() => { - setActiveTab("findings"); - }, [resourceId]); - - // Cleanup abort controller on unmount - useEffect(() => { - return () => { - findingFetchRef.current?.abort(); - }; - }, []); + providerType: providerData.provider, + currentPage, + pageSize, + searchQuery, + findingsReloadNonce, + }); + const { + findingDetails, + findingDetailLoading, + navigateToFinding: loadFindingDetails, + resetFindingDetails, + } = useFindingDetails(); const copyResourceUrl = () => { const url = `${window.location.origin}/resources?resourceId=${resourceId}`; @@ -202,119 +161,14 @@ export const ResourceDetailContent = ({ setTimeout(() => setMetadataCopied(false), 2000); }; - // Load resource tags on mount - useEffect(() => { - const loadResourceTags = async () => { - try { - const resourceData = await getResourceById(resourceId, { - fields: ["tags"], - }); - if (resourceData?.data) { - setResourceTags(resourceData.data.attributes.tags || {}); - } - } catch (err) { - console.error("Error loading resource tags:", err); - setResourceTags({}); - } - }; - - if (resourceId) { - loadResourceTags(); - } - }, [resourceId]); - - // Load findings with server-side pagination and search - useEffect(() => { - const loadFindings = async () => { - setFindingsLoading(true); - - try { - const findingsResponse = await getLatestFindings({ - page: currentPage, - pageSize, - query: searchQuery, - sort: "severity,-inserted_at", - filters: { - "filter[resource_uid]": attributes.uid, - "filter[status]": "FAIL", - }, - }); - - if (findingsResponse?.data) { - setFindingsMetadata(findingsResponse.meta || null); - setFindingsData(findingsResponse.data as ResourceFinding[]); - } else { - setFindingsData([]); - setFindingsMetadata(null); - } - } catch (err) { - console.error("Error loading findings:", err); - setFindingsData([]); - setFindingsMetadata(null); - } finally { - setFindingsLoading(false); - setHasInitiallyLoaded(true); - } - }; - - if (attributes.uid) { - loadFindings(); - } - }, [attributes.uid, currentPage, pageSize, searchQuery, findingsReloadNonce]); - const navigateToFinding = async (findingId: string) => { - if (findingFetchRef.current) { - findingFetchRef.current.abort(); - } - findingFetchRef.current = new AbortController(); - setSelectedFindingId(findingId); - setFindingDetailLoading(true); - - try { - const findingData = await getFindingById( - findingId, - "resources,scan.provider", - ); - - if (findingFetchRef.current?.signal.aborted) { - return; - } - - if (findingData?.data) { - const resourceDict = createDict("resources", findingData); - const scanDict = createDict("scans", findingData); - const providerDict = createDict("providers", findingData); - - const finding = findingData.data; - const scan = scanDict[finding.relationships?.scan?.data?.id]; - const foundResource = - resourceDict[finding.relationships?.resources?.data?.[0]?.id]; - const provider = providerDict[scan?.relationships?.provider?.data?.id]; - - const expandedFinding = { - ...finding, - relationships: { scan, resource: foundResource, provider }, - }; - - setFindingDetails(expandedFinding); - } - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - return; - } - console.error("Error fetching finding:", error); - } finally { - if (!findingFetchRef.current?.signal.aborted) { - setFindingDetailLoading(false); - } - } + await loadFindingDetails(findingId); }; const handleBackToResource = () => { setSelectedFindingId(null); - setFindingDetails(null); - setFindingDetailLoading(false); + resetFindingDetails(); }; const handleMuteComplete = (_findingIds?: string[]) => { @@ -332,11 +186,6 @@ export const ResourceDetailContent = ({ (f) => !f.attributes.muted, ).length; - // Reset selection when page changes - useEffect(() => { - setRowSelection({}); - }, [currentPage, pageSize]); - const totalFindings = findingsMetadata?.pagination?.count || 0; const getRowCanSelect = (row: Row): boolean => @@ -396,14 +245,16 @@ export const ResourceDetailContent = ({ /> {findingDetailLoading ? ( -
- -

- Loading finding details... -

-
+ ) : ( - findingDetails && + findingDetails && ( + + ) )}
); @@ -543,12 +394,7 @@ export const ResourceDetailContent = ({
{findingsLoading && !hasInitiallyLoaded ? ( -
- -

- Loading findings... -

-
+ ) : ( <> { + setRowSelection({}); setSearchQuery(value); setCurrentPage(1); }} controlledPage={currentPage} controlledPageSize={pageSize} - onPageChange={setCurrentPage} - onPageSizeChange={setPageSize} + onPageChange={(page) => { + setRowSelection({}); + setCurrentPage(page); + }} + onPageSizeChange={(size) => { + setRowSelection({}); + setCurrentPage(1); + setPageSize(size); + }} isLoading={findingsLoading} /> {selectedFindingIds.length > 0 && ( @@ -655,10 +509,12 @@ export const ResourceDetailContent = ({
- + {activeTab === "events" && ( + + )}
diff --git a/ui/components/resources/table/resource-detail.tsx b/ui/components/resources/table/resource-detail.tsx deleted file mode 100644 index 88cc57095c..0000000000 --- a/ui/components/resources/table/resource-detail.tsx +++ /dev/null @@ -1,85 +0,0 @@ -"use client"; - -import { X } from "lucide-react"; -import type { ReactNode } from "react"; -import { useState } from "react"; - -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from "@/components/shadcn"; -import { ResourceProps } from "@/types"; - -import { ResourceDetailContent } from "./resource-detail-content"; - -interface ResourceDetailProps { - resourceDetails: ResourceProps; - trigger?: ReactNode; - open?: boolean; - defaultOpen?: boolean; - onOpenChange?: (open: boolean) => void; -} - -/** - * Lightweight wrapper component for resource details. - * - * When used with a trigger (table rows), this component only renders the Drawer shell - * and trigger. The heavy ResourceDetailContent is only mounted when the drawer is open, - * preventing unnecessary state initialization and data fetching for closed drawers. - * - * When used without a trigger (inline mode from ResourceDetailsSheet), it renders - * the content directly since it's already visible. - */ -export const ResourceDetail = ({ - resourceDetails, - trigger, - open: controlledOpen, - defaultOpen = false, - onOpenChange, -}: ResourceDetailProps) => { - // Track internal open state for uncontrolled drawer (when using trigger) - const [internalOpen, setInternalOpen] = useState(defaultOpen); - - // Determine actual open state - const isOpen = controlledOpen ?? internalOpen; - - // Handle open state changes - const handleOpenChange = (newOpen: boolean) => { - setInternalOpen(newOpen); - onOpenChange?.(newOpen); - }; - - // If no trigger, render content directly (inline mode for ResourceDetailsSheet) - if (!trigger) { - return ; - } - - // With trigger, wrap in Drawer - content only mounts when open (lazy loading) - return ( - - {trigger} - - - Resource Details - View the resource details - - - - Close - - {/* Content only renders when drawer is open - this is the key optimization */} - {isOpen && } - - - ); -}; diff --git a/ui/components/resources/table/use-finding-details.ts b/ui/components/resources/table/use-finding-details.ts new file mode 100644 index 0000000000..b95770a6a6 --- /dev/null +++ b/ui/components/resources/table/use-finding-details.ts @@ -0,0 +1,72 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import { getFindingById } from "@/actions/findings"; +import { expandFindingWithRelationships } from "@/lib/finding-detail"; +import { FindingProps } from "@/types"; + +interface UseFindingDetailsReturn { + findingDetails: FindingProps | null; + findingDetailLoading: boolean; + navigateToFinding: (findingId: string) => Promise; + resetFindingDetails: () => void; +} + +export function useFindingDetails(): UseFindingDetailsReturn { + const [findingDetails, setFindingDetails] = useState( + null, + ); + const [findingDetailLoading, setFindingDetailLoading] = useState(false); + const findingFetchRef = useRef(null); + + useEffect(() => { + return () => { + findingFetchRef.current?.abort(); + }; + }, []); + + const navigateToFinding = async (findingId: string) => { + if (findingFetchRef.current) { + findingFetchRef.current.abort(); + } + findingFetchRef.current = new AbortController(); + setFindingDetailLoading(true); + + try { + const findingData = await getFindingById( + findingId, + "resources,scan.provider", + ); + + if (findingFetchRef.current?.signal.aborted) { + return; + } + + if (findingData?.data) { + setFindingDetails(expandFindingWithRelationships(findingData)); + } + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + return; + } + console.error("Error fetching finding:", error); + } finally { + if (!findingFetchRef.current?.signal.aborted) { + setFindingDetailLoading(false); + } + } + }; + + const resetFindingDetails = () => { + setFindingDetails(null); + setFindingDetailLoading(false); + }; + + return { + findingDetails, + findingDetailLoading, + navigateToFinding, + resetFindingDetails, + }; +} diff --git a/ui/components/resources/table/use-resource-drawer-bootstrap.ts b/ui/components/resources/table/use-resource-drawer-bootstrap.ts new file mode 100644 index 0000000000..7508a70626 --- /dev/null +++ b/ui/components/resources/table/use-resource-drawer-bootstrap.ts @@ -0,0 +1,115 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { getResourceDrawerData } from "@/actions/resources"; +import { MetaDataProps } from "@/types"; +import { OrganizationResource } from "@/types/organizations"; + +import { ResourceFinding } from "./resource-findings-columns"; + +interface UseResourceDrawerBootstrapOptions { + resourceId: string; + resourceUid: string; + providerId: string; + providerType: string; + currentPage: number; + pageSize: number; + searchQuery: string; + findingsReloadNonce: number; +} + +interface UseResourceDrawerBootstrapReturn { + findingsData: ResourceFinding[]; + findingsMetadata: MetaDataProps | null; + findingsLoading: boolean; + hasInitiallyLoaded: boolean; + providerOrg: OrganizationResource | null; + resourceTags: Record; +} + +export function useResourceDrawerBootstrap({ + resourceId, + resourceUid, + providerId, + providerType, + currentPage, + pageSize, + searchQuery, + findingsReloadNonce, +}: UseResourceDrawerBootstrapOptions): UseResourceDrawerBootstrapReturn { + const [findingsData, setFindingsData] = useState([]); + const [findingsMetadata, setFindingsMetadata] = + useState(null); + const [resourceTags, setResourceTags] = useState>({}); + const [findingsLoading, setFindingsLoading] = useState(true); + const [hasInitiallyLoaded, setHasInitiallyLoaded] = useState(false); + const [providerOrg, setProviderOrg] = useState( + null, + ); + + useEffect(() => { + let cancelled = false; + + const loadResourceDrawerData = async () => { + setFindingsLoading(true); + + try { + const drawerData = await getResourceDrawerData({ + resourceId, + resourceUid, + providerId, + providerType, + page: currentPage, + pageSize, + query: searchQuery, + }); + + if (cancelled) return; + + setResourceTags(drawerData.resourceTags); + setProviderOrg(drawerData.providerOrg); + setFindingsMetadata(drawerData.findingsMeta as MetaDataProps | null); + setFindingsData(drawerData.findings as ResourceFinding[]); + } catch (error) { + if (cancelled) return; + console.error("Error loading resource drawer data:", error); + setResourceTags({}); + setProviderOrg(null); + setFindingsData([]); + setFindingsMetadata(null); + } finally { + if (!cancelled) { + setFindingsLoading(false); + setHasInitiallyLoaded(true); + } + } + }; + + if (resourceUid) { + loadResourceDrawerData(); + } + + return () => { + cancelled = true; + }; + }, [ + currentPage, + findingsReloadNonce, + pageSize, + providerId, + providerType, + resourceId, + resourceUid, + searchQuery, + ]); + + return { + findingsData, + findingsMetadata, + findingsLoading, + hasInitiallyLoaded, + providerOrg, + resourceTags, + }; +} diff --git a/ui/components/shadcn/spinner/loading-state.test.tsx b/ui/components/shadcn/spinner/loading-state.test.tsx new file mode 100644 index 0000000000..c2cddc59e7 --- /dev/null +++ b/ui/components/shadcn/spinner/loading-state.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { LoadingState } from "./loading-state"; + +describe("LoadingState", () => { + it("renders a spinner with the default size", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg).toHaveAttribute("aria-label", "Loading"); + expect( + svg?.className.baseVal ?? svg?.getAttribute("class") ?? "", + ).toContain("size-6"); + }); + + it("does not render a label when none is provided", () => { + render(); + expect(screen.queryByText(/loading/i, { selector: "span" })).toBeNull(); + }); + + it("renders the label when provided", () => { + render(); + expect(screen.getByText("Loading findings...")).toBeInTheDocument(); + }); + + it("forwards spinnerClassName to the Spinner so callers can override the size", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg?.getAttribute("class") ?? "").toContain("size-5"); + }); + + it("forwards className to the wrapper element", () => { + const { container } = render(); + expect(container.firstChild).toHaveClass("custom-wrapper"); + }); +}); diff --git a/ui/components/shadcn/spinner/loading-state.tsx b/ui/components/shadcn/spinner/loading-state.tsx new file mode 100644 index 0000000000..77355c840f --- /dev/null +++ b/ui/components/shadcn/spinner/loading-state.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +import { Spinner } from "./spinner"; + +interface LoadingStateProps { + label?: string; + className?: string; + spinnerClassName?: string; +} + +export function LoadingState({ + label, + className, + spinnerClassName, +}: LoadingStateProps) { + return ( +
+ + {label && ( + {label} + )} +
+ ); +} diff --git a/ui/components/shared/events-timeline/events-timeline.test.ts b/ui/components/shared/events-timeline/events-timeline.test.ts new file mode 100644 index 0000000000..41354c4747 --- /dev/null +++ b/ui/components/shared/events-timeline/events-timeline.test.ts @@ -0,0 +1,17 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("events timeline", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const filePath = path.join(currentDir, "events-timeline.tsx"); + const source = readFileSync(filePath, "utf8"); + + it("delegates resource event loading to a dedicated hook instead of using useEffect in the component", () => { + expect(source).toContain("useResourceEventsTimeline"); + expect(source).not.toContain("getResourceEvents"); + expect(source).not.toContain("useEffect("); + }); +}); diff --git a/ui/components/shared/events-timeline/events-timeline.tsx b/ui/components/shared/events-timeline/events-timeline.tsx index c4f248ed51..e386e5528d 100644 --- a/ui/components/shared/events-timeline/events-timeline.tsx +++ b/ui/components/shared/events-timeline/events-timeline.tsx @@ -8,9 +8,8 @@ import { Server, Shield, } from "lucide-react"; -import { useEffect, useState, useTransition } from "react"; +import { useState } from "react"; -import { getResourceEvents } from "@/actions/resources"; import { Alert, AlertDescription, @@ -25,6 +24,8 @@ import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; import { cn } from "@/lib/utils"; import { ResourceEventProps } from "@/types"; +import { useResourceEventsTimeline } from "./use-resource-events-timeline"; + interface EventsTimelineProps { resourceId?: string; isAwsProvider: boolean; @@ -34,59 +35,17 @@ export const EventsTimeline = ({ resourceId, isAwsProvider, }: EventsTimelineProps) => { - const [events, setEvents] = useState([]); - const [error, setError] = useState(null); - const [errorStatus, setErrorStatus] = useState(null); const [expandedRows, setExpandedRows] = useState>(new Set()); const [includeReadEvents, setIncludeReadEvents] = useState(false); - const [hasFetched, setHasFetched] = useState(false); const [retryCount, setRetryCount] = useState(0); - const [isPending, startTransition] = useTransition(); - - useEffect(() => { - if (!isAwsProvider || !resourceId) return; - - let cancelled = false; - - setError(null); - setErrorStatus(null); - setHasFetched(false); - - startTransition(async () => { - try { - const response = await getResourceEvents(resourceId, { - includeReadEvents, - }); - - if (cancelled) return; - - if (!response) { - setError("Failed to fetch events. Please try again."); - return; - } - - if (response.error) { - setError(response.error); - setErrorStatus(response.status || null); - return; - } - - setEvents(response.data || []); - setExpandedRows(new Set()); - } catch (err) { - if (cancelled) return; - console.error("Error fetching events:", err); - setError("An unexpected error occurred."); - } finally { - if (!cancelled) setHasFetched(true); - } + const { events, error, errorStatus, hasFetched, isPending } = + useResourceEventsTimeline({ + resourceId, + isAwsProvider, + includeReadEvents, + retryCount, }); - return () => { - cancelled = true; - }; - }, [resourceId, includeReadEvents, isAwsProvider, retryCount]); - const toggleRow = (eventId: string) => { setExpandedRows((prev) => { const next = new Set(prev); diff --git a/ui/components/shared/events-timeline/use-resource-events-timeline.ts b/ui/components/shared/events-timeline/use-resource-events-timeline.ts new file mode 100644 index 0000000000..9c0717be27 --- /dev/null +++ b/ui/components/shared/events-timeline/use-resource-events-timeline.ts @@ -0,0 +1,85 @@ +"use client"; + +import { useEffect, useState, useTransition } from "react"; + +import { getResourceEvents } from "@/actions/resources"; +import { ResourceEventProps } from "@/types"; + +interface UseResourceEventsTimelineOptions { + resourceId?: string; + isAwsProvider: boolean; + includeReadEvents: boolean; + retryCount: number; +} + +interface UseResourceEventsTimelineReturn { + events: ResourceEventProps[]; + error: string | null; + errorStatus: number | null; + hasFetched: boolean; + isPending: boolean; +} + +export function useResourceEventsTimeline({ + resourceId, + isAwsProvider, + includeReadEvents, + retryCount, +}: UseResourceEventsTimelineOptions): UseResourceEventsTimelineReturn { + const [events, setEvents] = useState([]); + const [error, setError] = useState(null); + const [errorStatus, setErrorStatus] = useState(null); + const [hasFetched, setHasFetched] = useState(false); + const [isPending, startTransition] = useTransition(); + + useEffect(() => { + if (!isAwsProvider || !resourceId) return; + + let cancelled = false; + + setError(null); + setErrorStatus(null); + setHasFetched(false); + + startTransition(async () => { + try { + const response = await getResourceEvents(resourceId, { + includeReadEvents, + }); + + if (cancelled) return; + + if (!response) { + setError("Failed to fetch events. Please try again."); + return; + } + + if (response.error) { + setError(response.error); + setErrorStatus(response.status || null); + return; + } + + setEvents(response.data || []); + } catch (err) { + if (cancelled) return; + console.error("Error fetching events:", err); + setError("An unexpected error occurred."); + } finally { + if (!cancelled) setHasFetched(true); + } + }); + + return () => { + cancelled = true; + }; + }, [resourceId, includeReadEvents, isAwsProvider, retryCount]); + + return { + events, + error, + errorStatus, + hasFetched, + isPending, + }; +} diff --git a/ui/components/ui/custom/custom-link.test.ts b/ui/components/ui/custom/custom-link.test.ts new file mode 100644 index 0000000000..1f5a47a054 --- /dev/null +++ b/ui/components/ui/custom/custom-link.test.ts @@ -0,0 +1,17 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("custom link", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const filePath = path.join(currentDir, "custom-link.tsx"); + const source = readFileSync(filePath, "utf8"); + + it("renders external or placeholder hrefs as plain anchors instead of next/link", () => { + expect(source).toContain("isExternalHref"); + expect(source).toContain("hasDynamicHrefPlaceholder"); + expect(source).toContain(" { +interface CustomLinkProps extends AnchorHTMLAttributes { href: string; target?: "_self" | "_blank" | string; ariaLabel?: string; className?: string; - children: React.ReactNode; + children: ReactNode; scroll?: boolean; size?: string; } -export const CustomLink = React.forwardRef( +function isExternalHref(href: string) { + return /^https?:\/\//.test(href) || href.startsWith("mailto:"); +} + +function hasDynamicHrefPlaceholder(href: string) { + return href.includes("[") && href.includes("]"); +} + +export const CustomLink = forwardRef( ( { href, @@ -28,6 +35,29 @@ export const CustomLink = React.forwardRef( }, ref, ) => { + const linkClassName = cn( + `text-${size} text-button-tertiary p-0`, + className, + ); + const shouldUseAnchor = + isExternalHref(href) || hasDynamicHrefPlaceholder(href); + + if (shouldUseAnchor) { + return ( + + {children} + + ); + } + return ( ( aria-label={ariaLabel} target={target} rel={target === "_blank" ? "noopener noreferrer" : undefined} - className={cn(`text-${size} text-button-tertiary p-0`, className)} + className={linkClassName} {...props} > {children} diff --git a/ui/hooks/use-finding-group-resource-state.ts b/ui/hooks/use-finding-group-resource-state.ts new file mode 100644 index 0000000000..6374a5319b --- /dev/null +++ b/ui/hooks/use-finding-group-resource-state.ts @@ -0,0 +1,159 @@ +"use client"; + +import { OnChangeFn, Row, RowSelectionState } from "@tanstack/react-table"; +import { useState } from "react"; + +import { canMuteFindingResource } from "@/components/findings/table/finding-resource-selection"; +import { useResourceDetailDrawer } from "@/components/findings/table/resource-detail-drawer"; +import { useFindingGroupResources } from "@/hooks/use-finding-group-resources"; +import { FindingGroupRow, FindingResourceRow } from "@/types"; + +interface UseFindingGroupResourceStateOptions { + group: FindingGroupRow; + filters: Record; + hasHistoricalData: boolean; + onResourceSelectionChange?: (findingIds: string[]) => void; + scrollContainerRef?: React.RefObject; +} + +interface UseFindingGroupResourceStateReturn { + rowSelection: RowSelectionState; + resources: FindingResourceRow[]; + isLoading: boolean; + sentinelRef: (node: HTMLDivElement | null) => void; + refresh: () => void; + loadMore: () => void; + totalCount: number | null; + drawer: ReturnType; + handleDrawerMuteComplete: () => void; + selectedFindingIds: string[]; + selectableRowCount: number; + getRowCanSelect: (row: Row) => boolean; + clearSelection: () => void; + isSelected: (id: string) => boolean; + handleMuteComplete: () => void; + handleRowSelectionChange: OnChangeFn; + resolveSelectedFindingIds: (ids: string[]) => Promise; +} + +export function useFindingGroupResourceState({ + group, + filters, + hasHistoricalData, + onResourceSelectionChange, + scrollContainerRef, +}: UseFindingGroupResourceStateOptions): UseFindingGroupResourceStateReturn { + const [rowSelection, setRowSelection] = useState({}); + const [resources, setResources] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const handleSetResources = ( + newResources: FindingResourceRow[], + _hasMore: boolean, + ) => { + setResources(newResources); + setIsLoading(false); + }; + + const handleAppendResources = ( + newResources: FindingResourceRow[], + _hasMore: boolean, + ) => { + setResources((prev) => [...prev, ...newResources]); + setIsLoading(false); + }; + + const handleSetLoading = (loading: boolean) => { + setIsLoading(loading); + }; + + const { sentinelRef, refresh, loadMore, totalCount } = + useFindingGroupResources({ + checkId: group.checkId, + hasDateOrScanFilter: hasHistoricalData, + filters, + onSetResources: handleSetResources, + onAppendResources: handleAppendResources, + onSetLoading: handleSetLoading, + scrollContainerRef, + }); + + const drawer = useResourceDetailDrawer({ + resources, + checkId: group.checkId, + totalResourceCount: totalCount ?? group.resourcesTotal, + onRequestMoreResources: loadMore, + }); + + const handleDrawerMuteComplete = () => { + drawer.refetchCurrent(); + refresh(); + }; + + const selectedFindingIds = Object.keys(rowSelection) + .filter((key) => rowSelection[key]) + .map((idx) => resources[parseInt(idx)]?.findingId) + .filter((id): id is string => Boolean(id)); + + const selectableRowCount = resources.filter(canMuteFindingResource).length; + + const getRowCanSelect = (row: Row): boolean => { + return canMuteFindingResource(row.original); + }; + + const clearSelection = () => { + setRowSelection({}); + onResourceSelectionChange?.([]); + }; + + const isSelected = (id: string) => { + return selectedFindingIds.includes(id); + }; + + const handleMuteComplete = () => { + clearSelection(); + refresh(); + }; + + const handleRowSelectionChange = ( + updater: + | RowSelectionState + | ((prev: RowSelectionState) => RowSelectionState), + ) => { + const newSelection = + typeof updater === "function" ? updater(rowSelection) : updater; + setRowSelection(newSelection); + + if (onResourceSelectionChange) { + const newFindingIds = Object.keys(newSelection) + .filter((key) => newSelection[key]) + .map((idx) => resources[parseInt(idx)]?.findingId) + .filter((id): id is string => Boolean(id)); + onResourceSelectionChange(newFindingIds); + } + }; + + const resolveSelectedFindingIds = async (ids: string[]) => { + return ids.filter(Boolean); + }; + + return { + rowSelection, + resources, + isLoading, + sentinelRef, + refresh, + loadMore, + totalCount, + drawer, + handleDrawerMuteComplete, + selectedFindingIds, + selectableRowCount, + getRowCanSelect, + clearSelection, + isSelected, + handleMuteComplete, + handleRowSelectionChange, + resolveSelectedFindingIds, + }; +} diff --git a/ui/hooks/use-finding-group-resources.test.ts b/ui/hooks/use-finding-group-resources.test.ts new file mode 100644 index 0000000000..c2fe2e4145 --- /dev/null +++ b/ui/hooks/use-finding-group-resources.test.ts @@ -0,0 +1,206 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useFindingGroupResources } from "./use-finding-group-resources"; + +type IntersectionCallback = (entries: IntersectionObserverEntry[]) => void; + +let latestObserverCallback: IntersectionCallback | null = null; + +class MockIntersectionObserver { + callback: IntersectionCallback; + constructor(callback: IntersectionCallback) { + this.callback = callback; + latestObserverCallback = callback; + } + observe() {} + unobserve() {} + disconnect() { + if (latestObserverCallback === this.callback) { + latestObserverCallback = null; + } + } +} + +function triggerIntersection() { + latestObserverCallback?.([ + { isIntersecting: true } as IntersectionObserverEntry, + ]); +} + +const findingGroupActionsMock = vi.hoisted(() => ({ + getLatestFindingGroupResources: vi.fn(), + getFindingGroupResources: vi.fn(), + adaptFindingGroupResourcesResponse: vi.fn(), +})); + +vi.mock("@/actions/finding-groups", () => findingGroupActionsMock); + +function makeApiResponse( + resources: { id: string }[], + { pages = 1 }: { pages?: number } = {}, +) { + return { + data: resources, + meta: { pagination: { pages } }, + }; +} + +function fakeResource(id: string) { + return { + findingId: id, + resourceUid: `uid-${id}`, + resourceName: `Resource ${id}`, + status: "FAIL", + severity: "high", + isMuted: false, + }; +} + +function defaultOptions(overrides?: Record) { + return { + checkId: "check_1", + hasDateOrScanFilter: false, + filters: {}, + onSetResources: vi.fn(), + onAppendResources: vi.fn(), + onSetLoading: vi.fn(), + ...overrides, + }; +} + +async function flushAsync() { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); +} + +describe("useFindingGroupResources", () => { + beforeEach(() => { + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); + for (const mockFn of Object.values(findingGroupActionsMock)) { + mockFn.mockReset(); + } + }); + + describe("when mounting", () => { + it("should fetch page 1 and deliver resources via onSetResources", async () => { + const apiResponse = makeApiResponse([{ id: "r1" }, { id: "r2" }], { + pages: 1, + }); + const adapted = [fakeResource("r1"), fakeResource("r2")]; + + findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue( + apiResponse, + ); + findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue( + adapted, + ); + + const onSetResources = vi.fn(); + const onSetLoading = vi.fn(); + + renderHook(() => + useFindingGroupResources( + defaultOptions({ onSetResources, onSetLoading }), + ), + ); + await flushAsync(); + + expect( + findingGroupActionsMock.getLatestFindingGroupResources, + ).toHaveBeenCalledWith( + expect.objectContaining({ + checkId: "check_1", + page: 1, + pageSize: 10, + }), + ); + expect(onSetResources).toHaveBeenCalledWith(adapted, false); + }); + + it("should use getFindingGroupResources when hasDateOrScanFilter is true", async () => { + const apiResponse = makeApiResponse([], { pages: 1 }); + findingGroupActionsMock.getFindingGroupResources.mockResolvedValue( + apiResponse, + ); + findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue( + [], + ); + + renderHook(() => + useFindingGroupResources(defaultOptions({ hasDateOrScanFilter: true })), + ); + await flushAsync(); + + expect( + findingGroupActionsMock.getFindingGroupResources, + ).toHaveBeenCalledTimes(1); + expect( + findingGroupActionsMock.getLatestFindingGroupResources, + ).not.toHaveBeenCalled(); + }); + + it("should forward the active finding-group filters to the resources endpoint", async () => { + const apiResponse = makeApiResponse([], { pages: 1 }); + const filters = { + "filter[status__in]": "PASS", + "filter[severity__in]": "medium", + "filter[provider_type__in]": "aws", + }; + findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue( + apiResponse, + ); + findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue( + [], + ); + + renderHook(() => useFindingGroupResources(defaultOptions({ filters }))); + await flushAsync(); + + expect( + findingGroupActionsMock.getLatestFindingGroupResources, + ).toHaveBeenCalledWith( + expect.objectContaining({ + checkId: "check_1", + page: 1, + pageSize: 10, + filters, + }), + ); + }); + }); + + describe("when all resources fit in one page", () => { + it("should not fetch page 2 after page 1 completes", async () => { + const apiResponse = makeApiResponse( + [{ id: "r1" }, { id: "r2" }, { id: "r3" }, { id: "r4" }], + { pages: 1 }, + ); + findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue( + apiResponse, + ); + findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue( + [ + fakeResource("r1"), + fakeResource("r2"), + fakeResource("r3"), + fakeResource("r4"), + ], + ); + + const { result } = renderHook(() => + useFindingGroupResources(defaultOptions()), + ); + await flushAsync(); + + act(() => triggerIntersection()); + await flushAsync(); + + expect( + findingGroupActionsMock.getLatestFindingGroupResources, + ).toHaveBeenCalledTimes(1); + expect(result.current.totalCount).toBeNull(); + }); + }); +}); diff --git a/ui/hooks/use-infinite-resources.ts b/ui/hooks/use-finding-group-resources.ts similarity index 64% rename from ui/hooks/use-infinite-resources.ts rename to ui/hooks/use-finding-group-resources.ts index df710ebf10..7ef4813be0 100644 --- a/ui/hooks/use-infinite-resources.ts +++ b/ui/hooks/use-finding-group-resources.ts @@ -12,7 +12,7 @@ import { FindingResourceRow } from "@/types"; const RESOURCES_PAGE_SIZE = 10; -interface UseInfiniteResourcesOptions { +interface UseFindingGroupResourcesOptions { checkId: string; hasDateOrScanFilter: boolean; filters: Record; @@ -22,28 +22,17 @@ interface UseInfiniteResourcesOptions { hasMore: boolean, ) => void; onSetLoading: (loading: boolean) => void; - /** Scroll container element for IntersectionObserver root. Defaults to viewport. */ scrollContainerRef?: React.RefObject; } -interface UseInfiniteResourcesReturn { +interface UseFindingGroupResourcesReturn { sentinelRef: (node: HTMLDivElement | null) => void; - /** Reset pagination and re-fetch page 1 (e.g. after muting). */ refresh: () => void; - /** Imperatively load the next page (e.g. from drawer navigation). */ loadMore: () => void; - /** Total number of resources matching current filters (from API pagination). */ totalCount: number | null; } -/** - * Hook for paginated infinite-scroll loading of finding group resources. - * - * Uses refs for all mutable state to avoid dependency chains that - * cause infinite re-render loops. The parent component remounts this - * hook via key-prop when checkId or filters change. - */ -export function useInfiniteResources({ +export function useFindingGroupResources({ checkId, hasDateOrScanFilter, filters, @@ -51,28 +40,21 @@ export function useInfiniteResources({ onAppendResources, onSetLoading, scrollContainerRef, -}: UseInfiniteResourcesOptions): UseInfiniteResourcesReturn { - // All mutable state in refs to break dependency chains +}: UseFindingGroupResourcesOptions): UseFindingGroupResourcesReturn { const pageRef = useRef(1); const hasMoreRef = useRef(true); - // Start as `true` to block the IntersectionObserver from calling loadNextPage - // before the initial fetch runs. Ref callbacks fire during commit (sync), - // but useMountEffect fires after paint — the observer can sneak in between. const isLoadingRef = useRef(true); const currentCheckIdRef = useRef(checkId); const controllerRef = useRef(null); const observerRef = useRef(null); const totalCountRef = useRef(null); - // Store latest values in refs so the fetch function always reads current values - // without being recreated on every render const hasDateOrScanRef = useRef(hasDateOrScanFilter); const filtersRef = useRef(filters); const onSetResourcesRef = useRef(onSetResources); const onAppendResourcesRef = useRef(onAppendResources); const onSetLoadingRef = useRef(onSetLoading); - // Keep refs in sync with latest props currentCheckIdRef.current = checkId; hasDateOrScanRef.current = hasDateOrScanFilter; filtersRef.current = filters; @@ -103,7 +85,6 @@ export function useInfiniteResources({ filters: filtersRef.current, }); - // Discard stale response if aborted (e.g. Strict Mode remount) if (signal.aborted) { return; } @@ -116,10 +97,6 @@ export function useInfiniteResources({ const hasMore = page < totalPages; totalCountRef.current = response?.meta?.pagination?.count ?? null; - // Commit the page number only after a successful (non-aborted) fetch. - // This prevents a premature pageRef increment from loadNextPage being - // permanently committed if a concurrent abort fires before fetchPage - // starts executing. pageRef.current = page; hasMoreRef.current = hasMore; @@ -130,27 +107,20 @@ export function useInfiniteResources({ } } catch (error) { if (!signal.aborted) { - console.error("Error fetching resources:", error); + console.error("Error fetching finding group resources:", error); onSetLoadingRef.current(false); } } finally { - // Only release the loading guard if this fetch wasn't aborted. - // An aborted fetch (e.g. Strict Mode cleanup) must NOT reset the flag - // while a subsequent fetch from the remount is still in flight. if (!signal.aborted) { isLoadingRef.current = false; } } } - // Fetch first page on mount — parent remounts via key-prop on checkId/filter changes useMountEffect(() => { const controller = new AbortController(); controllerRef.current = controller; - // Release the loading guard so fetchPage can proceed. - // This is synchronous with the fetchPage call below, so the observer - // cannot sneak in between these two lines. isLoadingRef.current = false; fetchPage(1, false, checkId, controller.signal); @@ -167,17 +137,13 @@ export function useInfiniteResources({ isLoadingRef.current || !signal || signal.aborted - ) + ) { return; + } - // Pass the next page number as an argument without pre-committing - // pageRef.current. The fetchPage function commits pageRef.current = page - // only after a successful (non-aborted) response, eliminating the race - // where a concurrent abort would leave pageRef permanently incremented. fetchPage(pageRef.current + 1, true, currentCheckIdRef.current, signal); } - // IntersectionObserver callback function handleIntersection(entries: IntersectionObserverEntry[]) { const [entry] = entries; if (entry.isIntersecting) { @@ -185,7 +151,6 @@ export function useInfiniteResources({ } } - // Set up observer when sentinel node changes function sentinelRef(node: HTMLDivElement | null) { if (observerRef.current) { observerRef.current.disconnect(); @@ -201,7 +166,6 @@ export function useInfiniteResources({ } } - /** Imperatively reset and re-fetch page 1 without changing deps. */ function refresh() { controllerRef.current?.abort(); const controller = new AbortController(); diff --git a/ui/hooks/use-infinite-resources.test.ts b/ui/hooks/use-infinite-resources.test.ts deleted file mode 100644 index 618de56fba..0000000000 --- a/ui/hooks/use-infinite-resources.test.ts +++ /dev/null @@ -1,570 +0,0 @@ -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { useInfiniteResources } from "./use-infinite-resources"; - -// --------------------------------------------------------------------------- -// IntersectionObserver mock (jsdom doesn't provide one) -// --------------------------------------------------------------------------- - -type IntersectionCallback = (entries: IntersectionObserverEntry[]) => void; - -/** Stores the latest observer callback so tests can trigger intersections. */ -let latestObserverCallback: IntersectionCallback | null = null; - -class MockIntersectionObserver { - callback: IntersectionCallback; - constructor(callback: IntersectionCallback) { - this.callback = callback; - latestObserverCallback = callback; - } - observe() {} - unobserve() {} - disconnect() { - if (latestObserverCallback === this.callback) { - latestObserverCallback = null; - } - } -} - -/** Simulate the sentinel becoming visible in the scroll container. */ -function triggerIntersection() { - latestObserverCallback?.([ - { isIntersecting: true } as IntersectionObserverEntry, - ]); -} - -// --------------------------------------------------------------------------- -// Mocks -// --------------------------------------------------------------------------- - -const findingGroupActionsMock = vi.hoisted(() => ({ - getLatestFindingGroupResources: vi.fn(), - getFindingGroupResources: vi.fn(), - adaptFindingGroupResourcesResponse: vi.fn(), -})); - -vi.mock("@/actions/finding-groups", () => findingGroupActionsMock); - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function makeApiResponse( - resources: { id: string }[], - { pages = 1 }: { pages?: number } = {}, -) { - return { - data: resources, - meta: { pagination: { pages } }, - }; -} - -function fakeResource(id: string) { - return { - findingId: id, - resourceUid: `uid-${id}`, - resourceName: `Resource ${id}`, - status: "FAIL", - severity: "high", - isMuted: false, - }; -} - -function defaultOptions(overrides?: Record) { - return { - checkId: "check_1", - hasDateOrScanFilter: false, - filters: {}, - onSetResources: vi.fn(), - onAppendResources: vi.fn(), - onSetLoading: vi.fn(), - ...overrides, - }; -} - -/** Flush all pending microtasks (awaits in fetchPage). */ -async function flushAsync() { - await act(async () => { - await new Promise((r) => setTimeout(r, 0)); - }); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("useInfiniteResources", () => { - beforeEach(() => { - vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); - for (const mockFn of Object.values(findingGroupActionsMock)) { - mockFn.mockReset(); - } - }); - - describe("when mounting", () => { - it("should fetch page 1 and deliver resources via onSetResources", async () => { - // Given - const apiResponse = makeApiResponse([{ id: "r1" }, { id: "r2" }], { - pages: 1, - }); - const adapted = [fakeResource("r1"), fakeResource("r2")]; - - findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue( - apiResponse, - ); - findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue( - adapted, - ); - - const onSetResources = vi.fn(); - const onSetLoading = vi.fn(); - - // When - renderHook(() => - useInfiniteResources(defaultOptions({ onSetResources, onSetLoading })), - ); - await flushAsync(); - - // Then - expect( - findingGroupActionsMock.getLatestFindingGroupResources, - ).toHaveBeenCalledWith( - expect.objectContaining({ - checkId: "check_1", - page: 1, - pageSize: 10, - }), - ); - expect(onSetResources).toHaveBeenCalledWith(adapted, false); - }); - - it("should use getFindingGroupResources when hasDateOrScanFilter is true", async () => { - // Given - const apiResponse = makeApiResponse([], { pages: 1 }); - findingGroupActionsMock.getFindingGroupResources.mockResolvedValue( - apiResponse, - ); - findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue( - [], - ); - - // When - renderHook(() => - useInfiniteResources(defaultOptions({ hasDateOrScanFilter: true })), - ); - await flushAsync(); - - // Then - expect( - findingGroupActionsMock.getFindingGroupResources, - ).toHaveBeenCalledTimes(1); - expect( - findingGroupActionsMock.getLatestFindingGroupResources, - ).not.toHaveBeenCalled(); - }); - - it("should forward the active finding-group filters to the resources endpoint", async () => { - // Given - const apiResponse = makeApiResponse([], { pages: 1 }); - const filters = { - "filter[status__in]": "PASS", - "filter[severity__in]": "medium", - "filter[provider_type__in]": "aws", - }; - findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue( - apiResponse, - ); - findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue( - [], - ); - - // When - renderHook(() => useInfiniteResources(defaultOptions({ filters }))); - await flushAsync(); - - // Then - expect( - findingGroupActionsMock.getLatestFindingGroupResources, - ).toHaveBeenCalledWith( - expect.objectContaining({ - checkId: "check_1", - page: 1, - pageSize: 10, - filters, - }), - ); - }); - }); - - describe("when all resources fit in one page", () => { - it("should not fetch page 2 after page 1 completes", async () => { - // Given — API returns 4 resources, 1 page total - const apiResponse = makeApiResponse( - [{ id: "r1" }, { id: "r2" }, { id: "r3" }, { id: "r4" }], - { pages: 1 }, - ); - findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue( - apiResponse, - ); - findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue( - [ - fakeResource("r1"), - fakeResource("r2"), - fakeResource("r3"), - fakeResource("r4"), - ], - ); - - // When - const { result } = renderHook(() => - useInfiniteResources(defaultOptions()), - ); - await flushAsync(); - - // Attach sentinel so observer is created - const sentinel = document.createElement("div"); - act(() => { - result.current.sentinelRef(sentinel); - }); - - // Simulate observer firing (sentinel visible after page 1 loaded) - act(() => { - triggerIntersection(); - }); - await flushAsync(); - - // Then — only page 1 was fetched, never page 2 - const calls = - findingGroupActionsMock.getLatestFindingGroupResources.mock.calls; - const pageNumbers = calls.map( - (c: unknown[]) => (c[0] as { page: number }).page, - ); - expect(pageNumbers.every((p: number) => p === 1)).toBe(true); - }); - }); - - describe("when aborted fetch races with active fetch", () => { - it("should not reset isLoading when an aborted fetch resolves", async () => { - // Given — simulate the Strict Mode race condition: - // fetch1 starts, gets aborted, fetch2 starts, fetch1's finally runs - const onSetResources = vi.fn(); - const onSetLoading = vi.fn(); - - // fetch1 resolves slowly (after abort) - let resolveFetch1: (v: unknown) => void; - const fetch1Promise = new Promise((r) => { - resolveFetch1 = r; - }); - - // fetch2 resolves normally - const apiResponse = makeApiResponse([{ id: "r1" }], { pages: 1 }); - const adapted = [fakeResource("r1")]; - - let callCount = 0; - findingGroupActionsMock.getLatestFindingGroupResources.mockImplementation( - () => { - callCount++; - if (callCount === 1) return fetch1Promise; - return Promise.resolve(apiResponse); - }, - ); - findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue( - adapted, - ); - - // When — mount, abort (simulating cleanup), remount - const { unmount } = renderHook(() => - useInfiniteResources(defaultOptions({ onSetResources, onSetLoading })), - ); - - // Simulate Strict Mode: unmount triggers abort - unmount(); - - // Fetch1 resolves AFTER abort — its finally should NOT reset isLoading - await act(async () => { - resolveFetch1!(apiResponse); - await new Promise((r) => setTimeout(r, 0)); - }); - - // Then — onSetResources should NOT have been called by the aborted fetch - // (the signal.aborted check returns early) - expect(onSetResources).not.toHaveBeenCalled(); - }); - }); - - describe("when sentinel triggers next page", () => { - it("should fetch page 2 via onAppendResources when hasMore is true", async () => { - // Given — page 1 has more pages - const page1Response = makeApiResponse( - Array.from({ length: 10 }, (_, i) => ({ id: `r${i}` })), - { pages: 3 }, - ); - const page1Adapted = Array.from({ length: 10 }, (_, i) => - fakeResource(`r${i}`), - ); - - const page2Response = makeApiResponse( - Array.from({ length: 10 }, (_, i) => ({ id: `r${10 + i}` })), - { pages: 3 }, - ); - const page2Adapted = Array.from({ length: 10 }, (_, i) => - fakeResource(`r${10 + i}`), - ); - - findingGroupActionsMock.getLatestFindingGroupResources - .mockResolvedValueOnce(page1Response) - .mockResolvedValueOnce(page2Response); - findingGroupActionsMock.adaptFindingGroupResourcesResponse - .mockReturnValueOnce(page1Adapted) - .mockReturnValueOnce(page2Adapted); - - const onSetResources = vi.fn(); - const onAppendResources = vi.fn(); - - // When — mount and wait for page 1 - const { result } = renderHook(() => - useInfiniteResources( - defaultOptions({ onSetResources, onAppendResources }), - ), - ); - await flushAsync(); - - expect(onSetResources).toHaveBeenCalledWith(page1Adapted, true); - - // Attach sentinel and simulate intersection → triggers page 2 - const sentinel = document.createElement("div"); - act(() => { - result.current.sentinelRef(sentinel); - }); - act(() => { - triggerIntersection(); - }); - await flushAsync(); - - // Then - expect(onAppendResources).toHaveBeenCalledWith(page2Adapted, true); - expect( - findingGroupActionsMock.getLatestFindingGroupResources, - ).toHaveBeenCalledTimes(2); - }); - }); - - describe("when refresh is called", () => { - it("should re-fetch page 1 and deliver via onSetResources", async () => { - // Given - const apiResponse = makeApiResponse([{ id: "r1" }], { pages: 1 }); - const adapted = [fakeResource("r1")]; - - findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue( - apiResponse, - ); - findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue( - adapted, - ); - - const onSetResources = vi.fn(); - - const { result } = renderHook(() => - useInfiniteResources(defaultOptions({ onSetResources })), - ); - await flushAsync(); - - expect(onSetResources).toHaveBeenCalledTimes(1); - - // When — refresh (e.g. after muting) - act(() => { - result.current.refresh(); - }); - await flushAsync(); - - // Then — page 1 fetched again - expect(onSetResources).toHaveBeenCalledTimes(2); - const calls = - findingGroupActionsMock.getLatestFindingGroupResources.mock.calls; - expect(calls).toHaveLength(2); - expect(calls[0][0].page).toBe(1); - expect(calls[1][0].page).toBe(1); - }); - }); - - describe("when filters include search params", () => { - it("should pass filters to the fetch function", async () => { - // Given - const apiResponse = makeApiResponse([], { pages: 1 }); - findingGroupActionsMock.getLatestFindingGroupResources.mockResolvedValue( - apiResponse, - ); - findingGroupActionsMock.adaptFindingGroupResourcesResponse.mockReturnValue( - [], - ); - - const filters = { - "filter[name__icontains]": "my-resource", - "filter[severity__in]": "high", - }; - - // When - renderHook(() => useInfiniteResources(defaultOptions({ filters }))); - await flushAsync(); - - // Then - expect( - findingGroupActionsMock.getLatestFindingGroupResources, - ).toHaveBeenCalledWith(expect.objectContaining({ filters })); - }); - }); - - describe("when refresh() fires while loadNextPage is in-flight (race condition — Fix 5)", () => { - it("should discard in-flight page 2 and fetch page 1 when refresh fires during loadNextPage", async () => { - // Given — page 1 has 2 pages total, page 2 hangs indefinitely - const page1Response = makeApiResponse( - Array.from({ length: 10 }, (_, i) => ({ id: `r${i}` })), - { pages: 2 }, - ); - const page1Adapted = Array.from({ length: 10 }, (_, i) => - fakeResource(`r${i}`), - ); - - const page2Response = makeApiResponse( - Array.from({ length: 5 }, (_, i) => ({ id: `r${10 + i}` })), - { pages: 2 }, - ); - - const refreshPage1Response = makeApiResponse([{ id: "r-fresh-1" }], { - pages: 1, - }); - const refreshPage1Adapted = [fakeResource("r-fresh-1")]; - - // page 2 hangs until we explicitly resolve it - let resolveNextPage: (v: unknown) => void = () => {}; - const hangingPage2 = new Promise((r) => { - resolveNextPage = r; - }); - - let callCount = 0; - findingGroupActionsMock.getLatestFindingGroupResources.mockImplementation( - (args: { page: number }) => { - callCount++; - if (callCount === 1) { - return Promise.resolve(page1Response); - } - if (args.page === 2) { - return hangingPage2; - } - return Promise.resolve(refreshPage1Response); - }, - ); - - findingGroupActionsMock.adaptFindingGroupResourcesResponse - .mockReturnValueOnce(page1Adapted) - .mockReturnValue(refreshPage1Adapted); - - const onSetResources = vi.fn(); - const onAppendResources = vi.fn(); - - // When — mount and wait for page 1 - const { result } = renderHook(() => - useInfiniteResources( - defaultOptions({ onSetResources, onAppendResources }), - ), - ); - await flushAsync(); - - expect(onSetResources).toHaveBeenCalledWith(page1Adapted, true); - - // Trigger loadNextPage (increments pageRef to 2 in buggy code) - const sentinel = document.createElement("div"); - act(() => { - result.current.sentinelRef(sentinel); - }); - act(() => { - triggerIntersection(); - }); - // Do NOT flush — page 2 is hanging in-flight - - // Refresh fires while page 2 is in-flight - act(() => { - result.current.refresh(); - }); - await flushAsync(); - - // Resolve hanging page 2 after refresh (simulates late stale response) - await act(async () => { - resolveNextPage(page2Response); - await new Promise((r) => setTimeout(r, 0)); - }); - - // Then — the aborted page 2 must NOT deliver resources (signal.aborted check) - expect(onAppendResources).not.toHaveBeenCalled(); - - // The refresh must have fetched page 1 and delivered fresh resources - expect(onSetResources).toHaveBeenCalledWith(refreshPage1Adapted, false); - - // The refresh call must request page=1 (not page=3 due to stale pageRef) - // Exact call sequence: [0]=initial page 1, [1]=loadNextPage page 2, [2]=refresh page 1 - const calls = - findingGroupActionsMock.getLatestFindingGroupResources.mock.calls; - expect((calls[0][0] as { page: number }).page).toBe(1); // initial fetch - expect((calls[1][0] as { page: number }).page).toBe(2); // loadNextPage - expect((calls[2][0] as { page: number }).page).toBe(1); // refresh - }); - - it("should fetch sequential pages without skipping when loadNextPage is used normally", async () => { - // Given — page 1 has 3 pages; pages load sequentially - const makePageResponse = (startIdx: number, total: number) => - makeApiResponse( - Array.from({ length: 5 }, (_, i) => ({ id: `r${startIdx + i}` })), - { pages: total }, - ); - - findingGroupActionsMock.getLatestFindingGroupResources - .mockResolvedValueOnce(makePageResponse(0, 3)) // page 1 - .mockResolvedValueOnce(makePageResponse(5, 3)) // page 2 - .mockResolvedValueOnce(makePageResponse(10, 3)); // page 3 - - findingGroupActionsMock.adaptFindingGroupResourcesResponse - .mockReturnValueOnce( - Array.from({ length: 5 }, (_, i) => fakeResource(`r${i}`)), - ) - .mockReturnValueOnce( - Array.from({ length: 5 }, (_, i) => fakeResource(`r${5 + i}`)), - ) - .mockReturnValueOnce( - Array.from({ length: 5 }, (_, i) => fakeResource(`r${10 + i}`)), - ); - - const onAppendResources = vi.fn(); - - // When — mount and wait for page 1 - const { result } = renderHook(() => - useInfiniteResources(defaultOptions({ onAppendResources })), - ); - await flushAsync(); - - // Attach sentinel - const sentinel = document.createElement("div"); - act(() => { - result.current.sentinelRef(sentinel); - }); - - // Load page 2 - act(() => { - triggerIntersection(); - }); - await flushAsync(); - - // Load page 3 - act(() => { - triggerIntersection(); - }); - await flushAsync(); - - // Then — pages were fetched in order: 2, 3 (not 2, 4 due to double-increment) - const calls = - findingGroupActionsMock.getLatestFindingGroupResources.mock.calls; - expect(calls[1][0].page).toBe(2); - expect(calls[2][0].page).toBe(3); - }); - }); -}); diff --git a/ui/lib/finding-detail.ts b/ui/lib/finding-detail.ts new file mode 100644 index 0000000000..69c0224297 --- /dev/null +++ b/ui/lib/finding-detail.ts @@ -0,0 +1,62 @@ +import { createDict } from "@/lib"; +import type { FindingProps } from "@/types/components"; +import type { FindingResourceRow } from "@/types/findings-table"; +import type { ProviderType } from "@/types/providers"; + +interface JsonApiFindingResponse { + data?: FindingProps; + included?: Record[]; +} + +export function expandFindingWithRelationships( + apiResponse: JsonApiFindingResponse | undefined, +): FindingProps | null { + if (!apiResponse?.data) { + return null; + } + + const resourceDict = createDict("resources", apiResponse); + const scanDict = createDict("scans", apiResponse); + const providerDict = createDict("providers", apiResponse); + + const finding = apiResponse.data; + const scan = scanDict[finding.relationships?.scan?.data?.id]; + const resource = + resourceDict[finding.relationships?.resources?.data?.[0]?.id]; + const provider = providerDict[scan?.relationships?.provider?.data?.id]; + + return { + ...finding, + relationships: { ...finding.relationships, scan, resource, provider }, + } as FindingProps; +} + +export function findingToFindingResourceRow( + finding: FindingProps, +): FindingResourceRow { + const resource = finding.relationships?.resource?.attributes; + const provider = finding.relationships?.provider?.attributes; + + return { + id: finding.id, + rowType: "resource", + findingId: finding.id, + checkId: finding.attributes.check_id, + providerType: (provider?.provider || "aws") as ProviderType, + providerAlias: provider?.alias || "-", + providerUid: provider?.uid || "-", + resourceName: resource?.name || "-", + resourceType: resource?.type || "-", + resourceGroup: "-", + resourceUid: resource?.uid || "-", + service: resource?.service || "-", + region: resource?.region || "-", + severity: finding.attributes.severity, + status: finding.attributes.status, + delta: finding.attributes.delta, + isMuted: finding.attributes.muted, + mutedReason: finding.attributes.muted_reason, + firstSeenAt: finding.attributes.first_seen_at, + lastSeenAt: finding.attributes.updated_at, + }; +}