feat(ui): add compliance watchlist (#12300)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Pedro Martín
2026-08-05 11:18:45 +02:00
committed by GitHub
co-authored by alejandrobailo
parent a6d5dbacd9
commit 5285d25cfd
72 changed files with 5064 additions and 625 deletions
@@ -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);
});
});
+82 -75
View File
@@ -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)
)}
</>
);
};
+1
View File
@@ -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>
);
};
@@ -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",
);
@@ -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>
)}
@@ -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,