feat(ui): bring the issues UI closer to the original prototype (#13154)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Rubén De la Torre Vicoandalejandrobailo authored and GitHub committed 2026-10-26 17:24:52 +01:00
1 parent 63b8c8969c
commit 41a2bf605a
17 files changed
+495 -43

No files matched your search

@@ -17,6 +17,7 @@ describe("ExploitabilityChart", () => {
issues={12}
findings={37}
actionable={3}
coveredFindings={0}
scope={{ "filter[provider_type__in]": "aws" }}
/>,
);
@@ -45,4 +46,38 @@ describe("ExploitabilityChart", () => {
screen.getByText("37 failed findings folded into 12 issues"),
).toBeInTheDocument();
});
it("draws each verdict's share and names the findings a control covers", () => {
render(
<ExploitabilityChart
counts={{
exploitable: 1,
likely: 2,
conditional: 0,
not_exploitable: 4,
unknown: 1,
}}
issues={8}
findings={30}
actionable={3}
coveredFindings={9}
scope={{}}
/>,
);
// Sized by share, so the bar reads the same whatever the estate's size.
expect(screen.getByTitle("4 not exploitable")).toHaveStyle({
width: "50%",
});
expect(screen.getByTitle("1 exploitable")).toHaveAttribute(
"href",
"/issues?filter%5Bverdict%5D=exploitable",
);
// A verdict with no issues gets no segment.
expect(screen.queryByTitle("0 conditional")).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "9 findings" })).toHaveAttribute(
"href",
"/issues?filter%5Bverdict%5D=not_exploitable",
);
});
});
@@ -1,7 +1,13 @@
import { TriangleAlert } from "lucide-react";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/shadcn";
import { issuesHref } from "@/lib/issues/scope";
import { VERDICT_TONE, VERDICTS, VerdictTone } from "@/lib/issues/verdicts";
import { cn } from "@/lib/utils";
@@ -12,6 +18,8 @@ interface ExploitabilityChartProps {
issues: number;
findings: number;
actionable: number;
/** Findings folded into not-exploitable issues: a control already covers them. */
coveredFindings: number;
/** Overview scope, carried into every drill-down so the counts still match. */
scope: Record<string, string>;
}
@@ -27,6 +35,15 @@ const TONE_DOT: Record<VerdictTone, string> = {
[VERDICT_TONE.neutral]: "bg-bg-data-muted",
};
/** The share bar runs worst to best; likely is a lighter exploitable. */
const BAR_SEGMENT: Record<Verdict, string> = {
exploitable: "bg-bg-fail",
likely: "bg-bg-fail/60",
conditional: TONE_DOT[VERDICT_TONE.warning],
unknown: TONE_DOT[VERDICT_TONE.neutral],
not_exploitable: TONE_DOT[VERDICT_TONE.pass],
};
/**
* The fold, sized for the ThreatScore row.
*
@@ -40,8 +57,11 @@ export const ExploitabilityChart = ({
issues,
findings,
actionable,
coveredFindings,
scope,
}: ExploitabilityChartProps) => {
const barTotal = Object.values(counts).reduce((sum, count) => sum + count, 0);
return (
<Card
variant="base"
@@ -76,6 +96,32 @@ export const ExploitabilityChart = ({
</span>
</Link>
{/* The ladder's shape: whether this estate is mostly open or mostly
blocked, sized by share and never by severity. The rows around it
carry the same links for keyboard and screen-reader users. */}
{barTotal > 0 && (
<div
aria-hidden="true"
className="bg-bg-neutral-tertiary flex h-2 w-full overflow-hidden rounded-full"
>
{(Object.keys(BAR_SEGMENT) as Verdict[])
.filter((id) => counts[id] > 0)
.map((id) => (
<Link
key={id}
tabIndex={-1}
href={issuesHref(scope, { "filter[verdict]": id })}
title={`${counts[id]} ${(VERDICTS.find((item) => item.id === id)?.label ?? id).toLowerCase()}`}
style={{ width: `${(counts[id] / barTotal) * 100}%` }}
className={cn(
"h-full transition-opacity hover:opacity-80",
BAR_SEGMENT[id],
)}
/>
))}
</div>
)}
<Card variant="inner" padding="none" className="gap-0 px-4 py-0.5">
<ul className="divide-border-neutral-secondary flex flex-col divide-y">
{REST.map((id) => {
@@ -111,6 +157,22 @@ export const ExploitabilityChart = ({
<p className="border-border-neutral-secondary text-text-neutral-tertiary mt-auto border-t pt-3 text-xs">
{findings.toLocaleString()} failed findings folded into{" "}
{issues.toLocaleString()} {issues === 1 ? "issue" : "issues"}
{coveredFindings > 0 && (
<>
{" · "}
<Button asChild variant="link" size="link-xs">
<Link
href={issuesHref(scope, {
"filter[verdict]": "not_exploitable",
})}
>
{coveredFindings.toLocaleString()}{" "}
{coveredFindings === 1 ? "finding" : "findings"}
</Link>
</Button>{" "}
already covered by a control
</>
)}
</p>
</CardContent>
</Card>
@@ -29,6 +29,7 @@ export const ExploitabilityChartSSR = async ({
issues={attributes.issues ?? 0}
findings={attributes.findings ?? 0}
actionable={attributes.actionable ?? 0}
coveredFindings={attributes.not_exploitable_findings ?? 0}
scope={filters}
/>
);
@@ -0,0 +1,75 @@
import { render, screen } from "@testing-library/react";
import { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getIssue, notFound } = vi.hoisted(() => ({
getIssue: vi.fn(),
notFound: vi.fn(() => {
throw new Error("NOT_FOUND");
}),
}));
vi.mock("next/navigation", () => ({ notFound }));
vi.mock("@/actions/issues", () => ({ getIssue }));
vi.mock("@/components/shadcn/content-layout", () => ({
ContentLayout: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@/components/shared/feature-flag-gate", () => ({
FeatureFlagGate: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@/components/issues/issue-detail", () => ({
IssueDetail: ({ scanId }: { scanId?: string }) => (
<div data-testid="issue-detail">{scanId}</div>
),
}));
import IssuePage from "./page";
const params = Promise.resolve({ id: "issue-1" });
describe("IssuePage", () => {
beforeEach(() => {
vi.stubEnv("UI_CLOUD_ENABLED", "true");
getIssue.mockReset();
});
it("returns not found in Local Server without asking the API", async () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
await expect(IssuePage({ params })).rejects.toThrow("NOT_FOUND");
expect(getIssue).not.toHaveBeenCalled();
});
it("returns not found when the API has no such issue", async () => {
getIssue.mockResolvedValue({ error: "Not found", status: 404 });
await expect(IssuePage({ params })).rejects.toThrow("NOT_FOUND");
});
it("shows the issue with the detail the list opens in place", async () => {
getIssue.mockResolvedValue({
data: {
type: "issues",
id: "issue-1",
attributes: {
pattern: "aws_public_bucket",
cloud: "aws",
account: "prod",
title: "Public bucket",
verdict: "likely",
},
relationships: { scan: { data: { type: "scans", id: "scan-1" } } },
},
});
render(await IssuePage({ params }));
expect(getIssue).toHaveBeenCalledWith("issue-1");
expect(
screen.getByRole("heading", { name: "Public bucket" }),
).toBeInTheDocument();
expect(screen.getByText("Likely")).toBeInTheDocument();
expect(screen.getByTestId("issue-detail")).toHaveTextContent("scan-1");
expect(screen.getByRole("link", { name: "← All issues" })).toHaveAttribute(
"href",
"/issues",
);
});
});
+76
View File
@@ -0,0 +1,76 @@
import { ShieldAlert } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getIssue } from "@/actions/issues";
import { IssueDetail } from "@/components/issues/issue-detail";
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import { Card } from "@/components/shadcn/card/card";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { EntityInfo } from "@/components/shadcn/entities";
import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
import { FEATURE_FLAG } from "@/lib/feature-flags";
import { VERDICTS } from "@/lib/issues/verdicts";
import { isCloud } from "@/lib/shared/env";
/**
* One issue on its own URL, so a path can be shared or linked from a ticket.
* The body is the same detail the list opens in place.
*/
export default async function IssuePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
if (!isCloud()) notFound();
const { id } = await params;
const issue = (await getIssue(id))?.data;
// handleApiResponse resolves {error, status} on a 4xx: no data is no issue.
if (!issue?.attributes) notFound();
const { attributes, relationships } = issue;
const verdict = VERDICTS.find((item) => item.id === attributes.verdict);
return (
<ContentLayout title="Issue" icon={<ShieldAlert />}>
<FeatureFlagGate
flag={FEATURE_FLAG.ISSUES}
fallback={
<p className="text-text-neutral-tertiary text-sm">
Issues is not available for this account yet.
</p>
}
>
<div className="flex min-w-0 flex-col gap-4">
<Button asChild variant="link" size="link-sm" className="self-start">
<Link href="/issues">← All issues</Link>
</Button>
<Card variant="base" className="min-w-0 gap-3">
<div className="flex flex-wrap items-center gap-3">
<Badge variant={verdict?.variant ?? "tag"}>
{verdict?.label ?? attributes.verdict}
</Badge>
<span className="text-text-neutral-tertiary text-xs">
{attributes.pattern}
</span>
</div>
<h2 className="text-text-neutral-primary text-lg font-semibold break-words">
{attributes.title}
</h2>
<EntityInfo
cloudProvider={attributes.cloud}
entityAlias={attributes.account}
/>
<IssueDetail
id={issue.id}
issue={attributes}
scanId={relationships?.scan?.data?.id}
providerId={relationships?.provider?.data?.id}
/>
</Card>
</div>
</FeatureFlagGate>
</ContentLayout>
);
}
@@ -2,6 +2,7 @@ import { ReactNode } from "react";
import { getIssues, getIssuesOverview } from "@/actions/issues";
import { IssuesLoadError } from "@/components/issues/issues-load-error";
import { IssuesSummary } from "@/components/issues/issues-summary";
import { IssuesTable } from "@/components/issues/issues-table";
import { VerdictFilter } from "@/components/issues/verdict-filter";
import { ISSUE_SCOPE_KEYS } from "@/lib/issues/scope";
@@ -36,17 +37,16 @@ export const IssuesList = async ({
if (typeof value === "string" && value) filters[key] = value;
});
// The counts are deliberately NOT filtered by verdict: the ladder has to
// keep showing every verdict's total, or picking one would zero the rest
// and there would be no way back.
const scope = Object.fromEntries(
Object.entries(filters).filter(
([key]) => key !== "filter[verdict]" && key !== "filter[verdict__in]",
),
);
const [summary, issues] = await Promise.all([
// The counts are deliberately NOT filtered by verdict: the ladder has to
// keep showing every verdict's total, or picking one would zero the rest
// and there would be no way back.
getIssuesOverview({
filters: Object.fromEntries(
Object.entries(filters).filter(
([key]) => key !== "filter[verdict]" && key !== "filter[verdict__in]",
),
),
}),
getIssuesOverview({ filters: scope }),
getIssues({
filters,
page: Number(searchParams.page) || 1,
@@ -63,7 +63,11 @@ export const IssuesList = async ({
return (
<>
{attributes ? null : (
{attributes ? (
<div className="mb-6">
<IssuesSummary summary={attributes} scope={scope} />
</div>
) : (
<IssuesLoadError message="Failed to load issue summary." />
)}
@@ -75,7 +79,6 @@ export const IssuesList = async ({
? {
counts: verdictCounts(attributes),
total: attributes.issues,
findings: attributes.findings,
}
: undefined
}
@@ -76,6 +76,9 @@ interface AttackPathGraphProps {
onNodeClick?: (node: GraphNode) => void;
onNodeDoubleClick?: (node: GraphNode) => void;
onInitialFilter?: (filteredData: AttackPathGraphData) => void;
// Floor for the automatic fit: a graph that would shrink below it is shown
// at this zoom and panned instead, so its labels stay readable.
minFitZoom?: number;
ref?: Ref<GraphHandle>;
className?: string;
}
@@ -194,6 +197,13 @@ const AUTO_FIT_OPTIONS = {
duration: 300,
} as const;
// With a zoom floor the graph can be larger than the canvas and is centred on
// it, so the minimap's lopsided padding would only push the top out of view.
const fitOptionsFor = (minFitZoom?: number) =>
minFitZoom === undefined
? AUTO_FIT_OPTIONS
: { ...AUTO_FIT_OPTIONS, padding: "32px" as const, minZoom: minFitZoom };
const MEASURED_FIT_MAX_ATTEMPTS = 30;
const scheduleMeasuredFit = (
@@ -230,6 +240,7 @@ const GraphCanvas = ({
onNodeClick,
onNodeDoubleClick,
onInitialFilter,
minFitZoom,
ref,
}: GraphCanvasProps) => {
const { zoomIn, zoomOut, fitView, getZoom, getNodes, getNodesBounds } =
@@ -265,9 +276,9 @@ const GraphCanvas = ({
visibleNodes.every((n) => (n.measured?.width ?? 0) > 0)
);
},
() => fitView(AUTO_FIT_OPTIONS),
() => fitView(fitOptionsFor(minFitZoom)),
);
}, [expandedClasses, fitView, getNodes]);
}, [expandedClasses, fitView, getNodes, minFitZoom]);
// --- initialNodeId: synchronous filtered-view derivation on first render ---
// Compute the effective data: if initialNodeId is set and valid, derive filtered subgraph
@@ -352,15 +363,15 @@ const GraphCanvas = ({
);
if (contextualNodes.length > 0) {
fitView({ ...AUTO_FIT_OPTIONS, nodes: contextualNodes });
fitView({ ...fitOptionsFor(minFitZoom), nodes: contextualNodes });
return;
}
}
fitView(AUTO_FIT_OPTIONS);
fitView(fitOptionsFor(minFitZoom));
},
);
}, [expanded, isFilteredView, fitView, getNodes]);
}, [expanded, isFilteredView, fitView, getNodes, minFitZoom]);
useEffect(() => {
const previous = previousExpandedRef.current;
@@ -384,7 +395,7 @@ const GraphCanvas = ({
visibleNodes.every((n) => (n.measured?.width ?? 0) > 0)
);
},
() => fitView(AUTO_FIT_OPTIONS),
() => fitView(fitOptionsFor(minFitZoom)),
);
}
@@ -415,10 +426,10 @@ const GraphCanvas = ({
const contextualNodes = getNodes().filter((n) =>
contextualFitNodeIds.has(n.id),
);
fitView({ ...AUTO_FIT_OPTIONS, nodes: contextualNodes });
fitView({ ...fitOptionsFor(minFitZoom), nodes: contextualNodes });
},
);
}, [expanded, fitView, getNodes]);
}, [expanded, fitView, getNodes, minFitZoom]);
// Cloud reshapes into the attack-path view: drop the account hub, collapse
// each resource class into one expandable node, and inject the outcome node.
@@ -592,7 +603,10 @@ const GraphCanvas = ({
onNodeMouseEnter={handleNodeMouseEnter}
onNodeMouseLeave={handleNodeMouseLeave}
fitView
fitViewOptions={{ ...AUTO_FIT_OPTIONS, includeHiddenNodes: true }}
fitViewOptions={{
...fitOptionsFor(minFitZoom),
includeHiddenNodes: true,
}}
// Supported React Flow behavior: wheel over the graph zooms the
// viewport. The surrounding UX avoids using node details as inline
// content, so this no longer fights a below-graph details section.
@@ -646,6 +660,7 @@ export const AttackPathGraph = ({
onNodeClick,
onNodeDoubleClick,
onInitialFilter,
minFitZoom,
ref,
className,
}: AttackPathGraphProps) => {
@@ -674,6 +689,7 @@ export const AttackPathGraph = ({
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onInitialFilter={onInitialFilter}
minFitZoom={minFitZoom}
/>
</ReactFlowProvider>
</div>
@@ -149,6 +149,22 @@ describe("IssueDetail", () => {
expect(screen.queryByText("Outcome")).not.toBeInTheDocument();
});
it("links each affected resource to it on the Resources page", async () => {
getIssueMock.mockResolvedValue({
data: { attributes: { resource_uids: ["arn:aws:s3:::bucket"] } },
});
render(<IssueDetail id="issue-1" issue={issue} providerId="provider-1" />);
expect(
await screen.findByRole("link", {
name: "Open in Resources: arn:aws:s3:::bucket",
}),
).toHaveAttribute(
"href",
"/resources?filter%5Buid%5D=arn%3Aaws%3As3%3A%3A%3Abucket&filter%5Bprovider_id__in%5D=provider-1",
);
});
it("says so when the resources cannot be loaded", async () => {
getIssueMock.mockResolvedValue(undefined);
render(<IssueDetail id="issue-1" issue={issue} />);
+21 -1
View File
@@ -52,6 +52,15 @@ const STATE_LABELS: Record<LegState, string> = {
};
const RESOURCE_PAGE_SIZE = 12;
// The issue carries resource uids, not ids, so this opens the Resources list
// on that one resource rather than its drawer.
const resourceHref = (uid: string, providerId?: string) =>
`/resources?${new URLSearchParams({
"filter[uid]": uid,
// A uid is unique within an account, not across them.
...(providerId && { "filter[provider_id__in]": providerId }),
})}`;
interface IssueDetailProps {
id: string;
issue: IssueAttributes;
@@ -250,8 +259,19 @@ export const IssueDetail = ({
</h4>
<ul className="flex flex-col gap-2">
{state.resources.slice(0, visibleResources).map((uid) => (
<li key={uid} className="min-w-0">
<li
key={uid}
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
>
<CodeSnippet value={uid} multiline ariaLabel={`Copy ${uid}`} />
<Button asChild variant="link" size="link-sm">
<Link
href={resourceHref(uid, state.providerId)}
aria-label={`Open in Resources: ${uid}`}
>
Open in Resources
</Link>
</Button>
</li>
))}
</ul>
+9 -1
View File
@@ -12,6 +12,7 @@ import {
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Card } from "@/components/shadcn/card/card";
import { useFindingDetails } from "@/hooks/use-finding-details";
import { useMediaQuery } from "@/hooks/use-media-query";
import { isProwlerFindingNode } from "@/lib/attack-paths/node-types";
import { GraphNode } from "@/types/attack-paths";
import { IssueAttributes, IssueFinding } from "@/types/issues";
@@ -34,6 +35,11 @@ interface UnscopedIssueGraphProps extends IssueGraphBaseProps {
type IssueGraphProps = ScopedIssueGraphProps | UnscopedIssueGraphProps;
/** Below this the node labels (11px) stop being legible; past it, pan. */
const ISSUE_GRAPH_MIN_ZOOM = 0.8;
/** A phone cannot fit even one resource and its finding at that zoom: fit all. */
const READABLE_ZOOM_MEDIA_QUERY = "(min-width: 768px)";
export const IssueGraph = ({
issue,
resourceUids,
@@ -42,6 +48,7 @@ export const IssueGraph = ({
scanId,
}: IssueGraphProps) => {
const finding = useFindingDetails();
const canKeepReadableZoom = useMediaQuery(READABLE_ZOOM_MEDIA_QUERY);
const view = buildIssueGraph(issue, resourceUids, findings);
const shownFindings = new Set(
view.nodes.flatMap((node) =>
@@ -149,13 +156,14 @@ export const IssueGraph = ({
<Card
variant="inner"
padding="none"
className="h-[420px] overflow-hidden"
className="h-[560px] overflow-hidden"
>
<AttackPathGraph
data={{ nodes: view.nodes, edges: view.edges }}
view={view}
expandedResources={expandedResources}
onNodeClick={handleNodeClick}
minFitZoom={canKeepReadableZoom ? ISSUE_GRAPH_MIN_ZOOM : undefined}
/>
</Card>
{finding.findingDetails && (
@@ -0,0 +1,51 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { IssuesSummary } from "@/components/issues/issues-summary";
import { IssuesOverviewAttributes } from "@/types/issues";
const summary: IssuesOverviewAttributes = {
issues: 13,
findings: 41,
actionable: 4,
not_exploitable_findings: 1,
exploitable: 1,
likely: 3,
conditional: 5,
not_exploitable: 2,
unknown: 2,
};
describe("IssuesSummary", () => {
it("leads with the reduction and links every tile into the scoped list", () => {
render(
<IssuesSummary
summary={summary}
scope={{ "filter[provider_type__in]": "aws" }}
/>,
);
expect(
screen.getByRole("link", { name: "41 Failed findings folded" }),
).toHaveAttribute("href", "/issues?filter%5Bprovider_type__in%5D=aws");
expect(screen.getByRole("link", { name: "13 Issues" })).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "4 Exploitable or likely" }),
).toHaveAttribute(
"href",
"/issues?filter%5Bprovider_type__in%5D=aws&filter%5Bverdict__in%5D=exploitable%2Clikely",
);
expect(
screen.getByRole("link", {
name: "2 Not exploitable, 1 finding covered by a control",
}),
).toHaveAttribute(
"href",
"/issues?filter%5Bprovider_type__in%5D=aws&filter%5Bverdict%5D=not_exploitable",
);
expect(screen.getByRole("link", { name: "2 Unknown" })).toHaveAttribute(
"href",
"/issues?filter%5Bprovider_type__in%5D=aws&filter%5Bverdict%5D=unknown",
);
});
});
+92
View File
@@ -0,0 +1,92 @@
import {
CircleHelp,
Layers,
type LucideIcon,
Shield,
ShieldCheck,
TriangleAlert,
} from "lucide-react";
import Link from "next/link";
import { CardVariant, ResourceStatsCard } from "@/components/shadcn";
import { issuesHref } from "@/lib/issues/scope";
import { IssuesOverviewAttributes } from "@/types/issues";
interface TileProps {
value: number;
label: string;
detail?: string;
href: string;
icon: LucideIcon;
variant: CardVariant;
}
const Tile = ({ value, label, detail, href, icon, variant }: TileProps) => (
<Link
href={href}
aria-label={[`${value.toLocaleString()} ${label}`, detail]
.filter(Boolean)
.join(", ")}
className="focus-visible:ring-ring/50 min-w-0 rounded-xl focus-visible:ring-2 focus-visible:outline-none"
>
<ResourceStatsCard
badge={{ icon, count: value.toLocaleString(), variant }}
label={label}
stats={detail ? [{ icon: ShieldCheck, label: detail }] : []}
className="hover:border-border-neutral-primary h-full transition-colors"
/>
</Link>
);
/**
* The reduction, as the first row of the page: findings and issues side by
* side because the ratio between them is the claim the page makes. Every tile
* keeps the scope it was counted in.
*/
export const IssuesSummary = ({
summary,
scope,
}: {
summary: IssuesOverviewAttributes;
/** The filters in force other than the verdict, so the counts still match. */
scope: Record<string, string>;
}) => (
<div className="grid grid-cols-2 gap-2 lg:grid-cols-3 2xl:grid-cols-5">
<Tile
value={summary.findings}
label="Failed findings folded"
href={issuesHref(scope)}
icon={Layers}
variant={CardVariant.fail}
/>
<Tile
value={summary.issues}
label="Issues"
href={issuesHref(scope)}
icon={Shield}
variant={CardVariant.default}
/>
<Tile
value={summary.actionable}
label="Exploitable or likely"
href={issuesHref(scope, { "filter[verdict__in]": "exploitable,likely" })}
icon={TriangleAlert}
variant={summary.actionable > 0 ? CardVariant.fail : CardVariant.pass}
/>
<Tile
value={summary.not_exploitable}
label="Not exploitable"
detail={`${summary.not_exploitable_findings.toLocaleString()} ${summary.not_exploitable_findings === 1 ? "finding" : "findings"} covered by a control`}
href={issuesHref(scope, { "filter[verdict]": "not_exploitable" })}
icon={ShieldCheck}
variant={CardVariant.pass}
/>
<Tile
value={summary.unknown}
label="Unknown"
href={issuesHref(scope, { "filter[verdict]": "unknown" })}
icon={CircleHelp}
variant={CardVariant.warning}
/>
</div>
);
@@ -93,6 +93,9 @@ describe("IssuesTable", () => {
screen.getByRole("button", { name: "Collapse Public bucket" }),
).toHaveAttribute("aria-expanded", "true");
expect(await screen.findByText("Restrict access")).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Open the full path" }),
).toHaveAttribute("href", "/issues/issue-1");
// When
await user.keyboard(" ");
// Then
+6
View File
@@ -2,6 +2,7 @@
import { ColumnDef, Row } from "@tanstack/react-table";
import { ChevronDown, ChevronRight } from "lucide-react";
import Link from "next/link";
import { createContext, useContext, useState } from "react";
import { IssueDetail } from "@/components/issues/issue-detail";
@@ -138,6 +139,11 @@ export const IssuesTable = ({
id={`issue-${row.original.id}`}
className="sticky left-0 w-[calc(100cqw-4rem)] min-w-0"
>
<Button asChild variant="link" size="link-sm">
<Link href={`/issues/${row.original.id}`}>
Open the full path
</Link>
</Button>
<IssueDetail
key={`${row.original.id}-${row.original.attributes.updated_at}`}
id={row.original.id}
@@ -341,6 +341,7 @@ describe("Issues responsive layout", () => {
issues={1}
findings={3}
actionable={1}
coveredFindings={0}
scope={{}}
/>
<TopIssues issues={[issue]} scope={{}} />
+6 -9
View File
@@ -30,9 +30,9 @@ const lastPushedParams = () =>
.searchParams;
describe("VerdictFilter", () => {
it("renders one tab per verdict with its count and the fold", () => {
it("renders one tab per verdict with its count", () => {
navigationState.searchParams = new URLSearchParams();
render(<VerdictFilter summary={{ counts, total: 2, findings: 9 }} />);
render(<VerdictFilter summary={{ counts, total: 2 }} />);
expect(screen.getByRole("tab", { name: "All, 2 issues" })).toHaveAttribute(
"aria-selected",
@@ -42,9 +42,6 @@ describe("VerdictFilter", () => {
screen.getByRole("tab", { name: "Exploitable, 2 issues" }),
).toHaveAttribute("aria-selected", "false");
expect(screen.getAllByRole("tab")).toHaveLength(6);
expect(
screen.getByText("9 failed findings folded into 2 issues"),
).toBeInTheDocument();
});
it("selects the verdict tab and resets the page, keeping other params", async () => {
@@ -52,7 +49,7 @@ describe("VerdictFilter", () => {
navigationState.searchParams = new URLSearchParams(
"page=3&pageSize=10&filter[verdict]=likely",
);
render(<VerdictFilter summary={{ counts, total: 2, findings: 9 }} />);
render(<VerdictFilter summary={{ counts, total: 2 }} />);
expect(
screen.getByRole("tab", { name: "Likely, 0 issues" }),
@@ -72,7 +69,7 @@ describe("VerdictFilter", () => {
navigationState.searchParams = new URLSearchParams(
"filter[verdict__in]=exploitable,likely",
);
render(<VerdictFilter summary={{ counts, total: 2, findings: 9 }} />);
render(<VerdictFilter summary={{ counts, total: 2 }} />);
// No single tab owns the combined filter, so none is selected...
expect(
@@ -106,7 +103,7 @@ describe("VerdictFilter", () => {
navigationState.searchParams = new URLSearchParams(
"filter[verdict]=likely",
);
render(<VerdictFilter summary={{ counts, total: 2, findings: 9 }} />);
render(<VerdictFilter summary={{ counts, total: 2 }} />);
expect(screen.queryByRole("region", { name: "Active filters" })).toBeNull();
});
@@ -117,7 +114,7 @@ describe("VerdictFilter", () => {
navigationState.searchParams = new URLSearchParams(
"page=3&filter[pattern]=aws_public_bucket&filter[provider_id__in]=provider-1",
);
render(<VerdictFilter summary={{ counts, total: 2, findings: 9 }} />);
render(<VerdictFilter summary={{ counts, total: 2 }} />);
// When
await user.click(
screen.getByRole("button", {
-10
View File
@@ -15,8 +15,6 @@ interface VerdictSummary {
counts: Record<Verdict, number>;
/** Issues in scope, whatever their verdict. */
total: number;
/** Failed findings those issues fold. */
findings: number;
}
const ALL_TAB = "all";
@@ -142,14 +140,6 @@ export function VerdictFilter({ summary }: { summary?: VerdictSummary }) {
))}
</TabsList>
</div>
{summary ? (
// The fold: how much noise the issue count removed.
<p className="text-text-neutral-tertiary text-xs">
{summary.findings.toLocaleString()} failed findings folded into{" "}
{summary.total.toLocaleString()}{" "}
{summary.total === 1 ? "issue" : "issues"}
</p>
) : null}
</div>
</Tabs>
{/* Only filters that arrived by link get a chip; the tabs and the scope