mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +00:00
feat(ui): add compliance watchlist (#12300)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
co-authored by
alejandrobailo
parent
a6d5dbacd9
commit
5285d25cfd
@@ -9,17 +9,12 @@ describe("ComplianceCard", () => {
|
||||
const filePath = path.join(currentDir, "compliance-card.tsx");
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
|
||||
it("keeps the shadcn Card base variant", () => {
|
||||
expect(source).toContain('variant="base"');
|
||||
});
|
||||
it("keeps the logo canvas light in dark mode", () => {
|
||||
// Given
|
||||
const darkThemeSurface = "bg-bg-neutral-tertiary";
|
||||
|
||||
it("uses a single-column stacked layout", () => {
|
||||
expect(source).toContain("flex-col");
|
||||
expect(source).not.toContain("sm:flex-row");
|
||||
});
|
||||
|
||||
it("places compact actions in the icon column on larger screens", () => {
|
||||
expect(source).toContain('orientation="column"');
|
||||
expect(source).toContain('buttonWidth="icon"');
|
||||
// When / Then
|
||||
expect(source).toContain("bg-slate-50");
|
||||
expect(source).not.toContain(darkThemeSurface);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import { Card, CardAction, CardContent } from "@/components/shadcn/card/card";
|
||||
import { Progress } from "@/components/shadcn/progress";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -12,10 +13,12 @@ import {
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { buildComplianceDetailPath } from "@/lib/compliance/compliance-detail-url";
|
||||
import { getReportTypeForCompliance } from "@/lib/compliance/compliance-report-types";
|
||||
import { formatComplianceFrameworkTitle } from "@/lib/compliance/framework-title";
|
||||
import {
|
||||
getScoreIndicatorClass,
|
||||
type ScoreColorVariant,
|
||||
} from "@/lib/compliance/score-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ScanEntity } from "@/types/scans";
|
||||
|
||||
import { getComplianceIcon } from "../icons";
|
||||
@@ -40,6 +43,13 @@ interface ComplianceCardProps {
|
||||
* Ignored for non-CIS frameworks.
|
||||
*/
|
||||
isLatestCisForProvider?: boolean;
|
||||
/**
|
||||
* Watchlist control rendered in the card's top-right corner, beside the
|
||||
* export action, so it costs no vertical room. Cloud-only and gated on
|
||||
* MANAGE_SCANS, so it is absent (rather than disabled) whenever the
|
||||
* viewer cannot curate the organization's watchlist.
|
||||
*/
|
||||
watchlistAction?: ReactNode;
|
||||
}
|
||||
|
||||
export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
@@ -51,14 +61,13 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
complianceId,
|
||||
id,
|
||||
isLatestCisForProvider = false,
|
||||
watchlistAction,
|
||||
}) => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const hasRegionFilter = searchParams.has("filter[region__in]");
|
||||
|
||||
const formatTitle = (title: string) => {
|
||||
return title.split("-").join(" ");
|
||||
};
|
||||
const formattedTitle = formatComplianceFrameworkTitle(title, version);
|
||||
|
||||
const ratingPercentage = Math.floor(
|
||||
(passingRequirements / totalRequirements) * 100,
|
||||
@@ -83,22 +92,74 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
padding="md"
|
||||
className="relative cursor-pointer transition-shadow hover:shadow-md"
|
||||
onClick={navigateToDetail}
|
||||
>
|
||||
<div
|
||||
className="absolute top-2 right-2 z-10"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
<Card variant="base" padding="none" interactive className="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Open ${formattedTitle} compliance details`}
|
||||
onClick={navigateToDetail}
|
||||
className="focus-visible:ring-border-neutral-secondary/50 w-full rounded-xl bg-transparent px-4 py-3 text-left outline-none focus-visible:ring-2"
|
||||
>
|
||||
<CardContent>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3",
|
||||
watchlistAction ? "pr-16" : "pr-9",
|
||||
)}
|
||||
>
|
||||
{getComplianceIcon(title) && (
|
||||
<div className="border-border-neutral-tertiary flex h-10 w-10 min-w-10 shrink-0 items-center justify-center rounded-md border bg-slate-50">
|
||||
<Image
|
||||
src={getComplianceIcon(title)}
|
||||
alt={`${title} logo`}
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8 object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<h4 className="truncate text-sm leading-5 font-bold">
|
||||
{formattedTitle}
|
||||
</h4>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{formattedTitle}</TooltipContent>
|
||||
</Tooltip>
|
||||
<small className="truncate">
|
||||
<span className="mr-1 text-xs font-semibold">
|
||||
{passingRequirements} / {totalRequirements}
|
||||
</span>
|
||||
Passing Requirements
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<span className="text-text-neutral-secondary font-medium tracking-wider">
|
||||
Score:
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary">
|
||||
{ratingPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label="Compliance score"
|
||||
value={ratingPercentage}
|
||||
className="border-border-neutral-secondary h-2.5 border drop-shadow-sm"
|
||||
indicatorClassName={getScoreIndicatorClass(
|
||||
getRatingVariant(ratingPercentage),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</button>
|
||||
<CardAction
|
||||
className="absolute top-2 right-2 z-10 flex items-center gap-1"
|
||||
role="group"
|
||||
tabIndex={0}
|
||||
aria-label="Compliance actions"
|
||||
>
|
||||
<ComplianceDownloadContainer
|
||||
compact
|
||||
@@ -114,62 +175,8 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
)}
|
||||
disabled={hasRegionFilter}
|
||||
/>
|
||||
</div>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex items-center gap-3 pr-9">
|
||||
{getComplianceIcon(title) && (
|
||||
<div className="border-border-neutral-tertiary flex h-10 w-10 min-w-10 shrink-0 items-center justify-center rounded-md border bg-slate-50">
|
||||
<Image
|
||||
src={getComplianceIcon(title)}
|
||||
alt={`${title} logo`}
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8 object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<h4 className="truncate text-sm leading-5 font-bold">
|
||||
{formatTitle(title)}
|
||||
{version ? ` - ${version}` : ""}
|
||||
</h4>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{formatTitle(title)}
|
||||
{version ? ` - ${version}` : ""}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<small className="truncate">
|
||||
<span className="mr-1 text-xs font-semibold">
|
||||
{passingRequirements} / {totalRequirements}
|
||||
</span>
|
||||
Passing Requirements
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<span className="text-text-neutral-secondary font-medium tracking-wider">
|
||||
Score:
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary">
|
||||
{ratingPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label="Compliance score"
|
||||
value={ratingPercentage}
|
||||
className="border-border-neutral-secondary h-2.5 border drop-shadow-sm"
|
||||
indicatorClassName={getScoreIndicatorClass(
|
||||
getRatingVariant(ratingPercentage),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
{watchlistAction}
|
||||
</CardAction>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
export const ComplianceFrameworkGrid = ({ children }: PropsWithChildren) => (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,314 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { TourStepHandlers } from "@/lib/tours/tour-types";
|
||||
import type { ViewComplianceTourTarget } from "@/lib/tours/view-compliance.tour";
|
||||
import { VIEW_COMPLIANCE_TOUR_TARGETS } from "@/lib/tours/view-compliance.tour";
|
||||
import { useComplianceWatchlistViewStore } from "@/store/compliance/store";
|
||||
import { makeComplianceCatalogEntry } from "@/test-utils/compliance-watchlist";
|
||||
import type { ComplianceOverviewData } from "@/types/compliance";
|
||||
import { UNIVERSAL_PROVIDER_TYPE } from "@/types/compliance-watchlist";
|
||||
|
||||
import { ComplianceOverviewGrid } from "./compliance-overview-grid";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
usePathname: () => "/compliance",
|
||||
}));
|
||||
|
||||
vi.mock("@/components/lighthouse/context-contributor", () => ({
|
||||
LighthouseContextContributor: () => null,
|
||||
}));
|
||||
|
||||
type ViewComplianceStepHandlers = {
|
||||
[K in ViewComplianceTourTarget]?: TourStepHandlers<ViewComplianceTourTarget>;
|
||||
};
|
||||
|
||||
// Captured so the tour's own step handlers can be exercised: the trigger is a
|
||||
// driver.js host, and the handlers are the only part of it this grid owns.
|
||||
const capturedStepHandlers = vi.hoisted(() => ({
|
||||
current: {} as ViewComplianceStepHandlers,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/onboarding", () => ({
|
||||
OnboardingTrigger: ({
|
||||
stepHandlers,
|
||||
}: {
|
||||
stepHandlers: ViewComplianceStepHandlers;
|
||||
}) => {
|
||||
capturedStepHandlers.current = stepHandlers;
|
||||
return null;
|
||||
},
|
||||
PageReady: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/compliance-watchlist", () => ({
|
||||
addComplianceToWatchlist: vi.fn(),
|
||||
bulkUpdateComplianceWatchlist: vi.fn(),
|
||||
removeComplianceFromWatchlist: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./compliance-card", () => ({
|
||||
ComplianceCard: ({
|
||||
title,
|
||||
watchlistAction,
|
||||
}: {
|
||||
title: string;
|
||||
watchlistAction?: ReactNode;
|
||||
}) => (
|
||||
<div data-testid={`card-${title}`}>
|
||||
{title}
|
||||
{watchlistAction}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const framework = (id: string, frameworkName: string): ComplianceOverviewData =>
|
||||
({
|
||||
id,
|
||||
type: "compliance-overviews",
|
||||
attributes: {
|
||||
framework: frameworkName,
|
||||
version: "1.0",
|
||||
requirements_passed: 5,
|
||||
requirements_failed: 5,
|
||||
total_requirements: 10,
|
||||
},
|
||||
}) as unknown as ComplianceOverviewData;
|
||||
|
||||
const FRAMEWORKS = [
|
||||
framework("cis_1.4_aws", "CIS"),
|
||||
framework("gdpr_aws", "GDPR"),
|
||||
framework("iso27001_aws", "ISO27001"),
|
||||
];
|
||||
|
||||
const catalogEntry = (complianceId: string, inWatchlist: boolean) =>
|
||||
makeComplianceCatalogEntry({
|
||||
complianceId,
|
||||
providerType: "aws",
|
||||
framework: complianceId,
|
||||
name: complianceId,
|
||||
inWatchlist,
|
||||
watchlistEntryId: inWatchlist
|
||||
? "3fa85f64-5717-4562-b3fc-2c963f66afa6"
|
||||
: null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: false });
|
||||
});
|
||||
|
||||
const renderGrid = (
|
||||
overrides: Partial<Parameters<typeof ComplianceOverviewGrid>[0]> = {},
|
||||
) =>
|
||||
render(
|
||||
<ComplianceOverviewGrid
|
||||
frameworks={FRAMEWORKS}
|
||||
scanId="scan-1"
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe("ComplianceOverviewGrid without the watchlist (OSS / no catalog)", () => {
|
||||
it("keeps the full grid without watchlist affordances", () => {
|
||||
renderGrid();
|
||||
|
||||
expect(screen.getByTestId("card-CIS")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Watchlist" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("3 Total Entries")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComplianceOverviewGrid with a curated watchlist", () => {
|
||||
const catalogEntries = [
|
||||
catalogEntry("cis_1.4_aws", true),
|
||||
catalogEntry("gdpr_aws", false),
|
||||
catalogEntry("iso27001_aws", false),
|
||||
];
|
||||
|
||||
it("keeps the framework order when a later card is pinned", () => {
|
||||
renderGrid({
|
||||
catalogEntries: [
|
||||
catalogEntry("cis_1.4_aws", false),
|
||||
catalogEntry("gdpr_aws", true),
|
||||
catalogEntry("iso27001_aws", false),
|
||||
],
|
||||
providerType: "aws",
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getAllByTestId(/^card-/).map((card) => card.textContent),
|
||||
).toEqual(["CIS", "GDPR", "ISO27001"]);
|
||||
});
|
||||
|
||||
it("narrows the grid to the pinned frameworks when the filter is on", () => {
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true });
|
||||
|
||||
renderGrid({ catalogEntries, providerType: "aws" });
|
||||
|
||||
expect(screen.getByTestId("card-CIS")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("card-GDPR")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("1 Total Entries")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers the pin action on every card when the user can manage the account", () => {
|
||||
renderGrid({
|
||||
catalogEntries,
|
||||
providerType: "aws",
|
||||
canManageWatchlist: true,
|
||||
});
|
||||
|
||||
// One toggle per card, told apart by `aria-pressed` rather than by a
|
||||
// swapped label — which is what a screen reader actually announces.
|
||||
const toggles = screen.getAllByRole("button", { name: "Watchlist" });
|
||||
expect(toggles).toHaveLength(3);
|
||||
expect(
|
||||
toggles.some((toggle) => toggle.getAttribute("aria-pressed") === "true"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
toggles.some((toggle) => toggle.getAttribute("aria-pressed") === "false"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("filters read-only viewers without exposing write controls", () => {
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true });
|
||||
|
||||
renderGrid({
|
||||
catalogEntries,
|
||||
providerType: "aws",
|
||||
canManageWatchlist: false,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("card-CIS")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("card-GDPR")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Watchlist" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComplianceOverviewGrid with an empty watchlist", () => {
|
||||
const catalogEntries = [
|
||||
catalogEntry("cis_1.4_aws", false),
|
||||
catalogEntry("gdpr_aws", false),
|
||||
catalogEntry("iso27001_aws", false),
|
||||
];
|
||||
|
||||
it("explains the blank grid when the filter hides everything", () => {
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true });
|
||||
|
||||
renderGrid({
|
||||
catalogEntries,
|
||||
providerType: "aws",
|
||||
canManageWatchlist: true,
|
||||
});
|
||||
|
||||
expect(screen.getByText(/no frameworks pinned yet/i)).toBeVisible();
|
||||
expect(screen.queryByTestId("card-CIS")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComplianceOverviewGrid with a universal framework", () => {
|
||||
// The catalog keys a universal framework under `*`, while this per-scan grid
|
||||
// only knows the scan's own provider type — the wildcard fallback is what
|
||||
// makes the two agree.
|
||||
const universalEntries = [
|
||||
makeComplianceCatalogEntry({
|
||||
complianceId: "cis_controls_8.1",
|
||||
providerType: UNIVERSAL_PROVIDER_TYPE,
|
||||
providerTypes: ["aws", "azure"],
|
||||
inWatchlist: true,
|
||||
}),
|
||||
catalogEntry("gdpr_aws", false),
|
||||
];
|
||||
const universalFrameworks = [
|
||||
framework("cis_controls_8.1", "CIS-Controls"),
|
||||
framework("gdpr_aws", "GDPR"),
|
||||
];
|
||||
|
||||
it("resolves and filters the wildcard row", () => {
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true });
|
||||
|
||||
render(
|
||||
<ComplianceOverviewGrid
|
||||
frameworks={universalFrameworks}
|
||||
scanId="scan-1"
|
||||
catalogEntries={universalEntries}
|
||||
providerType="aws"
|
||||
canManageWatchlist
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("card-CIS-Controls")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("card-GDPR")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Watchlist" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComplianceOverviewGrid tour anchor", () => {
|
||||
const searchHandlers = () =>
|
||||
capturedStepHandlers.current[VIEW_COMPLIANCE_TOUR_TARGETS.SEARCH];
|
||||
|
||||
it("keeps the anchor on the first rendered card regardless of pin state", () => {
|
||||
renderGrid({
|
||||
catalogEntries: [
|
||||
catalogEntry("cis_1.4_aws", false),
|
||||
catalogEntry("gdpr_aws", true),
|
||||
catalogEntry("iso27001_aws", false),
|
||||
],
|
||||
providerType: "aws",
|
||||
});
|
||||
|
||||
expect(
|
||||
document.querySelector('[data-tour-id="view-compliance-frameworks"]'),
|
||||
).toHaveTextContent("CIS");
|
||||
});
|
||||
|
||||
it("waits for the framework card when one will render", async () => {
|
||||
const waitForStep = vi
|
||||
.fn()
|
||||
.mockResolvedValue(document.createElement("div"));
|
||||
|
||||
renderGrid();
|
||||
const onNext = searchHandlers()?.onNext;
|
||||
if (!onNext) throw new Error("Expected a search step handler");
|
||||
await onNext({ waitForStep });
|
||||
|
||||
expect(waitForStep).toHaveBeenCalledWith("frameworks");
|
||||
});
|
||||
|
||||
it("skips the wait when the persisted filter leaves no card to anchor to", async () => {
|
||||
// The filter survives reloads, so the tour can start on a grid that renders
|
||||
// the empty state instead of a card — and waiting for an anchor that never
|
||||
// mounts would hang it there.
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true });
|
||||
const waitForStep = vi
|
||||
.fn()
|
||||
.mockResolvedValue(document.createElement("div"));
|
||||
|
||||
renderGrid({
|
||||
catalogEntries: [
|
||||
catalogEntry("cis_1.4_aws", false),
|
||||
catalogEntry("gdpr_aws", false),
|
||||
catalogEntry("iso27001_aws", false),
|
||||
],
|
||||
providerType: "aws",
|
||||
});
|
||||
const onNext = searchHandlers()?.onNext;
|
||||
if (!onNext) throw new Error("Expected a search step handler");
|
||||
await onNext({ waitForStep });
|
||||
|
||||
expect(waitForStep).not.toHaveBeenCalled();
|
||||
expect(
|
||||
document.querySelector('[data-tour-id="view-compliance-frameworks"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,17 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useState } from "react";
|
||||
|
||||
import { ComplianceCard } from "@/components/compliance/compliance-card";
|
||||
import { ComplianceFrameworkGrid } from "@/components/compliance/compliance-framework-grid";
|
||||
import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor";
|
||||
import { OnboardingTrigger, PageReady } from "@/components/onboarding";
|
||||
import { DataTableSearch } from "@/components/shadcn/table/data-table-search";
|
||||
import { useShowOnlyWatchlist } from "@/hooks/use-show-only-watchlist";
|
||||
import { buildComplianceDetailPath } from "@/lib/compliance/compliance-detail-url";
|
||||
import {
|
||||
buildWatchlistIndex,
|
||||
isFrameworkPinned,
|
||||
resolveWatchlistEntryId,
|
||||
} from "@/lib/compliance/watchlist";
|
||||
import {
|
||||
LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE,
|
||||
LIGHTHOUSE_CONTEXT_CONTRIBUTOR_LIMIT,
|
||||
@@ -16,8 +23,13 @@ import { buildComplianceContext } from "@/lib/lighthouse/context/contributions";
|
||||
import { getFlowById } from "@/lib/onboarding";
|
||||
import { createViewComplianceTourStepHandlers } from "@/lib/tours/view-compliance.tour";
|
||||
import type { ComplianceOverviewData } from "@/types/compliance";
|
||||
import type { ComplianceCatalogEntry } from "@/types/compliance-watchlist";
|
||||
import { WATCHLIST_PIN_STATE } from "@/types/compliance-watchlist";
|
||||
import type { ScanEntity } from "@/types/scans";
|
||||
|
||||
import { WatchlistEmptyState } from "./watchlist/watchlist-empty-state";
|
||||
import { WatchlistToggle } from "./watchlist/watchlist-toggle";
|
||||
|
||||
const viewComplianceFlow = getFlowById("view-compliance")!;
|
||||
|
||||
// Module-level so the identity is stable: `configOverrides` is an effect dependency in
|
||||
@@ -31,12 +43,10 @@ interface ComplianceOverviewGridProps {
|
||||
frameworks: ComplianceOverviewData[];
|
||||
scanId: string;
|
||||
selectedScan?: ScanEntity;
|
||||
/**
|
||||
* Subset of compliance_ids that represent the latest CIS variant per
|
||||
* provider. Only those cards expose the PDF download button, matching
|
||||
* the backend's latest-only CIS PDF generation.
|
||||
*/
|
||||
latestCisIds?: ReadonlySet<string>;
|
||||
catalogEntries?: ComplianceCatalogEntry[];
|
||||
providerType?: string;
|
||||
canManageWatchlist?: boolean;
|
||||
}
|
||||
|
||||
export const ComplianceOverviewGrid = ({
|
||||
@@ -44,10 +54,14 @@ export const ComplianceOverviewGrid = ({
|
||||
scanId,
|
||||
selectedScan,
|
||||
latestCisIds,
|
||||
catalogEntries,
|
||||
providerType,
|
||||
canManageWatchlist = false,
|
||||
}: ComplianceOverviewGridProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const showOnlyWatchlist = useShowOnlyWatchlist();
|
||||
|
||||
const filteredFrameworks = frameworks.filter((compliance) =>
|
||||
compliance.attributes.framework
|
||||
@@ -55,13 +69,44 @@ export const ComplianceOverviewGrid = ({
|
||||
.includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
const catalogIndex = buildWatchlistIndex(catalogEntries ?? []);
|
||||
const watchlistEnabled =
|
||||
Boolean(providerType) && (catalogEntries?.length ?? 0) > 0;
|
||||
|
||||
const isPinned = (complianceId: string) =>
|
||||
watchlistEnabled &&
|
||||
isFrameworkPinned(catalogIndex, {
|
||||
complianceId,
|
||||
providerType: providerType!,
|
||||
});
|
||||
|
||||
// Counted before the search, so a term that matches nothing pinned does not
|
||||
// make the empty state claim the organization has pinned nothing.
|
||||
const pinnedTotal = watchlistEnabled
|
||||
? frameworks.filter((compliance) => isPinned(compliance.id)).length
|
||||
: 0;
|
||||
|
||||
const filterToWatchlist = watchlistEnabled && showOnlyWatchlist;
|
||||
const visibleFrameworks = filterToWatchlist
|
||||
? filteredFrameworks.filter((compliance) => isPinned(compliance.id))
|
||||
: filteredFrameworks;
|
||||
const tourAnchorId = visibleFrameworks[0]?.id;
|
||||
|
||||
const resetSearch = () => {
|
||||
setSearchTerm("");
|
||||
return frameworks.length > 0;
|
||||
// Clearing the search does not bring the anchor back while the persisted
|
||||
// watchlist filter is on and nothing is pinned: the grid renders the empty
|
||||
// state instead, so the tour has to skip the step rather than wait for a
|
||||
// selector that never mounts.
|
||||
return filterToWatchlist ? pinnedTotal > 0 : frameworks.length > 0;
|
||||
};
|
||||
|
||||
const openFirstFramework = () => {
|
||||
const first = frameworks[0];
|
||||
// The fallback covers a search that filtered every card away — never the
|
||||
// watchlist filter, where opening a hidden framework would contradict the
|
||||
// list the user is looking at.
|
||||
const first =
|
||||
visibleFrameworks[0] ?? (filterToWatchlist ? undefined : frameworks[0]);
|
||||
if (!first) return;
|
||||
router.push(
|
||||
buildComplianceDetailPath({
|
||||
@@ -74,6 +119,56 @@ export const ComplianceOverviewGrid = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderGrid = (items: ComplianceOverviewData[]) => (
|
||||
<ComplianceFrameworkGrid>
|
||||
{items.map((compliance) => {
|
||||
const { attributes, id } = compliance;
|
||||
const { framework, version, requirements_passed, total_requirements } =
|
||||
attributes;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
{...(id === tourAnchorId
|
||||
? { "data-tour-id": "view-compliance-frameworks" }
|
||||
: {})}
|
||||
className="h-full [&>*]:h-full"
|
||||
>
|
||||
<ComplianceCard
|
||||
title={framework}
|
||||
version={version}
|
||||
passingRequirements={requirements_passed}
|
||||
totalRequirements={total_requirements}
|
||||
prevPassingRequirements={requirements_passed}
|
||||
prevTotalRequirements={total_requirements}
|
||||
scanId={scanId}
|
||||
complianceId={id}
|
||||
id={id}
|
||||
selectedScan={selectedScan}
|
||||
isLatestCisForProvider={latestCisIds?.has(id) ?? false}
|
||||
watchlistAction={
|
||||
watchlistEnabled && canManageWatchlist ? (
|
||||
<WatchlistToggle
|
||||
target={{ complianceId: id, providerType: providerType! }}
|
||||
state={
|
||||
isPinned(id)
|
||||
? WATCHLIST_PIN_STATE.PINNED
|
||||
: WATCHLIST_PIN_STATE.UNPINNED
|
||||
}
|
||||
entryId={resolveWatchlistEntryId(catalogIndex, {
|
||||
complianceId: id,
|
||||
providerType: providerType!,
|
||||
})}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ComplianceFrameworkGrid>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{filteredFrameworks
|
||||
@@ -119,52 +214,23 @@ export const ComplianceOverviewGrid = ({
|
||||
/>
|
||||
</div>
|
||||
<span className="text-text-neutral-secondary shrink-0 text-sm">
|
||||
{filteredFrameworks.length.toLocaleString()} Total Entries
|
||||
{visibleFrameworks.length.toLocaleString()} Total Entries
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
{filteredFrameworks.map((compliance, index) => {
|
||||
const { attributes, id } = compliance;
|
||||
const {
|
||||
framework,
|
||||
version,
|
||||
requirements_passed,
|
||||
total_requirements,
|
||||
} = attributes;
|
||||
|
||||
const card = (
|
||||
<ComplianceCard
|
||||
title={framework}
|
||||
version={version}
|
||||
passingRequirements={requirements_passed}
|
||||
totalRequirements={total_requirements}
|
||||
prevPassingRequirements={requirements_passed}
|
||||
prevTotalRequirements={total_requirements}
|
||||
scanId={scanId}
|
||||
complianceId={id}
|
||||
id={id}
|
||||
selectedScan={selectedScan}
|
||||
isLatestCisForProvider={latestCisIds?.has(id) ?? false}
|
||||
/>
|
||||
);
|
||||
|
||||
// Anchor the tour to a single card, not the whole grid: highlighting the
|
||||
// grid lit up the entire viewport and scrolled the page to the bottom.
|
||||
return index === 0 ? (
|
||||
<div
|
||||
key={id}
|
||||
data-tour-id="view-compliance-frameworks"
|
||||
className="h-full [&>*]:h-full"
|
||||
>
|
||||
{card}
|
||||
</div>
|
||||
) : (
|
||||
<div key={id} className="h-full [&>*]:h-full">
|
||||
{card}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{filterToWatchlist && visibleFrameworks.length === 0 ? (
|
||||
<WatchlistEmptyState
|
||||
message={
|
||||
// A search term is the likelier culprit than an uncurated
|
||||
// watchlist, so it gets its own copy instead of telling someone
|
||||
// who has already pinned frameworks that they have pinned none.
|
||||
pinnedTotal > 0
|
||||
? "No pinned framework matches your search."
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
renderGrid(visibleFrameworks)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ export * from "./compliance-accordion/client-accordion-wrapper";
|
||||
export * from "./compliance-accordion/compliance-accordion-requeriment-title";
|
||||
export * from "./compliance-accordion/compliance-accordion-title";
|
||||
export * from "./compliance-card";
|
||||
export * from "./compliance-framework-grid";
|
||||
export * from "./compliance-charts/chart-skeletons";
|
||||
export * from "./compliance-charts/heatmap-chart";
|
||||
export * from "./compliance-charts/requirements-status-card";
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
|
||||
import { ComplianceFrameworkGrid } from "../compliance-framework-grid";
|
||||
|
||||
export const ComplianceSkeletonGrid = () => {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
<ComplianceFrameworkGrid>
|
||||
{[...Array(28)].map((_, index) => (
|
||||
<Skeleton key={index} className="h-28 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</ComplianceFrameworkGrid>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./watchlist-controls";
|
||||
export * from "./watchlist-empty-state";
|
||||
export * from "./watchlist-filter-toggle";
|
||||
export * from "./watchlist-multi-select";
|
||||
export * from "./watchlist-toggle";
|
||||
@@ -0,0 +1,48 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { makeComplianceCatalogEntry } from "@/test-utils/compliance-watchlist";
|
||||
|
||||
import { WatchlistControls } from "./watchlist-controls";
|
||||
|
||||
vi.mock("./watchlist-filter-toggle", () => ({
|
||||
WatchlistFilterToggle: () => <div data-testid="filter-toggle" />,
|
||||
}));
|
||||
|
||||
vi.mock("./watchlist-multi-select", () => ({
|
||||
WatchlistMultiSelect: () => <div data-testid="multi-select" />,
|
||||
}));
|
||||
|
||||
const ENTRY = makeComplianceCatalogEntry({
|
||||
complianceId: "cis_1.4_aws",
|
||||
providerType: "aws",
|
||||
framework: "CIS",
|
||||
name: "CIS",
|
||||
version: "1.4",
|
||||
});
|
||||
|
||||
describe("WatchlistControls", () => {
|
||||
it("renders nothing without a catalog, keeping the feature Cloud-only", () => {
|
||||
const { container } = render(
|
||||
<WatchlistControls entries={[]} canManageWatchlist />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("offers both the filter and the editor to a curator", () => {
|
||||
render(<WatchlistControls entries={[ENTRY]} canManageWatchlist />);
|
||||
|
||||
expect(screen.getByTestId("filter-toggle")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("multi-select")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the filter but drops the editor without MANAGE_SCANS", () => {
|
||||
// Rendering the editor disabled would be worse: the write 403s, so the
|
||||
// affordance is removed rather than teased.
|
||||
render(<WatchlistControls entries={[ENTRY]} canManageWatchlist={false} />);
|
||||
|
||||
expect(screen.getByTestId("filter-toggle")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("multi-select")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ComplianceCatalogEntry } from "@/types/compliance-watchlist";
|
||||
|
||||
import { WatchlistFilterToggle } from "./watchlist-filter-toggle";
|
||||
import { WatchlistMultiSelect } from "./watchlist-multi-select";
|
||||
|
||||
interface WatchlistControlsProps {
|
||||
entries: ComplianceCatalogEntry[];
|
||||
canManageWatchlist: boolean;
|
||||
}
|
||||
|
||||
export const WatchlistControls = ({
|
||||
entries,
|
||||
canManageWatchlist,
|
||||
}: WatchlistControlsProps) => {
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-4">
|
||||
<WatchlistFilterToggle />
|
||||
{canManageWatchlist && (
|
||||
<div className="w-56">
|
||||
<WatchlistMultiSelect entries={entries} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Pin } from "lucide-react";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
|
||||
/** Default copy: what an uncurated watchlist reads like on any surface. The
|
||||
* two Multiple Scans sections override it to name what is missing there. */
|
||||
const WATCHLIST_FILTER_EMPTY_HINT =
|
||||
"No frameworks pinned yet. Pin the ones your organization tracks — from a card or the watchlist selector — or clear the filter to browse the full catalog.";
|
||||
|
||||
interface WatchlistEmptyStateProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a surface renders when the watchlist filter hides everything it had.
|
||||
*
|
||||
* `role="status"` overrides the component's default `role="alert"`: this is
|
||||
* the expected result of a filter the user just applied, not an error, and an
|
||||
* assertive live region would interrupt to announce it — twice over on the
|
||||
* Multiple Scans tab, which renders two sections.
|
||||
*/
|
||||
export const WatchlistEmptyState = ({
|
||||
message = WATCHLIST_FILTER_EMPTY_HINT,
|
||||
}: WatchlistEmptyStateProps) => (
|
||||
<Alert role="status" variant="info">
|
||||
<Pin aria-hidden />
|
||||
<AlertDescription>{message}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { useComplianceWatchlistViewStore } from "@/store/compliance/store";
|
||||
|
||||
import { WatchlistFilterToggle } from "./watchlist-filter-toggle";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: false });
|
||||
});
|
||||
|
||||
describe("WatchlistFilterToggle", () => {
|
||||
it("updates the shared filter in both directions", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(<WatchlistFilterToggle />);
|
||||
const checkbox = screen.getByRole("checkbox", {
|
||||
name: "Show only watchlist",
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(checkbox);
|
||||
|
||||
// Then
|
||||
expect(useComplianceWatchlistViewStore.getState().showOnlyWatchlist).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(checkbox);
|
||||
|
||||
// Then
|
||||
expect(useComplianceWatchlistViewStore.getState().showOnlyWatchlist).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { Pin } from "lucide-react";
|
||||
|
||||
import { Checkbox } from "@/components/shadcn";
|
||||
import { useShowOnlyWatchlist } from "@/hooks/use-show-only-watchlist";
|
||||
import { useComplianceWatchlistViewStore } from "@/store";
|
||||
|
||||
const CHECKBOX_ID = "show-only-watchlist";
|
||||
|
||||
/**
|
||||
* Filters every compliance surface down to the pinned frameworks, mirroring
|
||||
* "Include muted findings" on the Findings page.
|
||||
*
|
||||
* The state lives in the store rather than the URL because the two compliance
|
||||
* tabs are separate navigations: the filter has to survive a tab switch, and
|
||||
* both tabs read the very same flag so a curated view stays curated across
|
||||
* them.
|
||||
*/
|
||||
export const WatchlistFilterToggle = () => {
|
||||
const showOnlyWatchlist = useShowOnlyWatchlist();
|
||||
const setShowOnlyWatchlist = useComplianceWatchlistViewStore(
|
||||
(state) => state.setShowOnlyWatchlist,
|
||||
);
|
||||
|
||||
return (
|
||||
// `text-nowrap`: wrapping the label would push the tab bar's height
|
||||
// around, and it is short enough that it never needs to.
|
||||
<div className="flex items-center gap-2 text-nowrap">
|
||||
<Checkbox
|
||||
id={CHECKBOX_ID}
|
||||
checked={showOnlyWatchlist}
|
||||
onCheckedChange={(checked) => setShowOnlyWatchlist(checked === true)}
|
||||
/>
|
||||
<Pin aria-hidden className="text-text-neutral-tertiary size-3 shrink-0" />
|
||||
{/* No `aria-label` on the checkbox: it would override this label, and a
|
||||
screen reader would then announce something the user cannot see. */}
|
||||
<label
|
||||
htmlFor={CHECKBOX_ID}
|
||||
className="cursor-pointer text-sm leading-none"
|
||||
>
|
||||
Show only watchlist
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { makeComplianceCatalogEntry } from "@/test-utils/compliance-watchlist";
|
||||
import type { ComplianceCatalogEntry } from "@/types/compliance-watchlist";
|
||||
import { UNIVERSAL_PROVIDER_TYPE } from "@/types/compliance-watchlist";
|
||||
|
||||
import { WatchlistMultiSelect } from "./watchlist-multi-select";
|
||||
|
||||
const { bulkUpdateComplianceWatchlistMock, toastMock } = vi.hoisted(() => ({
|
||||
bulkUpdateComplianceWatchlistMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/compliance-watchlist", () => ({
|
||||
bulkUpdateComplianceWatchlist: bulkUpdateComplianceWatchlistMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/toast/use-toast", () => ({
|
||||
useToast: () => ({ toast: toastMock }),
|
||||
}));
|
||||
|
||||
// Faithful-enough stand-in for the popover primitive: it keeps the controlled
|
||||
// `values`/`open` contract (which is what this component actually drives) and
|
||||
// drops cmdk's virtualised listbox, which does not render in jsdom.
|
||||
vi.mock("@/components/shadcn/select/multiselect", async () => {
|
||||
const { createContext, useContext } = await import("react");
|
||||
|
||||
const Ctx = createContext<{
|
||||
values: string[];
|
||||
onValuesChange: (values: string[]) => void;
|
||||
}>({ values: [], onValuesChange: () => {} });
|
||||
|
||||
return {
|
||||
MultiSelect: ({
|
||||
children,
|
||||
values,
|
||||
onValuesChange,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
values: string[];
|
||||
onValuesChange: (values: string[]) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => (
|
||||
<Ctx.Provider value={{ values, onValuesChange }}>
|
||||
<button type="button" onClick={() => onOpenChange(true)}>
|
||||
open-dropdown
|
||||
</button>
|
||||
<button type="button" onClick={() => onOpenChange(false)}>
|
||||
close-dropdown
|
||||
</button>
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
),
|
||||
MultiSelectTrigger: ({ children, ...props }: { children: ReactNode }) => (
|
||||
<button role="combobox" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
MultiSelectContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
MultiSelectGroup: ({
|
||||
heading,
|
||||
children,
|
||||
}: {
|
||||
heading: string;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<div role="group" aria-label={heading}>
|
||||
<span>{heading}</span>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
MultiSelectItem: ({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
const { values, onValuesChange } = useContext(Ctx);
|
||||
const selected = values.includes(value);
|
||||
return (
|
||||
<button
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
onClick={() =>
|
||||
onValuesChange(
|
||||
selected
|
||||
? values.filter((current) => current !== value)
|
||||
: [...values, value],
|
||||
)
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const ENTRIES: ComplianceCatalogEntry[] = [
|
||||
makeComplianceCatalogEntry({
|
||||
complianceId: "cis_controls_8.1",
|
||||
providerType: UNIVERSAL_PROVIDER_TYPE,
|
||||
framework: "CIS Controls",
|
||||
}),
|
||||
makeComplianceCatalogEntry({
|
||||
complianceId: "cis_1.4_aws",
|
||||
providerType: "aws",
|
||||
framework: "CIS",
|
||||
inWatchlist: true,
|
||||
}),
|
||||
makeComplianceCatalogEntry({
|
||||
complianceId: "cis_2.0_azure",
|
||||
providerType: "azure",
|
||||
framework: "CIS",
|
||||
}),
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
bulkUpdateComplianceWatchlistMock.mockReset();
|
||||
bulkUpdateComplianceWatchlistMock.mockResolvedValue({ success: "ok" });
|
||||
toastMock.mockReset();
|
||||
});
|
||||
|
||||
describe("WatchlistMultiSelect grouping", () => {
|
||||
it("labels the universal band separately from each provider type", () => {
|
||||
render(<WatchlistMultiSelect entries={ENTRIES} />);
|
||||
|
||||
const universal = screen.getByRole("group", { name: "Universal" });
|
||||
expect(
|
||||
within(universal).getByText("CIS Controls - 1.0"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
within(screen.getByRole("group", { name: "AWS" })).getByText("CIS - 1.0"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("group", { name: "Azure" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WatchlistMultiSelect editing", () => {
|
||||
it("submits one diff for every change when the dropdown closes", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WatchlistMultiSelect entries={ENTRIES} />);
|
||||
|
||||
// When: pin Azure, unpin AWS, then close
|
||||
await user.click(screen.getByRole("button", { name: "open-dropdown" }));
|
||||
await user.click(
|
||||
within(screen.getByRole("group", { name: "Azure" })).getByRole("option"),
|
||||
);
|
||||
await user.click(
|
||||
within(screen.getByRole("group", { name: "AWS" })).getByRole("option"),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "close-dropdown" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(bulkUpdateComplianceWatchlistMock).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(bulkUpdateComplianceWatchlistMock).toHaveBeenCalledWith({
|
||||
add: [{ complianceId: "cis_2.0_azure", providerType: "azure" }],
|
||||
remove: [{ complianceId: "cis_1.4_aws", providerType: "aws" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call the API when the selection is unchanged", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WatchlistMultiSelect entries={ENTRIES} />);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "open-dropdown" }));
|
||||
await user.click(screen.getByRole("button", { name: "close-dropdown" }));
|
||||
|
||||
// Then
|
||||
expect(bulkUpdateComplianceWatchlistMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rolls the selection back when the write fails", async () => {
|
||||
bulkUpdateComplianceWatchlistMock.mockResolvedValue({ error: "403" });
|
||||
const user = userEvent.setup();
|
||||
render(<WatchlistMultiSelect entries={ENTRIES} />);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "open-dropdown" }));
|
||||
await user.click(
|
||||
within(screen.getByRole("group", { name: "Azure" })).getByRole("option"),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "close-dropdown" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ variant: "destructive" }),
|
||||
),
|
||||
);
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent(
|
||||
"Watchlist · 1 pinned",
|
||||
);
|
||||
});
|
||||
|
||||
it("re-reads the server state when the dropdown is reopened", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<WatchlistMultiSelect entries={ENTRIES} />);
|
||||
|
||||
// When: a card pin lands from elsewhere while the dropdown is closed
|
||||
rerender(
|
||||
<WatchlistMultiSelect
|
||||
entries={ENTRIES.map((item) => ({ ...item, inWatchlist: true }))}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "open-dropdown" }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent(
|
||||
"Watchlist · 3 pinned",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
|
||||
import { bulkUpdateComplianceWatchlist } from "@/actions/compliance-watchlist";
|
||||
import {
|
||||
MultiSelect,
|
||||
MultiSelectContent,
|
||||
MultiSelectGroup,
|
||||
MultiSelectItem,
|
||||
MultiSelectTrigger,
|
||||
} from "@/components/shadcn/select/multiselect";
|
||||
import { useToast } from "@/components/shadcn/toast/use-toast";
|
||||
import {
|
||||
computeWatchlistDiff,
|
||||
exceedsWatchlistBulkLimit,
|
||||
isEmptyWatchlistDiff,
|
||||
MAX_WATCHLIST_BULK,
|
||||
watchlistKey,
|
||||
} from "@/lib/compliance/watchlist";
|
||||
import type {
|
||||
ComplianceCatalogEntry,
|
||||
ComplianceWatchlistTarget,
|
||||
} from "@/types/compliance-watchlist";
|
||||
import { WATCHLIST_SCOPE } from "@/types/compliance-watchlist";
|
||||
import { getProviderDisplayName } from "@/types/providers";
|
||||
|
||||
interface WatchlistMultiSelectProps {
|
||||
entries: ComplianceCatalogEntry[];
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const UNIVERSAL_GROUP_LABEL = "Universal";
|
||||
|
||||
interface WatchlistGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
entries: ComplianceCatalogEntry[];
|
||||
}
|
||||
|
||||
const frameworkLabel = (entry: ComplianceCatalogEntry): string =>
|
||||
`${entry.framework}${entry.version ? ` - ${entry.version}` : ""}`;
|
||||
|
||||
const buildGroups = (entries: ComplianceCatalogEntry[]): WatchlistGroup[] => {
|
||||
const universal = entries.filter(
|
||||
(entry) => entry.scope === WATCHLIST_SCOPE.UNIVERSAL,
|
||||
);
|
||||
|
||||
const byProviderType = new Map<string, ComplianceCatalogEntry[]>();
|
||||
for (const entry of entries) {
|
||||
if (entry.scope === WATCHLIST_SCOPE.UNIVERSAL) continue;
|
||||
byProviderType.set(entry.providerType, [
|
||||
...(byProviderType.get(entry.providerType) ?? []),
|
||||
entry,
|
||||
]);
|
||||
}
|
||||
|
||||
const sortEntries = (group: ComplianceCatalogEntry[]) =>
|
||||
[...group].sort((a, b) =>
|
||||
frameworkLabel(a).localeCompare(frameworkLabel(b)),
|
||||
);
|
||||
|
||||
const providerGroups: WatchlistGroup[] = Array.from(byProviderType.entries())
|
||||
.map(([providerType, group]) => ({
|
||||
key: providerType,
|
||||
label: getProviderDisplayName(providerType),
|
||||
entries: sortEntries(group),
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
return [
|
||||
...(universal.length > 0
|
||||
? [
|
||||
{
|
||||
key: WATCHLIST_SCOPE.UNIVERSAL,
|
||||
label: UNIVERSAL_GROUP_LABEL,
|
||||
entries: sortEntries(universal),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...providerGroups,
|
||||
];
|
||||
};
|
||||
|
||||
const pinnedKeys = (entries: ComplianceCatalogEntry[]): string[] =>
|
||||
entries
|
||||
.filter((entry) => entry.inWatchlist)
|
||||
.map((entry) => watchlistKey(entry));
|
||||
|
||||
const toTarget = (
|
||||
entry: ComplianceCatalogEntry,
|
||||
): ComplianceWatchlistTarget => ({
|
||||
complianceId: entry.complianceId,
|
||||
providerType: entry.providerType,
|
||||
});
|
||||
|
||||
export const WatchlistMultiSelect = ({
|
||||
entries,
|
||||
id = "compliance-watchlist-selector",
|
||||
}: WatchlistMultiSelectProps) => {
|
||||
const { toast } = useToast();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [open, setOpen] = useState(false);
|
||||
const labelId = `${id}-label`;
|
||||
// Local state needed: selections are pending edits, applied only on close.
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>(() =>
|
||||
pinnedKeys(entries),
|
||||
);
|
||||
|
||||
const groups = buildGroups(entries);
|
||||
|
||||
const submit = (nextKeys: string[]) => {
|
||||
const selected = new Set(nextKeys);
|
||||
const diff = computeWatchlistDiff(
|
||||
entries.filter((entry) => entry.inWatchlist).map(toTarget),
|
||||
entries
|
||||
.filter((entry) => selected.has(watchlistKey(entry)))
|
||||
.map(toTarget),
|
||||
);
|
||||
|
||||
if (isEmptyWatchlistDiff(diff)) return;
|
||||
|
||||
if (exceedsWatchlistBulkLimit(diff)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Too many changes at once",
|
||||
description: `A single update may reference at most ${MAX_WATCHLIST_BULK} frameworks. Apply the changes in smaller batches.`,
|
||||
});
|
||||
setSelectedKeys(pinnedKeys(entries));
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await bulkUpdateComplianceWatchlist(diff);
|
||||
|
||||
if (result.error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description: result.error,
|
||||
});
|
||||
setSelectedKeys(pinnedKeys(entries));
|
||||
return;
|
||||
}
|
||||
|
||||
toast({ title: "Watchlist updated", description: result.success });
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
setSelectedKeys(pinnedKeys(entries));
|
||||
} else {
|
||||
submit(selectedKeys);
|
||||
}
|
||||
setOpen(nextOpen);
|
||||
};
|
||||
|
||||
const pinnedCount = selectedKeys.length;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<label htmlFor={id} className="sr-only" id={labelId}>
|
||||
Compliance watchlist. Select the frameworks your organization wants to
|
||||
keep an eye on.
|
||||
</label>
|
||||
<MultiSelect
|
||||
values={selectedKeys}
|
||||
onValuesChange={setSelectedKeys}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<MultiSelectTrigger
|
||||
id={id}
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
aria-labelledby={labelId}
|
||||
>
|
||||
<span className="truncate">
|
||||
{pinnedCount > 0
|
||||
? `Watchlist · ${pinnedCount.toLocaleString()} pinned`
|
||||
: "Watchlist · none pinned"}
|
||||
</span>
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent
|
||||
width="wide"
|
||||
search={{
|
||||
placeholder: "Search frameworks...",
|
||||
emptyMessage: "No frameworks match your search.",
|
||||
}}
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<MultiSelectGroup key={group.key} heading={group.label}>
|
||||
{group.entries.map((entry) => {
|
||||
const label = frameworkLabel(entry);
|
||||
|
||||
return (
|
||||
<MultiSelectItem
|
||||
key={watchlistKey(entry)}
|
||||
value={watchlistKey(entry)}
|
||||
badgeLabel={label}
|
||||
keywords={[entry.framework, entry.name, entry.complianceId]}
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
</MultiSelectItem>
|
||||
);
|
||||
})}
|
||||
</MultiSelectGroup>
|
||||
))}
|
||||
</MultiSelectContent>
|
||||
</MultiSelect>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { WATCHLIST_PIN_STATE } from "@/types/compliance-watchlist";
|
||||
|
||||
import { WatchlistToggle } from "./watchlist-toggle";
|
||||
|
||||
const {
|
||||
addComplianceToWatchlistMock,
|
||||
removeComplianceFromWatchlistMock,
|
||||
bulkUpdateComplianceWatchlistMock,
|
||||
toastMock,
|
||||
} = vi.hoisted(() => ({
|
||||
addComplianceToWatchlistMock: vi.fn(),
|
||||
removeComplianceFromWatchlistMock: vi.fn(),
|
||||
bulkUpdateComplianceWatchlistMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/compliance-watchlist", () => ({
|
||||
addComplianceToWatchlist: addComplianceToWatchlistMock,
|
||||
removeComplianceFromWatchlist: removeComplianceFromWatchlistMock,
|
||||
bulkUpdateComplianceWatchlist: bulkUpdateComplianceWatchlistMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/toast/use-toast", () => ({
|
||||
useToast: () => ({ toast: toastMock }),
|
||||
}));
|
||||
|
||||
const TARGET = { complianceId: "cis_1.4_aws", providerType: "aws" };
|
||||
const ENTRY_ID = "3fa85f64-5717-4562-b3fc-2c963f66afa6";
|
||||
|
||||
beforeEach(() => {
|
||||
addComplianceToWatchlistMock.mockResolvedValue({ success: "ok" });
|
||||
removeComplianceFromWatchlistMock.mockResolvedValue({ success: "ok" });
|
||||
bulkUpdateComplianceWatchlistMock.mockResolvedValue({ success: "ok" });
|
||||
});
|
||||
|
||||
describe("WatchlistToggle", () => {
|
||||
it("adds an unpinned framework", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<WatchlistToggle target={TARGET} state={WATCHLIST_PIN_STATE.UNPINNED} />,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Watchlist" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(addComplianceToWatchlistMock).toHaveBeenCalledWith(TARGET),
|
||||
);
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "Success!" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes a pinned framework by entry id", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<WatchlistToggle
|
||||
target={TARGET}
|
||||
state={WATCHLIST_PIN_STATE.PINNED}
|
||||
entryId={ENTRY_ID}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Watchlist" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(removeComplianceFromWatchlistMock).toHaveBeenCalledWith(ENTRY_ID),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to bulk removal when the entry id is unavailable", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<WatchlistToggle target={TARGET} state={WATCHLIST_PIN_STATE.PINNED} />,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Watchlist" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(bulkUpdateComplianceWatchlistMock).toHaveBeenCalledWith({
|
||||
add: [],
|
||||
remove: [TARGET],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes the optimistic state while the request is pending", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
let resolveAction: (value: { success: string }) => void = () => {};
|
||||
addComplianceToWatchlistMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveAction = resolve;
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<WatchlistToggle target={TARGET} state={WATCHLIST_PIN_STATE.UNPINNED} />,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Watchlist" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Watchlist" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
),
|
||||
);
|
||||
resolveAction({ success: "ok" });
|
||||
});
|
||||
|
||||
it("rolls back and reports an API error", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
addComplianceToWatchlistMock.mockResolvedValue({ error: "Forbidden" });
|
||||
render(
|
||||
<WatchlistToggle target={TARGET} state={WATCHLIST_PIN_STATE.UNPINNED} />,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Watchlist" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ variant: "destructive" }),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Watchlist" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"false",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { Pin } from "lucide-react";
|
||||
import type { MouseEvent } from "react";
|
||||
import { useOptimistic, useTransition } from "react";
|
||||
|
||||
import {
|
||||
addComplianceToWatchlist,
|
||||
bulkUpdateComplianceWatchlist,
|
||||
removeComplianceFromWatchlist,
|
||||
} from "@/actions/compliance-watchlist";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { useToast } from "@/components/shadcn/toast/use-toast";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
ComplianceWatchlistActionResult,
|
||||
ComplianceWatchlistTarget,
|
||||
WatchlistPinState,
|
||||
} from "@/types/compliance-watchlist";
|
||||
import { WATCHLIST_PIN_STATE } from "@/types/compliance-watchlist";
|
||||
|
||||
interface WatchlistToggleProps {
|
||||
target: ComplianceWatchlistTarget;
|
||||
state: WatchlistPinState;
|
||||
entryId?: string | null;
|
||||
}
|
||||
|
||||
const LABELS = {
|
||||
[WATCHLIST_PIN_STATE.UNPINNED]: "Add to Watchlist",
|
||||
[WATCHLIST_PIN_STATE.PINNED]: "Remove From Watchlist",
|
||||
} as const satisfies Record<WatchlistPinState, string>;
|
||||
|
||||
export const WatchlistToggle = ({
|
||||
target,
|
||||
state,
|
||||
entryId,
|
||||
}: WatchlistToggleProps) => {
|
||||
const { toast } = useToast();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
// useOptimistic (not useState) so the override is scoped to the transition:
|
||||
// it reverts by itself when the action settles, whether the server accepted
|
||||
// the change or rejected it.
|
||||
const [optimisticState, setOptimisticState] = useOptimistic(state);
|
||||
|
||||
const isPinned = optimisticState === WATCHLIST_PIN_STATE.PINNED;
|
||||
const label = LABELS[optimisticState];
|
||||
|
||||
const mutate = (): Promise<ComplianceWatchlistActionResult> => {
|
||||
if (isPinned) {
|
||||
if (entryId) {
|
||||
return removeComplianceFromWatchlist(entryId);
|
||||
}
|
||||
return bulkUpdateComplianceWatchlist({ add: [], remove: [target] });
|
||||
}
|
||||
return addComplianceToWatchlist(target);
|
||||
};
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
if (isPending) return;
|
||||
|
||||
startTransition(async () => {
|
||||
setOptimisticState(
|
||||
isPinned ? WATCHLIST_PIN_STATE.UNPINNED : WATCHLIST_PIN_STATE.PINNED,
|
||||
);
|
||||
const result = await mutate();
|
||||
|
||||
if (result.error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description: result.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Success!",
|
||||
description: isPinned
|
||||
? "The framework was removed from the watchlist."
|
||||
: "The framework was added to the watchlist.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="bare"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
data-pin-state={optimisticState}
|
||||
aria-label="Watchlist"
|
||||
aria-pressed={isPinned}
|
||||
disabled={isPending}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Pin aria-hidden className={cn(isPinned && "fill-current")} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
+147
-51
@@ -520,6 +520,8 @@ vi.mock("../../muted", () => ({
|
||||
import type { ResourceDrawerFinding } from "@/actions/findings";
|
||||
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
import type { FindingComplianceFramework } from "@/types/compliance-watchlist";
|
||||
import { WATCHLIST_SCOPE } from "@/types/compliance-watchlist";
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageSummary,
|
||||
@@ -545,12 +547,41 @@ afterEach(() => {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A watchlisted framework as the API reports it for a finding. Provider-scoped
|
||||
* by default, which is the case that navigates without a lookup. */
|
||||
const complianceFramework = (
|
||||
overrides: Partial<FindingComplianceFramework> = {},
|
||||
): FindingComplianceFramework => ({
|
||||
id: "aws:cis_1.4_aws",
|
||||
complianceId: "cis_1.4_aws",
|
||||
providerType: "aws",
|
||||
scope: WATCHLIST_SCOPE.PROVIDER,
|
||||
framework: "CIS-1.4",
|
||||
name: "CIS",
|
||||
version: "1.4",
|
||||
inWatchlist: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockCheckMeta: CheckMeta = {
|
||||
checkId: "s3_check",
|
||||
checkTitle: "S3 Check",
|
||||
risk: "High",
|
||||
description: "S3 description",
|
||||
complianceFrameworks: ["CIS-1.4", "PCI-DSS"],
|
||||
complianceFrameworks: [
|
||||
complianceFramework({
|
||||
id: "aws:cis_1.4_aws",
|
||||
complianceId: "cis_1.4_aws",
|
||||
framework: "CIS-1.4",
|
||||
version: "1.4",
|
||||
}),
|
||||
complianceFramework({
|
||||
id: "aws:pci_dss_4.0_aws",
|
||||
complianceId: "pci_dss_4.0_aws",
|
||||
framework: "PCI-DSS",
|
||||
version: "4.0",
|
||||
}),
|
||||
],
|
||||
categories: ["security"],
|
||||
remediation: {
|
||||
recommendation: { text: "Fix it", url: "https://example.com" },
|
||||
@@ -1176,28 +1207,40 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("should resolve the clicked framework against the selected scan and navigate to compliance detail", async () => {
|
||||
it("should keep compliance logo canvases light in every navigation state", () => {
|
||||
// Given
|
||||
mockGetComplianceIcon.mockReturnValue("/compliance.svg");
|
||||
const props = {
|
||||
isLoading: false,
|
||||
isNavigating: false,
|
||||
checkMeta: mockCheckMeta,
|
||||
currentIndex: 0,
|
||||
totalResources: 1,
|
||||
currentFinding: mockFinding,
|
||||
otherFindings: [],
|
||||
onNavigatePrev: vi.fn(),
|
||||
onNavigateNext: vi.fn(),
|
||||
onMuteComplete: vi.fn(),
|
||||
};
|
||||
const { rerender } = render(<ResourceDetailDrawerContent {...props} />);
|
||||
|
||||
// When / Then - No scan: static logo
|
||||
const staticLogo = screen.getByRole("img", { name: "PCI-DSS 4.0" });
|
||||
expect(staticLogo.parentElement).toHaveClass("bg-slate-50");
|
||||
|
||||
// When / Then - Selected scan: navigable logo
|
||||
mockSearchParamsState.value = "filter[scan__in]=scan-selected";
|
||||
rerender(<ResourceDetailDrawerContent {...props} />);
|
||||
const navigableLogo = screen.getByRole("img", { name: "PCI-DSS 4.0" });
|
||||
expect(navigableLogo.parentElement).toHaveClass("bg-slate-50");
|
||||
});
|
||||
|
||||
it("should navigate straight to the framework the API identified, without querying the scan's overview", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
vi.stubGlobal("open", mockWindowOpen);
|
||||
mockSearchParamsState.value =
|
||||
"filter[scan__in]=scan-selected&filter[region__in]=eu-west-1";
|
||||
mockGetCompliancesOverview.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "compliance-1",
|
||||
type: "compliance-overviews",
|
||||
attributes: {
|
||||
framework: "PCI-DSS",
|
||||
version: "4.0",
|
||||
requirements_passed: 10,
|
||||
requirements_failed: 2,
|
||||
requirements_manual: 0,
|
||||
total_requirements: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
@@ -1217,16 +1260,14 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => {
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Open PCI-DSS compliance details",
|
||||
name: "Open PCI-DSS 4.0 compliance details",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(mockGetCompliancesOverview).toHaveBeenCalledWith({
|
||||
scanId: "scan-selected",
|
||||
});
|
||||
expect(mockGetCompliancesOverview).not.toHaveBeenCalled();
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
"/compliance/PCI-DSS?complianceId=compliance-1&version=4.0&scanId=scan-selected&filter%5Bregion__in%5D=eu-west-1",
|
||||
"/compliance/PCI-DSS?complianceId=pci_dss_4.0_aws&version=4.0&scanId=scan-selected&filter%5Bregion__in%5D=eu-west-1",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
@@ -1236,22 +1277,6 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
vi.stubGlobal("open", mockWindowOpen);
|
||||
mockGetCompliancesOverview.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "compliance-2",
|
||||
type: "compliance-overviews",
|
||||
attributes: {
|
||||
framework: "PCI-DSS",
|
||||
version: "4.0",
|
||||
requirements_passed: 10,
|
||||
requirements_failed: 2,
|
||||
requirements_manual: 0,
|
||||
total_requirements: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const findingWithScan = {
|
||||
...mockFinding,
|
||||
scan: {
|
||||
@@ -1287,22 +1312,19 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => {
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Open PCI-DSS compliance details",
|
||||
name: "Open PCI-DSS 4.0 compliance details",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(mockGetCompliancesOverview).toHaveBeenCalledWith({
|
||||
scanId: "scan-from-finding",
|
||||
});
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
"/compliance/PCI-DSS?complianceId=compliance-2&version=4.0&scanId=scan-from-finding",
|
||||
"/compliance/PCI-DSS?complianceId=pci_dss_4.0_aws&version=4.0&scanId=scan-from-finding",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
});
|
||||
|
||||
it("should navigate when the finding framework is a short alias of the compliance overview framework", async () => {
|
||||
it("should navigate a universal framework by its own id too, without a lookup", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
vi.stubGlobal("open", mockWindowOpen);
|
||||
@@ -1348,7 +1370,16 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => {
|
||||
isNavigating={false}
|
||||
checkMeta={{
|
||||
...mockCheckMeta,
|
||||
complianceFrameworks: ["KISA"],
|
||||
complianceFrameworks: [
|
||||
complianceFramework({
|
||||
id: "*:kisa_isms_p",
|
||||
complianceId: "kisa_isms_p",
|
||||
providerType: "*",
|
||||
scope: WATCHLIST_SCOPE.UNIVERSAL,
|
||||
framework: "KISA",
|
||||
version: "1.0",
|
||||
}),
|
||||
],
|
||||
}}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
@@ -1363,16 +1394,81 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => {
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Open KISA compliance details",
|
||||
name: "Open KISA 1.0 compliance details",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(mockGetCompliancesOverview).toHaveBeenCalledWith({
|
||||
scanId: "scan-from-finding",
|
||||
});
|
||||
// A universal framework's id is the SDK's file stem, which the per-scan
|
||||
// detail page keys on just like any other, so there is no lookup and no
|
||||
// `window.open` after an await for a pop-up blocker to swallow.
|
||||
expect(mockGetCompliancesOverview).not.toHaveBeenCalled();
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
"/compliance/KISA-ISMS-P?complianceId=compliance-kisa&version=1.0&scanId=scan-from-finding",
|
||||
"/compliance/KISA?complianceId=kisa_isms_p&version=1.0&scanId=scan-from-finding",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
});
|
||||
|
||||
it("should fall back to the framework's name for the URL, as the label does", async () => {
|
||||
// Given: a framework the SDK exposes no metadata for, so `framework` is
|
||||
// empty. It is a path segment, so without the same fallback the label uses
|
||||
// the destination collapses to `/compliance/`.
|
||||
const user = userEvent.setup();
|
||||
vi.stubGlobal("open", mockWindowOpen);
|
||||
const findingWithScan = {
|
||||
...mockFinding,
|
||||
scan: {
|
||||
id: "scan-from-finding",
|
||||
name: "Nightly scan",
|
||||
trigger: "manual",
|
||||
state: "completed",
|
||||
uniqueResourceCount: 25,
|
||||
progress: 100,
|
||||
duration: 300,
|
||||
startedAt: "2026-03-30T10:00:00Z",
|
||||
completedAt: "2026-03-30T10:05:00Z",
|
||||
insertedAt: "2026-03-30T09:59:00Z",
|
||||
scheduledAt: null,
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={{
|
||||
...mockCheckMeta,
|
||||
complianceFrameworks: [
|
||||
complianceFramework({
|
||||
id: "aws:mitre_attack_aws",
|
||||
complianceId: "mitre_attack_aws",
|
||||
framework: "",
|
||||
name: "MITRE-ATTACK",
|
||||
version: "1.0",
|
||||
}),
|
||||
],
|
||||
}}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={findingWithScan}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Open MITRE-ATTACK 1.0 compliance details",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
"/compliance/MITRE-ATTACK?complianceId=mitre_attack_aws&version=1.0&scanId=scan-from-finding",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
|
||||
+87
-225
@@ -15,7 +15,6 @@ import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { getCompliancesOverview } from "@/actions/compliances";
|
||||
import {
|
||||
loadLatestFindingTriageNote,
|
||||
type ResourceDrawerFinding,
|
||||
@@ -80,7 +79,7 @@ import { getRegionFlag } from "@/lib/region-flags";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { getRecommendationLinkLabel } from "@/lib/vulnerability-references";
|
||||
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
|
||||
import type { ComplianceOverviewData } from "@/types/compliance";
|
||||
import type { FindingComplianceFramework } from "@/types/compliance-watchlist";
|
||||
import type { FindingResourceRow } from "@/types/findings-table";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
|
||||
@@ -168,94 +167,66 @@ function renderRemediationCodeBlock({
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeComplianceFrameworkName(framework: string): string {
|
||||
return framework
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.replace(/-+/g, "-");
|
||||
/** Frameworks are not uniquely named — eight AWS ones are called "CIS" — so the
|
||||
* version is part of the label, not decoration. */
|
||||
function complianceFrameworkLabel(
|
||||
framework: FindingComplianceFramework,
|
||||
): string {
|
||||
const name = framework.framework || framework.name;
|
||||
return framework.version ? `${name} ${framework.version}` : name;
|
||||
}
|
||||
|
||||
function stripComplianceVersionSuffix(framework: string): string {
|
||||
return framework.replace(/-\d+(?:\.\d+)*$/g, "");
|
||||
interface ComplianceFrameworkChipProps {
|
||||
framework: FindingComplianceFramework;
|
||||
isNavigable: boolean;
|
||||
onOpen: (framework: FindingComplianceFramework) => void;
|
||||
}
|
||||
|
||||
function canonicalComplianceKey(framework: string): string {
|
||||
return stripComplianceVersionSuffix(
|
||||
normalizeComplianceFrameworkName(framework),
|
||||
)
|
||||
.replace(/[^a-z0-9]+/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function complianceTokens(framework: string): string[] {
|
||||
return stripComplianceVersionSuffix(
|
||||
normalizeComplianceFrameworkName(framework),
|
||||
)
|
||||
.split("-")
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean)
|
||||
.filter((token) => !/^\d+(?:\.\d+)*$/.test(token));
|
||||
}
|
||||
|
||||
function complianceMatchScore(
|
||||
sourceFramework: string,
|
||||
targetFramework: string,
|
||||
): number {
|
||||
const normalizedSource = normalizeComplianceFrameworkName(sourceFramework);
|
||||
const normalizedTarget = normalizeComplianceFrameworkName(targetFramework);
|
||||
|
||||
if (normalizedSource === normalizedTarget) {
|
||||
return 5;
|
||||
}
|
||||
|
||||
const canonicalSource = canonicalComplianceKey(sourceFramework);
|
||||
const canonicalTarget = canonicalComplianceKey(targetFramework);
|
||||
|
||||
if (canonicalSource === canonicalTarget) {
|
||||
return 4;
|
||||
}
|
||||
|
||||
if (canonicalSource && canonicalTarget) {
|
||||
const sourceTokens = canonicalSource.split("-");
|
||||
const targetTokens = canonicalTarget.split("-");
|
||||
if (
|
||||
sourceTokens.length !== targetTokens.length &&
|
||||
(sourceTokens.every((t) => targetTokens.includes(t)) ||
|
||||
targetTokens.every((t) => sourceTokens.includes(t)))
|
||||
) {
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
const sourceTokens = complianceTokens(sourceFramework);
|
||||
const targetTokens = complianceTokens(targetFramework);
|
||||
if (!sourceTokens.length || !targetTokens.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const sourceMatchesTarget = sourceTokens.every((token) =>
|
||||
targetTokens.includes(token),
|
||||
);
|
||||
const targetMatchesSource = targetTokens.every((token) =>
|
||||
sourceTokens.includes(token),
|
||||
function ComplianceFrameworkChip({
|
||||
framework,
|
||||
isNavigable,
|
||||
onOpen,
|
||||
}: ComplianceFrameworkChipProps) {
|
||||
const icon = getComplianceIcon(framework.complianceId);
|
||||
const label = complianceFrameworkLabel(framework);
|
||||
const content = icon ? (
|
||||
<span className="border-border-neutral-tertiary flex size-7 shrink-0 items-center justify-center rounded-md border bg-slate-50">
|
||||
<Image
|
||||
src={icon}
|
||||
alt={label}
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
label
|
||||
);
|
||||
|
||||
if (sourceMatchesTarget || targetMatchesSource) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (
|
||||
sourceTokens.some((token) => targetTokens.includes(token)) &&
|
||||
canonicalSource &&
|
||||
canonicalTarget &&
|
||||
(canonicalTarget.includes(canonicalSource) ||
|
||||
canonicalSource.includes(canonicalTarget))
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{isNavigable ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant={icon ? "bare" : "outline"}
|
||||
size={icon ? "icon-xs" : "sm"}
|
||||
aria-label={`Open ${label} compliance details`}
|
||||
onClick={() => onOpen(framework)}
|
||||
>
|
||||
{content}
|
||||
</Button>
|
||||
) : icon ? (
|
||||
content
|
||||
) : (
|
||||
<Badge variant="tag" size="sm" aria-label={label}>
|
||||
{content}
|
||||
</Badge>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function parseSelectedScanIds(scanFilterValue: string | null): string[] {
|
||||
@@ -269,37 +240,6 @@ function parseSelectedScanIds(scanFilterValue: string | null): string[] {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveComplianceMatch(
|
||||
compliances: ComplianceOverviewData[] | undefined,
|
||||
framework: string,
|
||||
): {
|
||||
complianceId: string;
|
||||
framework: string;
|
||||
version: string;
|
||||
} | null {
|
||||
if (!compliances?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = compliances
|
||||
.map((compliance) => ({
|
||||
compliance,
|
||||
score: complianceMatchScore(framework, compliance.attributes.framework),
|
||||
}))
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((a, b) => b.score - a.score)[0]?.compliance;
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
complianceId: match.id,
|
||||
framework: match.attributes.framework,
|
||||
version: match.attributes.version,
|
||||
};
|
||||
}
|
||||
|
||||
function buildComplianceDetailHref({
|
||||
complianceId,
|
||||
framework,
|
||||
@@ -368,9 +308,6 @@ export function ResourceDetailDrawerContent({
|
||||
const openSidePanel = useSidePanelStore((state) => state.openPanel);
|
||||
const lighthouseContext = useLighthouseCurrentContext();
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const [resolvingFramework, setResolvingFramework] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [optimisticallyMutedIds, setOptimisticallyMutedIds] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
@@ -499,42 +436,34 @@ export function ResourceDetailDrawerContent({
|
||||
requestPanelChatMessage("Analyze this finding", lighthouseContext.context);
|
||||
};
|
||||
|
||||
const handleOpenCompliance = async (framework: string) => {
|
||||
if (!complianceScanId || resolvingFramework) {
|
||||
/**
|
||||
* The API hands us the framework's `complianceId`, which is the same string
|
||||
* the per-scan detail page keys on — universal frameworks included, since
|
||||
* their id is the SDK's file stem and the provider template carries it
|
||||
* verbatim. So the destination is known up front: no lookup against the
|
||||
* scan's overview, no matching by display name, and `window.open` stays
|
||||
* inside the click gesture instead of running after an `await`, where a
|
||||
* pop-up blocker would eat it.
|
||||
*/
|
||||
const handleOpenCompliance = (framework: FindingComplianceFramework) => {
|
||||
if (!complianceScanId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setResolvingFramework(framework);
|
||||
|
||||
try {
|
||||
const compliancesOverview = await getCompliancesOverview({
|
||||
window.open(
|
||||
buildComplianceDetailHref({
|
||||
complianceId: framework.complianceId,
|
||||
// Same fallback the chip's label uses: `framework` is empty for one the
|
||||
// SDK exposes no metadata for, and it is a path segment here, so
|
||||
// without it the destination collapses to `/compliance/`.
|
||||
framework: framework.framework || framework.name,
|
||||
version: framework.version,
|
||||
scanId: complianceScanId,
|
||||
});
|
||||
const complianceMatch = resolveComplianceMatch(
|
||||
compliancesOverview?.data,
|
||||
framework,
|
||||
);
|
||||
|
||||
if (!complianceMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(
|
||||
buildComplianceDetailHref({
|
||||
complianceId: complianceMatch.complianceId,
|
||||
framework: complianceMatch.framework,
|
||||
version: complianceMatch.version,
|
||||
scanId: complianceScanId,
|
||||
regionFilter,
|
||||
}),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error resolving compliance detail:", error);
|
||||
} finally {
|
||||
setResolvingFramework(null);
|
||||
}
|
||||
regionFilter,
|
||||
}),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -584,81 +513,14 @@ export function ResourceDetailDrawerContent({
|
||||
Compliance Frameworks:
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{checkMeta.complianceFrameworks.map((framework) => {
|
||||
const icon = getComplianceIcon(framework);
|
||||
const isNavigable = Boolean(complianceScanId);
|
||||
const isResolving = resolvingFramework === framework;
|
||||
|
||||
return icon ? (
|
||||
<Tooltip key={framework}>
|
||||
<TooltipTrigger asChild>
|
||||
{isNavigable ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Open ${framework} compliance details`}
|
||||
onClick={() =>
|
||||
void handleOpenCompliance(framework)
|
||||
}
|
||||
disabled={Boolean(resolvingFramework)}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md border border-gray-300 bg-white p-0.5 transition-shadow hover:shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-wait disabled:opacity-70"
|
||||
>
|
||||
<Image
|
||||
src={icon}
|
||||
alt={framework}
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
{isResolving && (
|
||||
<span className="sr-only">
|
||||
Opening compliance
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-md border border-gray-300 bg-white p-0.5">
|
||||
<Image
|
||||
src={icon}
|
||||
alt={framework}
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{framework}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip key={framework}>
|
||||
<TooltipTrigger asChild>
|
||||
{isNavigable ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Open ${framework} compliance details`}
|
||||
onClick={() =>
|
||||
void handleOpenCompliance(framework)
|
||||
}
|
||||
disabled={Boolean(resolvingFramework)}
|
||||
className="text-text-neutral-secondary inline-flex h-7 shrink-0 items-center rounded-md border border-gray-300 bg-white px-1.5 text-xs transition-shadow hover:shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-wait disabled:opacity-70"
|
||||
>
|
||||
{framework}
|
||||
{isResolving && (
|
||||
<span className="sr-only">
|
||||
Opening compliance
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-text-neutral-secondary inline-flex h-7 shrink-0 items-center rounded-md border border-gray-300 bg-white px-1.5 text-xs">
|
||||
{framework}
|
||||
</span>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{framework}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{checkMeta.complianceFrameworks.map((framework) => (
|
||||
<ComplianceFrameworkChip
|
||||
key={framework.id}
|
||||
framework={framework}
|
||||
isNavigable={Boolean(complianceScanId)}
|
||||
onOpen={handleOpenCompliance}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+116
@@ -9,28 +9,46 @@ const {
|
||||
getFindingByIdMock,
|
||||
getLatestFindingsByResourceUidMock,
|
||||
adaptFindingsByResourceResponseMock,
|
||||
getFindingComplianceFrameworksMock,
|
||||
isCloudMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getFindingByIdMock: vi.fn(),
|
||||
getLatestFindingsByResourceUidMock: vi.fn(),
|
||||
adaptFindingsByResourceResponseMock: vi.fn(),
|
||||
// Shaped like the action's real result, not a bare array: the hook
|
||||
// destructures it, and a mock that lies about that hides the strip never
|
||||
// being populated.
|
||||
getFindingComplianceFrameworksMock: vi.fn(async () => ({
|
||||
frameworks: [] as FindingComplianceFramework[],
|
||||
unavailable: false,
|
||||
})),
|
||||
isCloudMock: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/findings", () => ({
|
||||
getFindingById: getFindingByIdMock,
|
||||
getLatestFindingsByResourceUid: getLatestFindingsByResourceUidMock,
|
||||
adaptFindingsByResourceResponse: adaptFindingsByResourceResponseMock,
|
||||
getFindingComplianceFrameworks: getFindingComplianceFrameworksMock,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
// The setup file clears UI_CLOUD_ENABLED, so without this every test would run
|
||||
// the OSS branch and the Cloud one would go uncovered.
|
||||
vi.mock("@/lib/shared/env", () => ({
|
||||
isCloud: isCloudMock,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import after mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { ResourceDrawerFinding } from "@/actions/findings";
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
import type { FindingComplianceFramework } from "@/types/compliance-watchlist";
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageSummary,
|
||||
@@ -137,6 +155,11 @@ describe("useResourceDetailDrawer — unmount cleanup", () => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] });
|
||||
getFindingComplianceFrameworksMock.mockResolvedValue({
|
||||
frameworks: [],
|
||||
unavailable: false,
|
||||
});
|
||||
isCloudMock.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("should abort the in-flight fetch controller when the hook unmounts", async () => {
|
||||
@@ -911,4 +934,97 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
|
||||
resourceFetchCount,
|
||||
);
|
||||
});
|
||||
|
||||
describe("compliance frameworks strip", () => {
|
||||
const openWithFinding = async (
|
||||
finding: Partial<ResourceDrawerFinding> = {},
|
||||
) => {
|
||||
getFindingByIdMock.mockResolvedValue({ data: [] });
|
||||
adaptFindingsByResourceResponseMock.mockReturnValue([
|
||||
makeDrawerFinding(finding),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResourceDetailDrawer({ resources: [makeResource()] }),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.openDrawer(0);
|
||||
await Promise.resolve();
|
||||
});
|
||||
// The strip is fetched after the panel has its data, so it lands a tick
|
||||
// later than everything else the drawer renders.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
it("fills the strip from the API in Cloud", async () => {
|
||||
getFindingComplianceFrameworksMock.mockResolvedValue({
|
||||
frameworks: [
|
||||
{
|
||||
id: "*:dora_2022_2554",
|
||||
complianceId: "dora_2022_2554",
|
||||
providerType: "*",
|
||||
scope: "universal",
|
||||
framework: "DORA",
|
||||
name: "DORA",
|
||||
version: "",
|
||||
inWatchlist: true,
|
||||
},
|
||||
],
|
||||
unavailable: false,
|
||||
});
|
||||
|
||||
const result = await openWithFinding();
|
||||
|
||||
expect(getFindingComplianceFrameworksMock).toHaveBeenCalledWith(
|
||||
"finding-1",
|
||||
{ inWatchlist: true },
|
||||
);
|
||||
expect(result.current.checkMeta?.complianceFrameworks).toHaveLength(1);
|
||||
expect(
|
||||
result.current.checkMeta?.complianceFrameworks[0].complianceId,
|
||||
).toBe("dora_2022_2554");
|
||||
});
|
||||
|
||||
it("falls back to the check's own framework names when the endpoint cannot answer", async () => {
|
||||
// Cloud, but the request failed. `unavailable` is what tells this apart
|
||||
// from a genuinely empty watchlist, which must leave the strip empty.
|
||||
getFindingComplianceFrameworksMock.mockResolvedValue({
|
||||
frameworks: [],
|
||||
unavailable: true,
|
||||
});
|
||||
|
||||
const result = await openWithFinding({
|
||||
complianceFrameworks: ["CIS", "SOC2"],
|
||||
});
|
||||
|
||||
expect(getFindingComplianceFrameworksMock).toHaveBeenCalled();
|
||||
expect(
|
||||
result.current.checkMeta?.complianceFrameworks.map(
|
||||
(entry) => entry.framework,
|
||||
),
|
||||
).toEqual(["CIS", "SOC2"]);
|
||||
});
|
||||
|
||||
it("falls back to the check's own framework names off Cloud", async () => {
|
||||
// No request is made at all there — the endpoint does not exist — but the
|
||||
// strip must keep showing what the finding already carries.
|
||||
isCloudMock.mockReturnValue(false);
|
||||
|
||||
const result = await openWithFinding({
|
||||
complianceFrameworks: ["CIS", "SOC2"],
|
||||
});
|
||||
|
||||
expect(getFindingComplianceFrameworksMock).not.toHaveBeenCalled();
|
||||
expect(
|
||||
result.current.checkMeta?.complianceFrameworks.map(
|
||||
(entry) => entry.framework,
|
||||
),
|
||||
).toEqual(["CIS", "SOC2"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
adaptFindingsByResourceResponse,
|
||||
getFindingById,
|
||||
getFindingComplianceFrameworks,
|
||||
getLatestFindingsByResourceUid,
|
||||
type ResourceDrawerFinding,
|
||||
} from "@/actions/findings";
|
||||
@@ -13,7 +14,12 @@ import {
|
||||
getOptimisticTriageMutedReason,
|
||||
shouldMarkFindingMutedForTriageUpdate,
|
||||
} from "@/lib/finding-triage";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { FindingResourceRow } from "@/types";
|
||||
import {
|
||||
type FindingComplianceFramework,
|
||||
WATCHLIST_SCOPE,
|
||||
} from "@/types/compliance-watchlist";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
|
||||
// Keep fast carousel navigations in a loading state for one short beat so
|
||||
@@ -29,19 +35,48 @@ export interface CheckMeta {
|
||||
checkTitle: string;
|
||||
risk: string;
|
||||
description: string;
|
||||
complianceFrameworks: string[];
|
||||
/**
|
||||
* Only the frameworks the organization pinned, resolved by the API rather
|
||||
* than derived from the check's metadata: the watchlist is keyed by
|
||||
* `compliance_id`, and the display names the metadata carries cannot be
|
||||
* matched against it without guessing.
|
||||
*/
|
||||
complianceFrameworks: FindingComplianceFramework[];
|
||||
categories: string[];
|
||||
remediation: ResourceDrawerFinding["remediation"];
|
||||
additionalUrls: string[];
|
||||
}
|
||||
|
||||
function extractCheckMeta(finding: ResourceDrawerFinding): CheckMeta {
|
||||
/**
|
||||
* A framework name the check's own metadata carries, dressed as an API entry.
|
||||
*
|
||||
* Only for deployments without the watchlist endpoint. There is no
|
||||
* `compliance_id` behind these names, so `complianceId` holds the display name:
|
||||
* enough for the logo, which resolves by substring, and for the by-name lookup
|
||||
* the universal branch already does. `inWatchlist` is false because on such a
|
||||
* deployment there is no watchlist to be in.
|
||||
*/
|
||||
const fallbackFramework = (framework: string): FindingComplianceFramework => ({
|
||||
id: `fallback:${framework}`,
|
||||
complianceId: framework,
|
||||
providerType: "",
|
||||
scope: WATCHLIST_SCOPE.PROVIDER,
|
||||
framework,
|
||||
name: framework,
|
||||
version: "",
|
||||
inWatchlist: false,
|
||||
});
|
||||
|
||||
function extractCheckMeta(
|
||||
finding: ResourceDrawerFinding,
|
||||
complianceFrameworks: FindingComplianceFramework[],
|
||||
): CheckMeta {
|
||||
return {
|
||||
checkId: finding.checkId,
|
||||
checkTitle: finding.checkTitle,
|
||||
risk: finding.risk,
|
||||
description: finding.description,
|
||||
complianceFrameworks: finding.complianceFrameworks,
|
||||
complianceFrameworks,
|
||||
categories: finding.categories,
|
||||
remediation: finding.remediation,
|
||||
additionalUrls: finding.additionalUrls,
|
||||
@@ -104,10 +139,16 @@ export function useResourceDetailDrawer({
|
||||
const currentFindingCacheRef = useRef<
|
||||
Map<string, ResourceDrawerFinding | null>
|
||||
>(new Map());
|
||||
const complianceFrameworksCacheRef = useRef<
|
||||
Map<string, FindingComplianceFramework[]>
|
||||
>(new Map());
|
||||
const otherFindingsCacheRef = useRef<Map<string, ResourceDrawerFinding[]>>(
|
||||
new Map(),
|
||||
);
|
||||
const checkMetaRef = useRef<CheckMeta | null>(null);
|
||||
// State, not a ref: the compliance frameworks land after the panel has
|
||||
// already painted, so the strip has to re-render on its own rather than
|
||||
// depend on some other setState happening to fire in the same tick.
|
||||
const [checkMeta, setCheckMeta] = useState<CheckMeta | null>(null);
|
||||
const fetchControllerRef = useRef<AbortController | null>(null);
|
||||
const navigationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
@@ -221,6 +262,34 @@ export function useResourceDetailDrawer({
|
||||
return adapted;
|
||||
};
|
||||
|
||||
const fetchComplianceFrameworks = async (
|
||||
finding: ResourceDrawerFinding | null,
|
||||
) => {
|
||||
const cached = complianceFrameworksCacheRef.current.get(findingId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// The whole strip is a Cloud feature; off Cloud there is nothing to ask
|
||||
// for, and the server action would still cost a round trip on the single
|
||||
// queue every other action in this drawer waits behind.
|
||||
const { frameworks, unavailable } = isCloud()
|
||||
? await getFindingComplianceFrameworks(findingId, { inWatchlist: true })
|
||||
: { frameworks: [], unavailable: true };
|
||||
|
||||
// The watchlist endpoint is Cloud-only. Where it does not exist, keep
|
||||
// showing what the check's own metadata already carries rather than
|
||||
// silently dropping the strip for every finding.
|
||||
const resolved =
|
||||
unavailable && finding
|
||||
? finding.complianceFrameworks.map(fallbackFramework)
|
||||
: frameworks;
|
||||
|
||||
complianceFrameworksCacheRef.current.set(findingId, resolved);
|
||||
|
||||
return resolved;
|
||||
};
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [nextCurrentFinding, nextOtherFindings] = await Promise.all([
|
||||
@@ -231,9 +300,16 @@ export function useResourceDetailDrawer({
|
||||
// Discard stale response if a newer request was started
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
checkMetaRef.current = nextCurrentFinding
|
||||
? extractCheckMeta(nextCurrentFinding)
|
||||
: null;
|
||||
setCheckMeta(
|
||||
nextCurrentFinding
|
||||
? extractCheckMeta(
|
||||
nextCurrentFinding,
|
||||
// Already resolved when navigating back to a visited finding, so
|
||||
// the strip does not blink empty on the way.
|
||||
complianceFrameworksCacheRef.current.get(findingId) ?? [],
|
||||
)
|
||||
: null,
|
||||
);
|
||||
|
||||
setCurrentFinding(nextCurrentFinding);
|
||||
// The API already filters to status=FAIL (see getLatestFindingsByResourceUid).
|
||||
@@ -243,7 +319,7 @@ export function useResourceDetailDrawer({
|
||||
);
|
||||
} catch (_error) {
|
||||
if (!controller.signal.aborted) {
|
||||
checkMetaRef.current = null;
|
||||
setCheckMeta(null);
|
||||
setCurrentFinding(null);
|
||||
setOtherFindings([]);
|
||||
}
|
||||
@@ -252,6 +328,25 @@ export function useResourceDetailDrawer({
|
||||
finishNavigation();
|
||||
}
|
||||
}
|
||||
|
||||
// Deliberately after the panel has its data, and deliberately not inside
|
||||
// the `Promise.all` above. Server actions dispatched from a client
|
||||
// component share one queue and run strictly one at a time, so bundling
|
||||
// this one added a whole round-trip to opening any finding. It is
|
||||
// supporting detail: it must never delay the panel, and its failure must
|
||||
// never empty it — hence its own `catch`, outside the block that nulls
|
||||
// everything.
|
||||
try {
|
||||
const frameworks = await fetchComplianceFrameworks(
|
||||
currentFindingCacheRef.current.get(findingId) ?? null,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setCheckMeta((current) =>
|
||||
current ? { ...current, complianceFrameworks: frameworks } : current,
|
||||
);
|
||||
} catch (_error) {
|
||||
// Leaves the strip empty; the panel stays as it is.
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -289,6 +384,7 @@ export function useResourceDetailDrawer({
|
||||
const resource = resources[currentIndex];
|
||||
if (!resource) return;
|
||||
currentFindingCacheRef.current.delete(resource.findingId);
|
||||
complianceFrameworksCacheRef.current.delete(resource.findingId);
|
||||
otherFindingsCacheRef.current.delete(resource.resourceUid);
|
||||
startNavigation();
|
||||
resetCurrentResourceState();
|
||||
@@ -388,7 +484,7 @@ export function useResourceDetailDrawer({
|
||||
isOpen,
|
||||
isLoading,
|
||||
isNavigating,
|
||||
checkMeta: checkMetaRef.current,
|
||||
checkMeta,
|
||||
currentIndex,
|
||||
totalResources: totalResourceCount ?? resources.length,
|
||||
currentResource: currentResource ?? null,
|
||||
|
||||
Reference in New Issue
Block a user