mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 20:42:02 +00:00
refactor(ui): redesign compliance page layout and components (#10767)
This commit is contained in:
+3
-2
@@ -4,9 +4,10 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.25.0] (Prowler UNRELEASED)
|
||||
|
||||
### ❌ Removed
|
||||
### 🔄 Changed
|
||||
|
||||
- Backward-compatibility middleware redirect from `/sign-up?invitation_token=…` to `/invitation/accept?invitation_token=…`; new invitation emails use `/invitation/accept` directly
|
||||
- Redesign compliance page with a horizontal ThreatScore card (always-visible pillar breakdown + ActionDropdown), client-side search for compliance frameworks, compact scan selector trigger, responsive mobile filters, download-started toasts for CSV/PDF exports, enhanced compliance cards with truncated titles, and Alert-based empty/error states; migrate Progress component from HeroUI to shadcn [(#10767)](https://github.com/prowler-cloud/prowler/pull/10767)
|
||||
- Backward-compatibility middleware redirect from `/sign-up?invitation_token=…` to `/invitation/accept?invitation_token=…`; new invitation emails use `/invitation/accept` directly [(#10797)](https://github.com/prowler-cloud/prowler/pull/10797)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6,12 +6,10 @@ import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
export const getCompliancesOverview = async ({
|
||||
scanId,
|
||||
region,
|
||||
query,
|
||||
filters = {},
|
||||
}: {
|
||||
scanId?: string;
|
||||
region?: string | string[];
|
||||
query?: string;
|
||||
filters?: Record<string, string | string[] | undefined>;
|
||||
} = {}) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
@@ -31,8 +29,6 @@ export const getCompliancesOverview = async ({
|
||||
|
||||
setParam("filter[scan_id]", scanId);
|
||||
setParam("filter[region__in]", region);
|
||||
if (query) url.searchParams.set("filter[search]", query);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
headers,
|
||||
@@ -46,15 +42,16 @@ export const getCompliancesOverview = async ({
|
||||
};
|
||||
|
||||
export const getComplianceOverviewMetadataInfo = async ({
|
||||
query = "",
|
||||
sort = "",
|
||||
filters = {},
|
||||
}) => {
|
||||
}: {
|
||||
sort?: string;
|
||||
filters?: Record<string, string | string[] | undefined>;
|
||||
} = {}) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/compliance-overviews/metadata`);
|
||||
|
||||
if (query) url.searchParams.append("filter[search]", query);
|
||||
if (sort) url.searchParams.append("sort", sort);
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
|
||||
@@ -78,7 +78,7 @@ export default async function ComplianceDetail({
|
||||
await Promise.all([
|
||||
getComplianceOverviewMetadataInfo({
|
||||
filters: {
|
||||
"filter[scan_id]": selectedScanId,
|
||||
"filter[scan_id]": selectedScanId ?? undefined,
|
||||
},
|
||||
}),
|
||||
getComplianceAttributes(complianceId),
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("Compliance overview page", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const filePath = path.join(currentDir, "page.tsx");
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
|
||||
it("delegates client-side search to ComplianceOverviewGrid", () => {
|
||||
expect(source).toContain("ComplianceOverviewGrid");
|
||||
expect(source).not.toContain("filter[search]");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Info } from "lucide-react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import {
|
||||
@@ -7,12 +8,14 @@ import {
|
||||
import { getThreatScore } from "@/actions/overview";
|
||||
import { getScans } from "@/actions/scans";
|
||||
import {
|
||||
ComplianceCard,
|
||||
ComplianceSkeletonGrid,
|
||||
NoScansAvailable,
|
||||
ThreatScoreBadge,
|
||||
} from "@/components/compliance";
|
||||
import { ComplianceHeader } from "@/components/compliance/compliance-header/compliance-header";
|
||||
import { ComplianceFilters } from "@/components/compliance/compliance-header/compliance-filters";
|
||||
import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overview-grid";
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import {
|
||||
ExpandedScanData,
|
||||
@@ -30,12 +33,6 @@ export default async function Compliance({
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
|
||||
|
||||
const filters = Object.fromEntries(
|
||||
Object.entries(resolvedSearchParams).filter(([key]) =>
|
||||
key.startsWith("filter["),
|
||||
),
|
||||
);
|
||||
|
||||
const scansData = await getScans({
|
||||
filters: {
|
||||
"filter[state]": "completed",
|
||||
@@ -79,9 +76,12 @@ export default async function Compliance({
|
||||
.filter(Boolean) as ExpandedScanData[];
|
||||
|
||||
// Use scanId from URL, or select the first scan if not provided
|
||||
const selectedScanId =
|
||||
resolvedSearchParams.scanId || expandedScansData[0]?.id || null;
|
||||
const query = (filters["filter[search]"] as string) || "";
|
||||
const scanIdParam = resolvedSearchParams.scanId;
|
||||
const scanIdFromUrl = Array.isArray(scanIdParam)
|
||||
? scanIdParam[0]
|
||||
: scanIdParam;
|
||||
const selectedScanId: string | null =
|
||||
scanIdFromUrl || expandedScansData[0]?.id || null;
|
||||
|
||||
// Find the selected scan
|
||||
const selectedScan = expandedScansData.find(
|
||||
@@ -102,7 +102,6 @@ export default async function Compliance({
|
||||
// Fetch metadata if we have a selected scan
|
||||
const metadataInfoData = selectedScanId
|
||||
? await getComplianceOverviewMetadataInfo({
|
||||
query,
|
||||
filters: {
|
||||
"filter[scan_id]": selectedScanId,
|
||||
},
|
||||
@@ -131,28 +130,39 @@ export default async function Compliance({
|
||||
<ContentLayout title="Compliance" icon="lucide:shield-check">
|
||||
{selectedScanId ? (
|
||||
<>
|
||||
<div className="mb-6 flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<ComplianceHeader
|
||||
scans={expandedScansData}
|
||||
uniqueRegions={uniqueRegions}
|
||||
/>
|
||||
</div>
|
||||
{threatScoreData &&
|
||||
typeof selectedScanId === "string" &&
|
||||
selectedScan && (
|
||||
<div className="w-full lg:w-[360px] lg:flex-shrink-0">
|
||||
<ThreatScoreBadge
|
||||
score={threatScoreData.score}
|
||||
scanId={selectedScanId}
|
||||
provider={selectedScan.providerInfo.provider}
|
||||
selectedScan={selectedScanData}
|
||||
sectionScores={threatScoreData.sectionScores}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Row 1: Filters */}
|
||||
<div className="mb-6">
|
||||
<ComplianceFilters
|
||||
scans={expandedScansData}
|
||||
uniqueRegions={uniqueRegions}
|
||||
selectedScanId={selectedScanId}
|
||||
/>
|
||||
</div>
|
||||
<Suspense key={searchParamsKey} fallback={<ComplianceSkeletonGrid />}>
|
||||
|
||||
{/* Row 2: ThreatScore card — full width, horizontal */}
|
||||
{threatScoreData &&
|
||||
typeof selectedScanId === "string" &&
|
||||
selectedScan && (
|
||||
<div className="mb-6">
|
||||
<ThreatScoreBadge
|
||||
score={threatScoreData.score}
|
||||
scanId={selectedScanId}
|
||||
provider={selectedScan.providerInfo.provider}
|
||||
selectedScan={selectedScanData}
|
||||
sectionScores={threatScoreData.sectionScores}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 3: Compliance grid with client-side search */}
|
||||
<Suspense
|
||||
key={searchParamsKey}
|
||||
fallback={
|
||||
<ComplianceOverviewPanel>
|
||||
<ComplianceSkeletonGrid />
|
||||
</ComplianceOverviewPanel>
|
||||
}
|
||||
>
|
||||
<SSRComplianceGrid
|
||||
searchParams={resolvedSearchParams}
|
||||
selectedScan={selectedScanData}
|
||||
@@ -176,25 +186,23 @@ const SSRComplianceGrid = async ({
|
||||
const scanId = searchParams.scanId?.toString() || "";
|
||||
const regionFilter = searchParams["filter[region__in]"]?.toString() || "";
|
||||
|
||||
// Extract all filter parameters
|
||||
const filters = Object.fromEntries(
|
||||
Object.entries(searchParams).filter(([key]) => key.startsWith("filter[")),
|
||||
);
|
||||
|
||||
// Extract query from filters
|
||||
const query = (filters["filter[search]"] as string) || "";
|
||||
|
||||
// Only fetch compliance data if we have a valid scanId
|
||||
const compliancesData =
|
||||
scanId && scanId.trim() !== ""
|
||||
? await getCompliancesOverview({
|
||||
scanId,
|
||||
region: regionFilter,
|
||||
query,
|
||||
})
|
||||
: { data: [], errors: [] };
|
||||
|
||||
const type = compliancesData?.data?.type;
|
||||
const frameworks = compliancesData?.data
|
||||
?.filter((compliance: ComplianceOverviewData) => {
|
||||
return compliance.attributes.framework !== "ProwlerThreatScore";
|
||||
})
|
||||
.sort((a: ComplianceOverviewData, b: ComplianceOverviewData) =>
|
||||
a.attributes.framework.localeCompare(b.attributes.framework),
|
||||
);
|
||||
|
||||
// Check if the response contains no data
|
||||
if (
|
||||
@@ -204,58 +212,49 @@ const SSRComplianceGrid = async ({
|
||||
type === "tasks"
|
||||
) {
|
||||
return (
|
||||
<div className="flex h-full items-center">
|
||||
<div className="text-default-500 text-sm">
|
||||
No compliance data available for the selected scan.
|
||||
</div>
|
||||
</div>
|
||||
<Alert variant="info">
|
||||
<Info className="size-4" />
|
||||
<AlertDescription>
|
||||
This scan has no compliance data available yet, please select a
|
||||
different one.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle errors returned by the API
|
||||
if (compliancesData?.errors?.length > 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center">
|
||||
<div className="text-default-500 text-sm">Provide a valid scan ID.</div>
|
||||
</div>
|
||||
<Alert variant="info">
|
||||
<Info className="size-4" />
|
||||
<AlertDescription>Provide a valid scan ID.</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
{compliancesData.data
|
||||
.filter((compliance: ComplianceOverviewData) => {
|
||||
// Filter out ProwlerThreatScore from the grid
|
||||
return compliance.attributes.framework !== "ProwlerThreatScore";
|
||||
})
|
||||
.sort((a: ComplianceOverviewData, b: ComplianceOverviewData) =>
|
||||
a.attributes.framework.localeCompare(b.attributes.framework),
|
||||
)
|
||||
.map((compliance: ComplianceOverviewData) => {
|
||||
const { attributes, id } = compliance;
|
||||
const {
|
||||
framework,
|
||||
version,
|
||||
requirements_passed,
|
||||
total_requirements,
|
||||
} = attributes;
|
||||
|
||||
return (
|
||||
<ComplianceCard
|
||||
key={id}
|
||||
title={framework}
|
||||
version={version}
|
||||
passingRequirements={requirements_passed}
|
||||
totalRequirements={total_requirements}
|
||||
prevPassingRequirements={requirements_passed}
|
||||
prevTotalRequirements={total_requirements}
|
||||
scanId={scanId}
|
||||
complianceId={id}
|
||||
id={id}
|
||||
selectedScan={selectedScan}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ComplianceOverviewPanel>
|
||||
<ComplianceOverviewGrid
|
||||
frameworks={frameworks}
|
||||
scanId={scanId}
|
||||
selectedScan={selectedScan}
|
||||
/>
|
||||
</ComplianceOverviewPanel>
|
||||
);
|
||||
};
|
||||
|
||||
const ComplianceOverviewPanel = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
padding="none"
|
||||
className="minimal-scrollbar shadow-small relative z-0 w-full gap-4 overflow-auto"
|
||||
>
|
||||
<CardContent className="flex flex-col gap-4 p-4">{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("ComplianceCard", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
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("uses a responsive stacked layout for narrow screens", () => {
|
||||
expect(source).toContain("flex-col");
|
||||
expect(source).toContain("sm:flex-row");
|
||||
});
|
||||
|
||||
it("uses the shadcn progress component instead of Hero UI", () => {
|
||||
expect(source).toContain('from "@/components/shadcn/progress"');
|
||||
expect(source).not.toContain("@heroui/progress");
|
||||
});
|
||||
|
||||
it("places compact actions in the icon column on larger screens", () => {
|
||||
expect(source).toContain('orientation="column"');
|
||||
expect(source).toContain('buttonWidth="icon"');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Progress } from "@heroui/progress";
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import { Progress } from "@/components/shadcn/progress";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { getReportTypeForFramework } from "@/lib/compliance/compliance-report-types";
|
||||
import {
|
||||
getScoreIndicatorClass,
|
||||
type ScoreColorVariant,
|
||||
} from "@/lib/compliance/score-utils";
|
||||
import { ScanEntity } from "@/types/scans";
|
||||
|
||||
import { getComplianceIcon } from "../icons";
|
||||
@@ -45,13 +54,9 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
(passingRequirements / totalRequirements) * 100,
|
||||
);
|
||||
|
||||
const getRatingColor = (ratingPercentage: number) => {
|
||||
if (ratingPercentage <= 10) {
|
||||
return "danger";
|
||||
}
|
||||
if (ratingPercentage <= 40) {
|
||||
return "warning";
|
||||
}
|
||||
const getRatingVariant = (value: number): ScoreColorVariant => {
|
||||
if (value <= 10) return "danger";
|
||||
if (value <= 40) return "warning";
|
||||
return "success";
|
||||
};
|
||||
|
||||
@@ -80,58 +85,76 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
onClick={navigateToDetail}
|
||||
>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
{getComplianceIcon(title) && (
|
||||
<Image
|
||||
src={getComplianceIcon(title)}
|
||||
alt={`${title} logo`}
|
||||
className="h-10 w-10 min-w-10 rounded-md border border-gray-300 bg-white object-contain p-1"
|
||||
/>
|
||||
)}
|
||||
<div className="flex w-full flex-col">
|
||||
<h4 className="text-small mb-1 leading-5 font-bold">
|
||||
{formatTitle(title)}
|
||||
{version ? ` - ${version}` : ""}
|
||||
</h4>
|
||||
<Progress
|
||||
label="Score:"
|
||||
size="sm"
|
||||
aria-label="Compliance score"
|
||||
value={ratingPercentage}
|
||||
showValueLabel={true}
|
||||
classNames={{
|
||||
track: "drop-shadow-sm border border-default",
|
||||
label: "tracking-wider font-medium text-default-600 text-xs",
|
||||
value: "text-foreground/60 -mb-2",
|
||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-start">
|
||||
<div className="flex shrink-0 items-center justify-between sm:flex-col sm:items-start sm:gap-2">
|
||||
{getComplianceIcon(title) && (
|
||||
<Image
|
||||
src={getComplianceIcon(title)}
|
||||
alt={`${title} logo`}
|
||||
className="h-10 w-10 min-w-10 self-start rounded-md border border-gray-300 bg-white object-contain p-1"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
color={getRatingColor(ratingPercentage)}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<small>
|
||||
role="group"
|
||||
tabIndex={0}
|
||||
>
|
||||
<ComplianceDownloadContainer
|
||||
compact
|
||||
orientation="column"
|
||||
buttonWidth="icon"
|
||||
presentation="dropdown"
|
||||
scanId={scanId}
|
||||
complianceId={complianceId}
|
||||
reportType={getReportTypeForFramework(title)}
|
||||
disabled={hasRegionFilter}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full min-w-0 flex-col gap-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<h4 className="text-small truncate leading-5 font-bold">
|
||||
{formatTitle(title)}
|
||||
{version ? ` - ${version}` : ""}
|
||||
</h4>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{formatTitle(title)}
|
||||
{version ? ` - ${version}` : ""}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<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 className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<small className="min-w-0">
|
||||
<span className="mr-1 text-xs font-semibold">
|
||||
{passingRequirements} / {totalRequirements}
|
||||
</span>
|
||||
Passing Requirements
|
||||
</small>
|
||||
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
role="group"
|
||||
tabIndex={0}
|
||||
>
|
||||
<ComplianceDownloadContainer
|
||||
compact
|
||||
scanId={scanId}
|
||||
complianceId={complianceId}
|
||||
reportType={getReportTypeForFramework(title)}
|
||||
disabled={hasRegionFilter}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { downloadComplianceCsvMock, downloadComplianceReportPdfMock } =
|
||||
vi.hoisted(() => ({
|
||||
downloadComplianceCsvMock: vi.fn(),
|
||||
downloadComplianceReportPdfMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/helper", () => ({
|
||||
downloadComplianceCsv: downloadComplianceCsvMock,
|
||||
downloadComplianceReportPdf: downloadComplianceReportPdfMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
toast: {},
|
||||
}));
|
||||
|
||||
import { ComplianceDownloadContainer } from "./compliance-download-container";
|
||||
|
||||
describe("ComplianceDownloadContainer", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const filePath = path.join(currentDir, "compliance-download-container.tsx");
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses the shared action dropdown for the card actions mode", () => {
|
||||
expect(source).toContain("ActionDropdown");
|
||||
expect(source).not.toContain("@heroui/button");
|
||||
});
|
||||
|
||||
it("should expose an accessible actions menu trigger", () => {
|
||||
render(
|
||||
<ComplianceDownloadContainer
|
||||
compact
|
||||
presentation="dropdown"
|
||||
scanId="scan-1"
|
||||
complianceId="compliance-1"
|
||||
reportType="threatscore"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Open compliance export actions" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should support fixed icon-sized dropdown trigger in column mode", () => {
|
||||
render(
|
||||
<ComplianceDownloadContainer
|
||||
compact
|
||||
presentation="dropdown"
|
||||
orientation="column"
|
||||
buttonWidth="icon"
|
||||
scanId="scan-1"
|
||||
complianceId="compliance-1"
|
||||
reportType="threatscore"
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole("button", {
|
||||
name: "Open compliance export actions",
|
||||
});
|
||||
expect(trigger.className).toContain("border-text-neutral-secondary");
|
||||
});
|
||||
|
||||
it("should open export actions from the compact trigger", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ComplianceDownloadContainer
|
||||
compact
|
||||
presentation="dropdown"
|
||||
scanId="scan-1"
|
||||
complianceId="compliance-1"
|
||||
reportType="threatscore"
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Open compliance export actions" }),
|
||||
);
|
||||
|
||||
expect(screen.getByText("Download CSV report")).toBeInTheDocument();
|
||||
expect(screen.getByText("Download PDF report")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should trigger both downloads from the actions menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ComplianceDownloadContainer
|
||||
compact
|
||||
presentation="dropdown"
|
||||
scanId="scan-1"
|
||||
complianceId="compliance-1"
|
||||
reportType="threatscore"
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Open compliance export actions" }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("menuitem", { name: /Download CSV report/i }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Open compliance export actions" }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("menuitem", { name: /Download PDF report/i }),
|
||||
);
|
||||
|
||||
expect(downloadComplianceCsvMock).toHaveBeenCalledWith(
|
||||
"scan-1",
|
||||
"compliance-1",
|
||||
{},
|
||||
);
|
||||
expect(downloadComplianceReportPdfMock).toHaveBeenCalledWith(
|
||||
"scan-1",
|
||||
"threatscore",
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,15 @@ import { DownloadIcon, FileTextIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { toast } from "@/components/ui";
|
||||
import type { ComplianceReportType } from "@/lib/compliance/compliance-report-types";
|
||||
import {
|
||||
@@ -18,6 +27,9 @@ interface ComplianceDownloadContainerProps {
|
||||
reportType?: ComplianceReportType;
|
||||
compact?: boolean;
|
||||
disabled?: boolean;
|
||||
orientation?: "row" | "column";
|
||||
buttonWidth?: "auto" | "icon";
|
||||
presentation?: "buttons" | "dropdown";
|
||||
}
|
||||
|
||||
export const ComplianceDownloadContainer = ({
|
||||
@@ -26,9 +38,14 @@ export const ComplianceDownloadContainer = ({
|
||||
reportType,
|
||||
compact = false,
|
||||
disabled = false,
|
||||
orientation = "row",
|
||||
buttonWidth = "auto",
|
||||
presentation = "buttons",
|
||||
}: ComplianceDownloadContainerProps) => {
|
||||
const [isDownloadingCsv, setIsDownloadingCsv] = useState(false);
|
||||
const [isDownloadingPdf, setIsDownloadingPdf] = useState(false);
|
||||
const isIconWidth = buttonWidth === "icon";
|
||||
const isDropdown = presentation === "dropdown";
|
||||
|
||||
const handleDownloadCsv = async () => {
|
||||
if (isDownloadingCsv) return;
|
||||
@@ -52,40 +69,116 @@ export const ComplianceDownloadContainer = ({
|
||||
|
||||
const buttonClassName = cn(
|
||||
"border-button-primary text-button-primary hover:bg-button-primary/10",
|
||||
compact && "h-7 px-2 text-xs",
|
||||
compact &&
|
||||
!isIconWidth &&
|
||||
"h-7 px-2 text-xs sm:w-full sm:justify-center sm:px-2.5",
|
||||
orientation === "column" && !isIconWidth && "w-full",
|
||||
isIconWidth && "size-10 rounded-lg p-0",
|
||||
);
|
||||
const labelClassName = isIconWidth
|
||||
? "sr-only"
|
||||
: compact
|
||||
? "sr-only sm:not-sr-only"
|
||||
: undefined;
|
||||
const showTooltip = compact || isIconWidth;
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-2", compact ? "items-center" : "flex-col")}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={buttonClassName}
|
||||
onClick={handleDownloadCsv}
|
||||
disabled={disabled || isDownloadingCsv}
|
||||
aria-label="Download compliance CSV report"
|
||||
>
|
||||
<FileTextIcon
|
||||
size={14}
|
||||
className={isDownloadingCsv ? "animate-download-icon" : ""}
|
||||
/>
|
||||
CSV
|
||||
</Button>
|
||||
{reportType && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={buttonClassName}
|
||||
onClick={handleDownloadPdf}
|
||||
disabled={disabled || isDownloadingPdf}
|
||||
aria-label="Download compliance PDF report"
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "column"
|
||||
? "flex-col items-start"
|
||||
: compact
|
||||
? "w-full justify-end sm:w-auto"
|
||||
: "flex-row",
|
||||
)}
|
||||
>
|
||||
{isDropdown ? (
|
||||
<ActionDropdown
|
||||
variant={isIconWidth ? "bordered" : "table"}
|
||||
ariaLabel="Open compliance export actions"
|
||||
>
|
||||
<DownloadIcon
|
||||
size={14}
|
||||
className={isDownloadingPdf ? "animate-download-icon" : ""}
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
<FileTextIcon
|
||||
className={isDownloadingCsv ? "animate-download-icon" : ""}
|
||||
/>
|
||||
}
|
||||
label="Download CSV report"
|
||||
onSelect={handleDownloadCsv}
|
||||
disabled={disabled || isDownloadingCsv}
|
||||
/>
|
||||
PDF
|
||||
</Button>
|
||||
{reportType && (
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
<DownloadIcon
|
||||
className={isDownloadingPdf ? "animate-download-icon" : ""}
|
||||
/>
|
||||
}
|
||||
label="Download PDF report"
|
||||
onSelect={handleDownloadPdf}
|
||||
disabled={disabled || isDownloadingPdf}
|
||||
/>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2",
|
||||
orientation === "column"
|
||||
? isIconWidth
|
||||
? "flex-col items-start"
|
||||
: "flex-col items-stretch"
|
||||
: compact
|
||||
? "w-full flex-wrap items-center justify-end sm:w-auto sm:flex-nowrap"
|
||||
: "flex-row flex-wrap items-center",
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={buttonClassName}
|
||||
onClick={handleDownloadCsv}
|
||||
disabled={disabled || isDownloadingCsv}
|
||||
aria-label="Download compliance CSV report"
|
||||
>
|
||||
<FileTextIcon
|
||||
size={14}
|
||||
className={isDownloadingCsv ? "animate-download-icon" : ""}
|
||||
/>
|
||||
<span className={labelClassName}>CSV</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{showTooltip && (
|
||||
<TooltipContent>Download CSV report</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
{reportType && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={buttonClassName}
|
||||
onClick={handleDownloadPdf}
|
||||
disabled={disabled || isDownloadingPdf}
|
||||
aria-label="Download compliance PDF report"
|
||||
>
|
||||
<DownloadIcon
|
||||
size={14}
|
||||
className={isDownloadingPdf ? "animate-download-icon" : ""}
|
||||
/>
|
||||
<span className={labelClassName}>PDF</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{showTooltip && (
|
||||
<TooltipContent>Download PDF report</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
|
||||
import {
|
||||
MultiSelect,
|
||||
MultiSelectContent,
|
||||
MultiSelectItem,
|
||||
MultiSelectSelectAll,
|
||||
MultiSelectSeparator,
|
||||
MultiSelectTrigger,
|
||||
MultiSelectValue,
|
||||
} from "@/components/shadcn/select/multiselect";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
|
||||
import { ScanSelector, SelectScanComplianceDataProps } from "./scan-selector";
|
||||
|
||||
interface ComplianceFiltersProps {
|
||||
scans: SelectScanComplianceDataProps["scans"];
|
||||
uniqueRegions: string[];
|
||||
selectedScanId: string;
|
||||
}
|
||||
|
||||
export const ComplianceFilters = ({
|
||||
scans,
|
||||
uniqueRegions,
|
||||
selectedScanId,
|
||||
}: ComplianceFiltersProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { updateFilter } = useUrlFilters();
|
||||
|
||||
const handleScanChange = (selectedKey: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("scanId", selectedKey);
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const regionValues =
|
||||
searchParams.get("filter[region__in]")?.split(",").filter(Boolean) ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex max-w-4xl flex-wrap items-center gap-4">
|
||||
<div className="w-full sm:max-w-[380px] sm:min-w-[200px] sm:flex-1">
|
||||
<ScanSelector
|
||||
scans={scans}
|
||||
selectedScanId={selectedScanId}
|
||||
onSelectionChange={handleScanChange}
|
||||
/>
|
||||
</div>
|
||||
{uniqueRegions.length > 0 && (
|
||||
<div className="w-full sm:max-w-[280px] sm:min-w-[200px] sm:flex-1">
|
||||
<MultiSelect
|
||||
values={regionValues}
|
||||
onValuesChange={(values) => updateFilter("region__in", values)}
|
||||
>
|
||||
<MultiSelectTrigger size="default">
|
||||
<MultiSelectValue placeholder="All Regions" />
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent search={false} width="wide">
|
||||
<MultiSelectSelectAll>Select All</MultiSelectSelectAll>
|
||||
<MultiSelectSeparator />
|
||||
{uniqueRegions.map((region) => (
|
||||
<MultiSelectItem key={region} value={region}>
|
||||
{region}
|
||||
</MultiSelectItem>
|
||||
))}
|
||||
</MultiSelectContent>
|
||||
</MultiSelect>
|
||||
</div>
|
||||
)}
|
||||
<ClearFiltersButton showCount />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("ComplianceHeader", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const filePath = path.join(currentDir, "compliance-header.tsx");
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
|
||||
it("renders the scan selector inside the shared filters grid using default layout", () => {
|
||||
expect(source).toContain("prependElement");
|
||||
expect(source).toContain("<DataCompliance");
|
||||
expect(source).toContain("DataTableFilterCustom");
|
||||
expect(source).not.toContain("gridClassName");
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,9 @@ export const ComplianceHeader = ({
|
||||
selectedScan,
|
||||
}: ComplianceHeaderProps) => {
|
||||
const frameworkFilters = [];
|
||||
const prependElement = showProviders ? (
|
||||
<DataCompliance scans={scans} className="w-full sm:col-span-2" />
|
||||
) : undefined;
|
||||
|
||||
// Add CIS Profile Level filter if framework is CIS
|
||||
if (framework === "CIS") {
|
||||
@@ -42,6 +45,7 @@ export const ComplianceHeader = ({
|
||||
key: "cis_profile_level",
|
||||
labelCheckboxGroup: "Level",
|
||||
values: ["Level 1", "Level 2"],
|
||||
width: "wide" as const,
|
||||
index: 0, // Show first
|
||||
showSelectAll: false, // No "Select All" option since Level 2 includes Level 1
|
||||
defaultValues: ["Level 2"], // Default to Level 2 selected (which includes Level 1)
|
||||
@@ -55,6 +59,7 @@ export const ComplianceHeader = ({
|
||||
key: "region__in",
|
||||
labelCheckboxGroup: "Regions",
|
||||
values: uniqueRegions,
|
||||
width: "wide" as const,
|
||||
index: 1, // Show after framework filters
|
||||
},
|
||||
]
|
||||
@@ -77,9 +82,11 @@ export const ComplianceHeader = ({
|
||||
{selectedScan && <ComplianceScanInfo scan={selectedScan} />}
|
||||
|
||||
{/* Showed in the compliance page */}
|
||||
{showProviders && <DataCompliance scans={scans} />}
|
||||
{!hideFilters && allFilters.length > 0 && (
|
||||
<DataTableFilterCustom filters={allFilters} />
|
||||
{!hideFilters && (allFilters.length > 0 || showProviders) && (
|
||||
<DataTableFilterCustom
|
||||
filters={allFilters}
|
||||
prependElement={prependElement}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{logoPath && complianceTitle && (
|
||||
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
ScanSelector,
|
||||
SelectScanComplianceDataProps,
|
||||
} from "@/components/compliance/compliance-header/index";
|
||||
import { cn } from "@/lib/utils";
|
||||
interface DataComplianceProps {
|
||||
scans: SelectScanComplianceDataProps["scans"];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DataCompliance = ({ scans }: DataComplianceProps) => {
|
||||
export const DataCompliance = ({ scans, className }: DataComplianceProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -36,7 +38,7 @@ export const DataCompliance = ({ scans }: DataComplianceProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex max-w-fit">
|
||||
<div className={cn("w-full", className)}>
|
||||
<ScanSelector
|
||||
scans={scans}
|
||||
selectedScanId={selectedScanId}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./compliance-filters";
|
||||
export * from "./data-compliance";
|
||||
export * from "./scan-selector";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn/select/select";
|
||||
import { getScanEntityLabel } from "@/lib/helper-filters";
|
||||
import { ProviderType, ScanProps } from "@/types";
|
||||
|
||||
import { ComplianceScanInfo } from "./compliance-scan-info";
|
||||
@@ -29,6 +31,7 @@ export const ScanSelector = ({
|
||||
onSelectionChange,
|
||||
}: SelectScanComplianceDataProps) => {
|
||||
const selectedScan = scans.find((item) => item.id === selectedScanId);
|
||||
const triggerLabel = selectedScan ? getScanEntityLabel(selectedScan) : "";
|
||||
|
||||
return (
|
||||
<Select
|
||||
@@ -39,21 +42,28 @@ export const ScanSelector = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full max-w-[360px]">
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a scan">
|
||||
{selectedScan ? (
|
||||
<ComplianceScanInfo scan={selectedScan} />
|
||||
<>
|
||||
<span className="text-text-neutral-secondary shrink-0 text-xs">
|
||||
Scan:
|
||||
</span>
|
||||
<Badge variant="tag" className="truncate">
|
||||
{triggerLabel}
|
||||
</Badge>
|
||||
</>
|
||||
) : (
|
||||
"Select a scan"
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-w-[360px]">
|
||||
<SelectContent>
|
||||
{scans.map((scan) => (
|
||||
<SelectItem
|
||||
key={scan.id}
|
||||
value={scan.id}
|
||||
className="data-[state=checked]:bg-bg-neutral-tertiary"
|
||||
className="data-[state=checked]:bg-bg-neutral-tertiary [&_svg:not([class*='size-'])]:size-6"
|
||||
>
|
||||
<ComplianceScanInfo scan={scan} />
|
||||
</SelectItem>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { ComplianceCard } from "@/components/compliance/compliance-card";
|
||||
import { DataTableSearch } from "@/components/ui/table/data-table-search";
|
||||
import type { ComplianceOverviewData } from "@/types/compliance";
|
||||
import type { ScanEntity } from "@/types/scans";
|
||||
|
||||
interface ComplianceOverviewGridProps {
|
||||
frameworks: ComplianceOverviewData[];
|
||||
scanId: string;
|
||||
selectedScan?: ScanEntity;
|
||||
}
|
||||
|
||||
export const ComplianceOverviewGrid = ({
|
||||
frameworks,
|
||||
scanId,
|
||||
selectedScan,
|
||||
}: ComplianceOverviewGridProps) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const filteredFrameworks = frameworks.filter((compliance) =>
|
||||
compliance.attributes.framework
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<DataTableSearch
|
||||
controlledValue={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
placeholder="Search frameworks..."
|
||||
/>
|
||||
<span className="text-text-neutral-secondary shrink-0 text-sm">
|
||||
{filteredFrameworks.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) => {
|
||||
const { attributes, id } = compliance;
|
||||
const {
|
||||
framework,
|
||||
version,
|
||||
requirements_passed,
|
||||
total_requirements,
|
||||
} = attributes;
|
||||
|
||||
return (
|
||||
<ComplianceCard
|
||||
key={id}
|
||||
title={framework}
|
||||
version={version}
|
||||
passingRequirements={requirements_passed}
|
||||
totalRequirements={total_requirements}
|
||||
prevPassingRequirements={requirements_passed}
|
||||
prevTotalRequirements={total_requirements}
|
||||
scanId={scanId}
|
||||
complianceId={id}
|
||||
id={id}
|
||||
selectedScan={selectedScan}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -13,10 +13,12 @@ export * from "./compliance-custom-details/cis-details";
|
||||
export * from "./compliance-custom-details/ens-details";
|
||||
export * from "./compliance-custom-details/iso-details";
|
||||
export * from "./compliance-download-container";
|
||||
export * from "./compliance-header/compliance-filters";
|
||||
export * from "./compliance-header/compliance-header";
|
||||
export * from "./compliance-header/compliance-scan-info";
|
||||
export * from "./compliance-header/data-compliance";
|
||||
export * from "./compliance-header/scan-selector";
|
||||
export * from "./compliance-overview-grid";
|
||||
export * from "./no-scans-available";
|
||||
export * from "./skeletons/bar-chart-skeleton";
|
||||
export * from "./skeletons/compliance-accordion-skeleton";
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("ComplianceSkeletonGrid", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const filePath = path.join(currentDir, "compliance-grid-skeleton.tsx");
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
|
||||
it("uses shadcn skeletons instead of Hero UI", () => {
|
||||
expect(source).toContain('from "@/components/shadcn/skeleton/skeleton"');
|
||||
expect(source).not.toContain("@heroui/card");
|
||||
expect(source).not.toContain("@heroui/skeleton");
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,11 @@
|
||||
import { Card } from "@heroui/card";
|
||||
import { Skeleton } from "@heroui/skeleton";
|
||||
import React from "react";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
|
||||
export const ComplianceSkeletonGrid = () => {
|
||||
return (
|
||||
<Card className="h-fit w-full p-4">
|
||||
<div className="3xl:grid-cols-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3">
|
||||
{[...Array(28)].map((_, index) => (
|
||||
<div key={index} className="flex flex-col gap-4">
|
||||
<Skeleton className="h-28 rounded-lg">
|
||||
<div className="bg-default-300 h-full"></div>
|
||||
</Skeleton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
{[...Array(28)].map((_, index) => (
|
||||
<Skeleton key={index} className="h-28 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("ThreatScoreBadge", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const filePath = path.join(currentDir, "threatscore-badge.tsx");
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
|
||||
it("uses shadcn card and progress components instead of Hero UI", () => {
|
||||
expect(source).toContain('from "@/components/shadcn/card/card"');
|
||||
expect(source).toContain('from "@/components/shadcn/progress"');
|
||||
expect(source).not.toContain("@heroui/card");
|
||||
expect(source).not.toContain("@heroui/progress");
|
||||
});
|
||||
|
||||
it("uses ActionDropdown for downloads instead of ComplianceDownloadContainer", () => {
|
||||
expect(source).toContain("ActionDropdown");
|
||||
expect(source).toContain("ActionDropdownItem");
|
||||
expect(source).toContain("downloadComplianceCsv");
|
||||
expect(source).toContain("downloadComplianceReportPdf");
|
||||
expect(source).not.toContain("ComplianceDownloadContainer");
|
||||
});
|
||||
|
||||
it("does not use Collapsible components", () => {
|
||||
expect(source).not.toContain("Collapsible");
|
||||
expect(source).not.toContain("CollapsibleTrigger");
|
||||
expect(source).not.toContain("CollapsibleContent");
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardBody } from "@heroui/card";
|
||||
import { Progress } from "@heroui/progress";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
DownloadIcon,
|
||||
FileTextIcon,
|
||||
} from "lucide-react";
|
||||
import { DownloadIcon, FileTextIcon } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { SectionScores } from "@/actions/overview/threat-score";
|
||||
import { ThreatScoreLogo } from "@/components/compliance/threatscore-logo";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/shadcn/collapsible";
|
||||
ActionDropdown,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { Progress } from "@/components/shadcn/progress";
|
||||
import { toast } from "@/components/ui";
|
||||
import { COMPLIANCE_REPORT_TYPES } from "@/lib/compliance/compliance-report-types";
|
||||
import { getScoreColor, getScoreTextClass } from "@/lib/compliance/score-utils";
|
||||
import {
|
||||
getScoreColor,
|
||||
getScoreIndicatorClass,
|
||||
getScoreTextClass,
|
||||
} from "@/lib/compliance/score-utils";
|
||||
import {
|
||||
downloadComplianceCsv,
|
||||
downloadComplianceReportPdf,
|
||||
@@ -44,9 +41,8 @@ export const ThreatScoreBadge = ({
|
||||
}: ThreatScoreBadgeProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isDownloadingPdf, setIsDownloadingPdf] = useState(false);
|
||||
const [isDownloadingCsv, setIsDownloadingCsv] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isDownloadingPdf, setIsDownloadingPdf] = useState(false);
|
||||
|
||||
const complianceId = `prowler_threatscore_${provider.toLowerCase()}`;
|
||||
|
||||
@@ -69,7 +65,18 @@ export const ThreatScoreBadge = ({
|
||||
router.push(`${path}?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleDownloadCsv = async () => {
|
||||
if (isDownloadingCsv) return;
|
||||
setIsDownloadingCsv(true);
|
||||
try {
|
||||
await downloadComplianceCsv(scanId, complianceId, toast);
|
||||
} finally {
|
||||
setIsDownloadingCsv(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadPdf = async () => {
|
||||
if (isDownloadingPdf) return;
|
||||
setIsDownloadingPdf(true);
|
||||
try {
|
||||
await downloadComplianceReportPdf(
|
||||
@@ -82,23 +89,12 @@ export const ThreatScoreBadge = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadCsv = async () => {
|
||||
setIsDownloadingCsv(true);
|
||||
try {
|
||||
await downloadComplianceCsv(scanId, complianceId, toast);
|
||||
} finally {
|
||||
setIsDownloadingCsv(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
shadow="sm"
|
||||
className="border-default-200 h-full border bg-transparent"
|
||||
>
|
||||
<CardBody className="flex flex-row flex-wrap items-center justify-between gap-3 p-4 lg:flex-col lg:items-stretch lg:justify-start">
|
||||
<Card variant="base" padding="md" className="relative gap-4">
|
||||
<CardContent className="flex flex-col gap-4 p-0 pr-14 lg:flex-row lg:items-start lg:gap-6">
|
||||
{/* Clickable ThreatScore button */}
|
||||
<button
|
||||
className="border-default-200 hover:border-default-300 hover:bg-default-50/50 flex w-full cursor-pointer flex-row items-center justify-between gap-4 rounded-lg border bg-transparent p-3 transition-all"
|
||||
className="border-border-neutral-secondary bg-bg-neutral-tertiary hover:border-border-neutral-primary hover:bg-bg-neutral-secondary flex shrink-0 cursor-pointer flex-row items-center justify-between gap-4 rounded-xl border p-3 pr-12 text-left transition-colors lg:pr-3"
|
||||
onClick={handleCardClick}
|
||||
type="button"
|
||||
>
|
||||
@@ -111,92 +107,67 @@ export const ThreatScoreBadge = ({
|
||||
<Progress
|
||||
aria-label="ThreatScore progress"
|
||||
value={score}
|
||||
color={getScoreColor(score)}
|
||||
size="sm"
|
||||
className="w-24"
|
||||
className="border-border-neutral-secondary h-2.5 w-24 border"
|
||||
indicatorClassName={getScoreIndicatorClass(getScoreColor(score))}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Pillar breakdown — always visible */}
|
||||
{sectionScores && Object.keys(sectionScores).length > 0 && (
|
||||
<Collapsible
|
||||
open={isExpanded}
|
||||
onOpenChange={setIsExpanded}
|
||||
className="w-full"
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
aria-label={
|
||||
isExpanded ? "Hide pillar breakdown" : "Show pillar breakdown"
|
||||
}
|
||||
className="text-default-500 hover:text-default-700 flex w-auto items-center justify-center gap-1 py-1 text-xs transition-colors lg:w-full"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp size={14} />
|
||||
Hide pillar breakdown
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown size={14} />
|
||||
Show pillar breakdown
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-default-200 mt-2 w-full space-y-2 border-t pt-2">
|
||||
{Object.entries(sectionScores)
|
||||
.sort(([, a], [, b]) => a - b)
|
||||
.map(([section, sectionScore]) => (
|
||||
<div
|
||||
key={section}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
<div className="border-border-neutral-secondary flex-1 space-y-2 border-t pt-3 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-6">
|
||||
{Object.entries(sectionScores)
|
||||
.sort(([, a], [, b]) => a - b)
|
||||
.map(([section, sectionScore]) => (
|
||||
<div key={section} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-text-neutral-secondary w-1/3 min-w-0 shrink-0 truncate lg:w-1/4">
|
||||
{section}
|
||||
</span>
|
||||
<Progress
|
||||
aria-label={`${section} score`}
|
||||
value={sectionScore}
|
||||
className="border-border-neutral-secondary h-2 min-w-16 flex-1 border"
|
||||
indicatorClassName={getScoreIndicatorClass(
|
||||
getScoreColor(sectionScore),
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={`w-12 shrink-0 text-right font-medium ${getScoreTextClass(sectionScore)}`}
|
||||
>
|
||||
<span className="text-default-600 w-1/3 min-w-0 shrink-0 truncate">
|
||||
{section}
|
||||
</span>
|
||||
<Progress
|
||||
aria-label={`${section} score`}
|
||||
value={sectionScore}
|
||||
color={getScoreColor(sectionScore)}
|
||||
size="sm"
|
||||
className="min-w-16 flex-1"
|
||||
/>
|
||||
<span
|
||||
className={`w-12 shrink-0 text-right font-medium ${getScoreTextClass(sectionScore)}`}
|
||||
>
|
||||
{sectionScore.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
{sectionScore.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={handleDownloadPdf}
|
||||
disabled={isDownloadingPdf || isDownloadingCsv}
|
||||
>
|
||||
<DownloadIcon
|
||||
size={14}
|
||||
className={isDownloadingPdf ? "animate-download-icon" : ""}
|
||||
/>
|
||||
PDF
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={handleDownloadCsv}
|
||||
disabled={isDownloadingCsv || isDownloadingPdf}
|
||||
>
|
||||
<FileTextIcon size={14} />
|
||||
CSV
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
{/* ActionDropdown for downloads — top-right */}
|
||||
<div className="absolute top-3 right-4">
|
||||
<ActionDropdown
|
||||
variant="bordered"
|
||||
ariaLabel="Open compliance export actions"
|
||||
>
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
<FileTextIcon
|
||||
className={isDownloadingCsv ? "animate-download-icon" : ""}
|
||||
/>
|
||||
}
|
||||
label="Download CSV report"
|
||||
onSelect={handleDownloadCsv}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
<DownloadIcon
|
||||
className={isDownloadingPdf ? "animate-download-icon" : ""}
|
||||
/>
|
||||
}
|
||||
label="Download PDF report"
|
||||
onSelect={handleDownloadPdf}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ export * from "./drawer";
|
||||
export * from "./dropdown/dropdown";
|
||||
export * from "./info-field";
|
||||
export * from "./input/input";
|
||||
export * from "./progress";
|
||||
export * from "./search-input/search-input";
|
||||
export * from "./select/multiselect";
|
||||
export * from "./select/select";
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
import { ComponentProps } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ProgressProps extends ComponentProps<typeof ProgressPrimitive.Root> {
|
||||
indicatorClassName?: string;
|
||||
}
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value = 0,
|
||||
indicatorClassName,
|
||||
...props
|
||||
}: ProgressProps) {
|
||||
const normalizedValue = value ?? 0;
|
||||
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
value={normalizedValue}
|
||||
className={cn(
|
||||
"bg-bg-neutral-tertiary relative h-2 w-full overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className={cn(
|
||||
"bg-button-primary h-full w-full flex-1 transition-all",
|
||||
indicatorClassName,
|
||||
)}
|
||||
style={{ transform: `translateX(-${100 - normalizedValue}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress };
|
||||
@@ -53,3 +53,9 @@ export function getScoreLabel(score: number): string {
|
||||
if (score >= SCORE_THRESHOLDS.WARNING) return "Moderate Risk";
|
||||
return "Critical Risk";
|
||||
}
|
||||
|
||||
export function getScoreIndicatorClass(variant: ScoreColorVariant): string {
|
||||
if (variant === "danger") return "bg-bg-fail";
|
||||
if (variant === "warning") return "bg-bg-warning";
|
||||
return "bg-bg-pass";
|
||||
}
|
||||
|
||||
+9
-1
@@ -216,6 +216,10 @@ export const downloadComplianceCsv = async (
|
||||
complianceId: string,
|
||||
toast: ReturnType<typeof useToast>["toast"],
|
||||
): Promise<void> => {
|
||||
toast({
|
||||
title: "Download Started",
|
||||
description: "Preparing the CSV report. This may take a moment.",
|
||||
});
|
||||
const result = await getComplianceCsv(scanId, complianceId);
|
||||
await downloadFile(
|
||||
result,
|
||||
@@ -236,8 +240,12 @@ export const downloadComplianceReportPdf = async (
|
||||
reportType: ComplianceReportType,
|
||||
toast: ReturnType<typeof useToast>["toast"],
|
||||
): Promise<void> => {
|
||||
const result = await getCompliancePdfReport(scanId, reportType);
|
||||
const reportName = COMPLIANCE_REPORT_DISPLAY_NAMES[reportType];
|
||||
toast({
|
||||
title: "Download Started",
|
||||
description: `Preparing the ${reportName} PDF report. This may take a moment.`,
|
||||
});
|
||||
const result = await getCompliancePdfReport(scanId, reportType);
|
||||
await downloadFile(
|
||||
result,
|
||||
"application/pdf",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"@radix-ui/react-icons": "1.3.2",
|
||||
"@radix-ui/react-label": "2.1.7",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-progress": "1.1.7",
|
||||
"@radix-ui/react-radio-group": "1.3.8",
|
||||
"@radix-ui/react-scroll-area": "1.2.10",
|
||||
"@radix-ui/react-select": "2.2.5",
|
||||
|
||||
Generated
+3
@@ -110,6 +110,9 @@ importers:
|
||||
'@radix-ui/react-popover':
|
||||
specifier: 1.1.15
|
||||
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@radix-ui/react-progress':
|
||||
specifier: 1.1.7
|
||||
version: 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@radix-ui/react-radio-group':
|
||||
specifier: 1.3.8
|
||||
version: 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
|
||||
Reference in New Issue
Block a user