Compare commits

...

14 Commits

Author SHA1 Message Date
alejandrobailo
ec4d4f4095 chore(deps): update @vitejs/plugin-react to 5.2.0 and add semver override to pnpm lockfile 2026-04-16 16:09:23 +02:00
Alejandro Bailo
ba08476631 Merge branch 'master' into refactor/improve-findings-and-compliance-ui-behavior 2026-04-16 15:43:39 +02:00
alejandrobailo
b959584fb6 refactor(ui): improve compliance scan selector and download UX 2026-04-16 15:04:27 +02:00
alejandrobailo
1b312fbd6b refactor(ui): truncate compliance card title with tooltip 2026-04-16 14:03:56 +02:00
alejandrobailo
c85b51132f refactor(ui): add client-side search for compliance grid 2026-04-16 14:03:51 +02:00
alejandrobailo
35f85ec053 refactor(ui): redesign ThreatScore card with horizontal layout 2026-04-16 14:03:45 +02:00
alejandrobailo
fe89002ee9 refactor(ui): use default grid layout for compliance filters 2026-04-16 14:03:41 +02:00
alejandrobailo
090a035150 refactor(ui): remove unused query param from compliance server actions 2026-04-16 14:03:37 +02:00
alejandrobailo
a511b70b08 test(ui): add tests for compliance, filters, and findings 2026-04-16 13:23:18 +02:00
alejandrobailo
ca6d5a5f47 fix(ui): exclude muted findings by default in resource drawer 2026-04-16 13:23:12 +02:00
alejandrobailo
e10d01ce77 refactor(ui): redesign compliance page layout and components 2026-04-16 13:23:06 +02:00
alejandrobailo
be42b84daf refactor(ui): improve MultiSelect context label display 2026-04-16 13:22:59 +02:00
alejandrobailo
97cc324393 feat(ui): add width option to filter dropdowns 2026-04-16 13:22:56 +02:00
alejandrobailo
eef02f25b8 feat(ui): add shadcn Progress component replacing HeroUI 2026-04-16 13:22:51 +02:00
39 changed files with 1186 additions and 389 deletions

View File

@@ -4,6 +4,10 @@ All notable changes to the **Prowler UI** are documented in this file.
## [1.24.1] (Prowler v5.24.1)
### 🐞 Fixed
- Compliance components coherence and minor UX fixes: simplified scan selector trigger to show a compact badge instead of full entity info, responsive mobile layout for compliance filters, and added download-started toast for CSV/PDF exports [(#10734)](https://github.com/prowler-cloud/prowler/pull/10734)
### 🔒 Security
- Upgrade React to 19.2.5 and Next.js to 16.2.3 to mitigate CVE-2026-23869 (React2DoS), a high-severity unauthenticated remote DoS vulnerability in the React Flight Protocol's Server Function deserialization

View File

@@ -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]) => {

View File

@@ -43,6 +43,7 @@ vi.mock("@/actions/finding-groups", () => ({
}));
import {
getLatestFindingsByResourceUid,
resolveFindingIdsByCheckIds,
resolveFindingIdsByVisibleGroupResources,
} from "./findings-by-resource";
@@ -262,3 +263,41 @@ describe("resolveFindingIdsByVisibleGroupResources", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("getLatestFindingsByResourceUid", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
handleApiResponseMock.mockResolvedValue({ data: [] });
});
it("should exclude muted findings by default and always apply severity/time sorting", async () => {
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
await getLatestFindingsByResourceUid({
resourceUid: "resource-1",
});
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
expect(calledUrl.pathname).toBe("/api/v1/findings/latest");
expect(calledUrl.searchParams.get("filter[resource_uid]")).toBe(
"resource-1",
);
expect(calledUrl.searchParams.get("filter[muted]")).toBe("false");
expect(calledUrl.searchParams.get("sort")).toBe("-severity,-updated_at");
});
it("should include muted findings only when explicitly requested", async () => {
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
await getLatestFindingsByResourceUid({
resourceUid: "resource-1",
includeMuted: true,
});
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
expect(calledUrl.searchParams.get("filter[muted]")).toBe("include");
expect(calledUrl.searchParams.get("sort")).toBe("-severity,-updated_at");
});
});

View File

@@ -250,10 +250,12 @@ export const getLatestFindingsByResourceUid = async ({
resourceUid,
page = 1,
pageSize = 50,
includeMuted = false,
}: {
resourceUid: string;
page?: number;
pageSize?: number;
includeMuted?: boolean;
}) => {
const headers = await getAuthHeaders({ contentType: false });
@@ -262,7 +264,7 @@ export const getLatestFindingsByResourceUid = async ({
);
url.searchParams.append("filter[resource_uid]", resourceUid);
url.searchParams.append("filter[muted]", "include");
url.searchParams.append("filter[muted]", includeMuted ? "include" : "false");
url.searchParams.append("sort", "-severity,-updated_at");
if (page) url.searchParams.append("page[number]", page.toString());
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());

View File

@@ -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),

View File

@@ -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]");
});
});

View File

@@ -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",
@@ -81,7 +78,6 @@ export default async function Compliance({
// 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) || "";
// Find the selected scan
const selectedScan = expandedScansData.find(
@@ -102,7 +98,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 +126,38 @@ 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}
/>
</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 +181,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 +207,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>
);
};

View File

@@ -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"');
});
});

View File

@@ -1,10 +1,15 @@
"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 { ScanEntity } from "@/types/scans";
@@ -45,14 +50,14 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
(passingRequirements / totalRequirements) * 100,
);
const getRatingColor = (ratingPercentage: number) => {
const getRatingIndicatorClassName = (ratingPercentage: number) => {
if (ratingPercentage <= 10) {
return "danger";
return "bg-bg-fail";
}
if (ratingPercentage <= 40) {
return "warning";
return "bg-bg-warning";
}
return "success";
return "bg-bg-pass";
};
const navigateToDetail = () => {
@@ -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={getRatingIndicatorClassName(
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>

View File

@@ -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",
{},
);
});
});

View File

@@ -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>
);

View File

@@ -0,0 +1,86 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
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[];
}
export const ComplianceFilters = ({
scans,
uniqueRegions,
}: ComplianceFiltersProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { updateFilter } = useUrlFilters();
const scanIdParam = searchParams.get("scanId");
const selectedScanId = scanIdParam || (scans.length > 0 ? scans[0].id : "");
useEffect(() => {
if (!scanIdParam && scans.length > 0) {
const params = new URLSearchParams(searchParams);
params.set("scanId", scans[0].id);
router.replace(`?${params.toString()}`, { scroll: false });
}
}, [scans, scanIdParam, searchParams, router]);
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>
);
};

View File

@@ -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");
});
});

View File

@@ -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 && (

View File

@@ -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}

View File

@@ -1,2 +1,3 @@
export * from "./compliance-filters";
export * from "./data-compliance";
export * from "./scan-selector";

View File

@@ -1,5 +1,6 @@
"use client";
import { Badge } from "@/components/shadcn/badge/badge";
import {
Select,
SelectContent,
@@ -29,6 +30,11 @@ export const ScanSelector = ({
onSelectionChange,
}: SelectScanComplianceDataProps) => {
const selectedScan = scans.find((item) => item.id === selectedScanId);
const triggerLabel =
selectedScan?.attributes.name ||
selectedScan?.providerInfo.alias ||
selectedScan?.providerInfo.uid ||
"";
return (
<Select
@@ -39,21 +45,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>

View File

@@ -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>
</>
);
};

View File

@@ -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";

View File

@@ -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");
});
});

View File

@@ -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>
);
};

View File

@@ -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");
});
});

View File

@@ -1,24 +1,17 @@
"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";
@@ -44,12 +37,23 @@ 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()}`;
const getProgressIndicatorClassName = (value: number) => {
const color = getScoreColor(value);
if (color === "danger") {
return "bg-bg-fail";
}
if (color === "warning") {
return "bg-bg-warning";
}
return "bg-bg-pass";
};
const handleCardClick = () => {
const title = "ProwlerThreatScore";
const version = "1.0";
@@ -69,7 +73,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 +97,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 +115,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={getProgressIndicatorClassName(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={getProgressIndicatorClassName(
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>
);
};

View File

@@ -132,6 +132,7 @@ export const FindingsFilters = ({
key: FilterType.SCAN,
labelCheckboxGroup: "Scan ID",
values: completedScanIds,
width: "wide" as const,
valueLabelMapping: scanDetails,
labelFormatter: (value: string) =>
getFindingsFilterDisplayValue(`filter[${FilterType.SCAN}]`, value, {

View File

@@ -270,6 +270,31 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
]);
});
it("should request muted findings only when explicitly enabled", async () => {
const resources = [makeResource()];
getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] });
adaptFindingsByResourceResponseMock.mockReturnValue([makeDrawerFinding()]);
const { result } = renderHook(() =>
useResourceDetailDrawer({
resources,
checkId: "s3_check",
includeMutedInOtherFindings: true,
}),
);
await act(async () => {
result.current.openDrawer(0);
await Promise.resolve();
});
expect(getLatestFindingsByResourceUidMock).toHaveBeenCalledWith({
resourceUid: "arn:aws:s3:::my-bucket",
includeMuted: true,
});
});
it("should keep isNavigating true for a cached resource long enough to render skeletons", async () => {
vi.useFakeTimers();

View File

@@ -47,6 +47,7 @@ interface UseResourceDetailDrawerOptions {
totalResourceCount?: number;
onRequestMoreResources?: () => void;
initialIndex?: number | null;
includeMutedInOtherFindings?: boolean;
}
interface UseResourceDetailDrawerReturn {
@@ -79,6 +80,7 @@ export function useResourceDetailDrawer({
totalResourceCount,
onRequestMoreResources,
initialIndex = null,
includeMutedInOtherFindings = false,
}: UseResourceDetailDrawerOptions): UseResourceDetailDrawerReturn {
const [isOpen, setIsOpen] = useState(initialIndex !== null);
const [isLoading, setIsLoading] = useState(false);
@@ -165,7 +167,10 @@ export function useResourceDetailDrawer({
setIsLoading(true);
try {
const response = await getLatestFindingsByResourceUid({ resourceUid });
const response = await getLatestFindingsByResourceUid({
resourceUid,
includeMuted: includeMutedInOtherFindings,
});
// Discard stale response if a newer request was started
if (controller.signal.aborted) return;

View File

@@ -125,7 +125,10 @@ export const ProvidersFilters = ({
placeholder={`All ${filter.labelCheckboxGroup}`}
/>
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectContent
search={false}
width={filter.width ?? "default"}
>
<MultiSelectSelectAll>Select All</MultiSelectSelectAll>
<MultiSelectSeparator />
{filter.values.map((value) => {

View File

@@ -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";

View File

@@ -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 };

View File

@@ -47,6 +47,33 @@ describe("MultiSelect", () => {
expect(
within(screen.getByRole("combobox")).getByText("Production AWS"),
).toBeInTheDocument();
expect(
within(screen.getByRole("combobox")).queryByText("Select accounts"),
).not.toBeInTheDocument();
});
it("keeps the filter label context when a value is selected", () => {
render(
<MultiSelect values={["FAIL"]} onValuesChange={() => {}}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="All Status" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectItem value="FAIL">FAIL</MultiSelectItem>
<MultiSelectItem value="PASS">PASS</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>,
);
expect(
within(screen.getByRole("combobox")).getByText("Status"),
).toBeInTheDocument();
expect(
within(screen.getByRole("combobox")).getByText("FAIL"),
).toBeInTheDocument();
expect(
within(screen.getByRole("combobox")).queryByText("All Status"),
).not.toBeInTheDocument();
});
it("filters items without crashing when search is enabled", async () => {

View File

@@ -163,6 +163,10 @@ export function MultiSelectValue({
const shouldWrap =
overflowBehavior === "wrap" ||
(overflowBehavior === "wrap-when-open" && open);
const selectedContextLabel =
placeholder && /^All\s+/i.test(placeholder) && selectedValues.size > 0
? placeholder.replace(/^All\s+/i, "").trim()
: "";
const checkOverflow = useCallback(() => {
if (valueRef.current === null) return;
@@ -222,11 +226,16 @@ export function MultiSelectValue({
className,
)}
>
{placeholder && (
{placeholder && selectedValues.size === 0 && (
<span className="text-bg-button-secondary shrink-0 font-normal">
{placeholder}
</span>
)}
{selectedContextLabel && (
<span className="text-bg-button-secondary shrink-0 font-normal">
{selectedContextLabel}
</span>
)}
{Array.from(selectedValues)
.filter((value) => items.has(value))
.map((value) => (

View File

@@ -62,8 +62,16 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
<span>{placeholder}</span>
),
MultiSelectContent: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
MultiSelectContent: ({
children,
width,
}: {
children: React.ReactNode;
width?: string;
}) => (
<div data-testid="multiselect-content" data-width={width ?? "default"}>
{children}
</div>
),
MultiSelectSelectAll: ({ children }: { children: React.ReactNode }) => (
<button type="button">{children}</button>
@@ -114,6 +122,13 @@ const severityFilter: FilterOption = {
values: ["critical", "high"],
};
const scanFilter: FilterOption = {
key: "filter[scan__in]",
labelCheckboxGroup: "Scan ID",
values: ["scan-1"],
width: "wide",
};
describe("DataTableFilterCustom — batch vs instant mode", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -275,4 +290,15 @@ describe("DataTableFilterCustom — batch vs instant mode", () => {
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
});
});
describe("dropdown width", () => {
it("should propagate the filter width to the dropdown content", () => {
render(<DataTableFilterCustom filters={[scanFilter]} />);
expect(screen.getByTestId("multiselect-content")).toHaveAttribute(
"data-width",
"wide",
);
});
});
});

View File

@@ -16,6 +16,7 @@ import {
import { EntityInfo } from "@/components/ui/entities/entity-info";
import { useUrlFilters } from "@/hooks/use-url-filters";
import { isConnectionStatus, isScanEntity } from "@/lib/helper-filters";
import { cn } from "@/lib/utils";
import {
FilterEntity,
FilterOption,
@@ -29,6 +30,8 @@ export interface DataTableFilterCustomProps {
filters: FilterOption[];
/** Optional element to render at the start of the filters grid */
prependElement?: React.ReactNode;
/** Optional className override for the filters grid layout */
gridClassName?: string;
/** Hide the clear filters button and active badges (useful when parent manages this) */
hideClearButton?: boolean;
/**
@@ -54,6 +57,7 @@ export interface DataTableFilterCustomProps {
export const DataTableFilterCustom = ({
filters,
prependElement,
gridClassName,
hideClearButton = false,
mode = DATA_TABLE_FILTER_MODE.INSTANT,
onBatchChange,
@@ -173,7 +177,12 @@ export const DataTableFilterCustom = ({
};
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
<div
className={cn(
"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5",
gridClassName,
)}
>
{prependElement}
{sortedFilters().map((filter) => {
const selectedValues = getSelectedValues(filter);
@@ -189,7 +198,10 @@ export const DataTableFilterCustom = ({
placeholder={`All ${filter.labelCheckboxGroup}`}
/>
</MultiSelectTrigger>
<MultiSelectContent search={false}>
<MultiSelectContent
search={false}
width={filter.width ?? "default"}
>
<MultiSelectSelectAll>Select All</MultiSelectSelectAll>
<MultiSelectSeparator />
{filter.values.map((value) => {

View File

@@ -0,0 +1,15 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("useFindingGroupResourceState", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "use-finding-group-resource-state.ts");
const source = readFileSync(filePath, "utf8");
it("enables muted findings only for the finding-group resource drawer", () => {
expect(source).toContain("includeMutedInOtherFindings: true");
});
});

View File

@@ -83,6 +83,7 @@ export function useFindingGroupResourceState({
checkId: group.checkId,
totalResourceCount: totalCount ?? group.resourcesTotal,
onRequestMoreResources: loadMore,
includeMutedInOtherFindings: true,
});
const handleDrawerMuteComplete = () => {

View File

@@ -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",

View File

@@ -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",
@@ -137,7 +138,7 @@
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "8.53.0",
"@typescript-eslint/parser": "8.53.0",
"@vitejs/plugin-react": "5.1.2",
"@vitejs/plugin-react": "5.2.0",
"@vitest/coverage-v8": "4.0.18",
"autoprefixer": "10.4.19",
"babel-plugin-react-compiler": "1.0.0",
@@ -167,6 +168,7 @@
},
"pnpm": {
"overrides": {
"semver": "7.6.3",
"@react-types/shared": "3.26.0",
"@internationalized/date": "3.10.0",
"alert>react": "19.2.5",

223
ui/pnpm-lock.yaml generated
View File

@@ -5,6 +5,7 @@ settings:
excludeLinksFromLockfile: false
overrides:
semver: 7.6.3
'@react-types/shared': 3.26.0
'@internationalized/date': 3.10.0
alert>react: 19.2.5
@@ -82,7 +83,7 @@ importers:
version: 1.2.3
'@next/third-parties':
specifier: 16.2.3
version: 16.2.3(next@16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)
version: 16.2.3(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)
'@radix-ui/react-alert-dialog':
specifier: 1.1.14
version: 1.1.14(@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)
@@ -110,6 +111,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)
@@ -157,7 +161,7 @@ importers:
version: 3.26.0(react@19.2.5)
'@sentry/nextjs':
specifier: 10.27.0
version: 10.27.0(@opentelemetry/context-async-hooks@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0))(next@16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(webpack@5.104.1)
version: 10.27.0(@opentelemetry/context-async-hooks@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0))(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(webpack@5.104.1)
'@tailwindcss/postcss':
specifier: 4.1.18
version: 4.1.18
@@ -235,13 +239,13 @@ importers:
version: 5.1.6
next:
specifier: 16.2.3
version: 16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
version: 16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
next-auth:
specifier: 5.0.0-beta.30
version: 5.0.0-beta.30(next@16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)
version: 5.0.0-beta.30(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)
next-themes:
specifier: 0.2.1
version: 0.2.1(next@16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
version: 0.2.1(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
radix-ui:
specifier: 1.4.2
version: 1.4.2(@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)
@@ -361,8 +365,8 @@ importers:
specifier: 8.53.0
version: 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.5.4)
'@vitejs/plugin-react':
specifier: 5.1.2
version: 5.1.2(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))
specifier: 5.2.0
version: 5.2.0(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))
'@vitest/coverage-v8':
specifier: 4.0.18
version: 4.0.18(@vitest/browser@4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)
@@ -757,6 +761,10 @@ packages:
resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==}
engines: {node: '>=6.9.0'}
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
'@babel/compat-data@7.28.6':
resolution: {integrity: sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==}
engines: {node: '>=6.9.0'}
@@ -765,10 +773,18 @@ packages:
resolution: {integrity: sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==}
engines: {node: '>=6.9.0'}
'@babel/core@7.29.0':
resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==}
engines: {node: '>=6.9.0'}
'@babel/generator@7.28.6':
resolution: {integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==}
engines: {node: '>=6.9.0'}
'@babel/generator@7.29.1':
resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==}
engines: {node: '>=6.9.0'}
'@babel/helper-annotate-as-pure@7.27.3':
resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==}
engines: {node: '>=6.9.0'}
@@ -899,6 +915,10 @@ packages:
resolution: {integrity: sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==}
engines: {node: '>=6.9.0'}
'@babel/traverse@7.29.0':
resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==}
engines: {node: '>=6.9.0'}
'@babel/types@7.28.6':
resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
engines: {node: '>=6.9.0'}
@@ -4106,8 +4126,8 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@rolldown/pluginutils@1.0.0-beta.53':
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
'@rolldown/pluginutils@1.0.0-rc.3':
resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==}
'@rollup/plugin-commonjs@28.0.1':
resolution: {integrity: sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==}
@@ -5213,11 +5233,11 @@ packages:
resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==}
engines: {node: '>= 20'}
'@vitejs/plugin-react@5.1.2':
resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==}
'@vitejs/plugin-react@5.2.0':
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
'@vitest/browser-playwright@4.0.18':
resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==}
@@ -8524,12 +8544,8 @@ packages:
scroll-into-view-if-needed@3.0.10:
resolution: {integrity: sha512-t44QCeDKAPf1mtQH3fYpWz8IM/DyvHLjs8wUvvwMYxk5moOqCzrMSxK6HQVD0QVmVjXFavoFIPRVrMuJPKAvtg==}
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
semver@7.7.3:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
semver@7.6.3:
resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==}
engines: {node: '>=10'}
hasBin: true
@@ -10344,6 +10360,12 @@ snapshots:
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/compat-data@7.28.6': {}
'@babel/core@7.28.6':
@@ -10362,21 +10384,49 @@ snapshots:
debug: 4.4.3
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
semver: 7.6.3
transitivePeerDependencies:
- supports-color
'@babel/core@7.29.0':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
'@babel/helper-compilation-targets': 7.28.6
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
'@babel/helpers': 7.28.6
'@babel/parser': 7.29.0
'@babel/template': 7.28.6
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 7.6.3
transitivePeerDependencies:
- supports-color
'@babel/generator@7.28.6':
dependencies:
'@babel/parser': 7.28.6
'@babel/types': 7.28.6
'@babel/parser': 7.29.0
'@babel/types': 7.29.0
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
'@babel/generator@7.29.1':
dependencies:
'@babel/parser': 7.29.0
'@babel/types': 7.29.0
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
'@babel/helper-annotate-as-pure@7.27.3':
dependencies:
'@babel/types': 7.28.6
'@babel/types': 7.29.0
'@babel/helper-compilation-targets@7.28.6':
dependencies:
@@ -10384,7 +10434,7 @@ snapshots:
'@babel/helper-validator-option': 7.27.1
browserslist: 4.28.1
lru-cache: 5.1.1
semver: 6.3.1
semver: 7.6.3
'@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.28.6)':
dependencies:
@@ -10395,7 +10445,7 @@ snapshots:
'@babel/helper-replace-supers': 7.28.6(@babel/core@7.28.6)
'@babel/helper-skip-transparent-expression-wrappers': 7.27.1
'@babel/traverse': 7.28.6
semver: 6.3.1
semver: 7.6.3
transitivePeerDependencies:
- supports-color
@@ -10404,14 +10454,14 @@ snapshots:
'@babel/helper-member-expression-to-functions@7.28.5':
dependencies:
'@babel/traverse': 7.28.6
'@babel/types': 7.28.6
'@babel/types': 7.29.0
transitivePeerDependencies:
- supports-color
'@babel/helper-module-imports@7.28.6':
dependencies:
'@babel/traverse': 7.28.6
'@babel/types': 7.28.6
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
transitivePeerDependencies:
- supports-color
@@ -10420,13 +10470,22 @@ snapshots:
'@babel/core': 7.28.6
'@babel/helper-module-imports': 7.28.6
'@babel/helper-validator-identifier': 7.28.5
'@babel/traverse': 7.28.6
'@babel/traverse': 7.29.0
transitivePeerDependencies:
- supports-color
'@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-module-imports': 7.28.6
'@babel/helper-validator-identifier': 7.28.5
'@babel/traverse': 7.29.0
transitivePeerDependencies:
- supports-color
'@babel/helper-optimise-call-expression@7.27.1':
dependencies:
'@babel/types': 7.28.6
'@babel/types': 7.29.0
'@babel/helper-plugin-utils@7.28.6': {}
@@ -10442,7 +10501,7 @@ snapshots:
'@babel/helper-skip-transparent-expression-wrappers@7.27.1':
dependencies:
'@babel/traverse': 7.28.6
'@babel/types': 7.28.6
'@babel/types': 7.29.0
transitivePeerDependencies:
- supports-color
@@ -10455,7 +10514,7 @@ snapshots:
'@babel/helpers@7.28.6':
dependencies:
'@babel/template': 7.28.6
'@babel/types': 7.28.6
'@babel/types': 7.29.0
'@babel/parser@7.28.6':
dependencies:
@@ -10483,14 +10542,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.6)':
'@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.28.6
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.6)':
'@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.28.6
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
'@babel/plugin-transform-typescript@7.28.6(@babel/core@7.28.6)':
@@ -10519,18 +10578,30 @@ snapshots:
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.28.6
'@babel/parser': 7.28.6
'@babel/types': 7.28.6
'@babel/code-frame': 7.29.0
'@babel/parser': 7.29.0
'@babel/types': 7.29.0
'@babel/traverse@7.28.6':
dependencies:
'@babel/code-frame': 7.28.6
'@babel/generator': 7.28.6
'@babel/helper-globals': 7.28.0
'@babel/parser': 7.28.6
'@babel/parser': 7.29.0
'@babel/template': 7.28.6
'@babel/types': 7.28.6
'@babel/types': 7.29.0
debug: 4.4.3
transitivePeerDependencies:
- supports-color
'@babel/traverse@7.29.0':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
'@babel/helper-globals': 7.28.0
'@babel/parser': 7.29.0
'@babel/template': 7.28.6
'@babel/types': 7.29.0
debug: 4.4.3
transitivePeerDependencies:
- supports-color
@@ -12350,9 +12421,9 @@ snapshots:
'@next/swc-win32-x64-msvc@16.2.3':
optional: true
'@next/third-parties@16.2.3(next@16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)':
'@next/third-parties@16.2.3(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)':
dependencies:
next: 16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
next: 16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react: 19.2.5
third-party-capital: 1.0.20
@@ -14693,7 +14764,7 @@ snapshots:
'@react-types/shared': 3.26.0(react@19.2.5)
react: 19.2.5
'@rolldown/pluginutils@1.0.0-beta.53': {}
'@rolldown/pluginutils@1.0.0-rc.3': {}
'@rollup/plugin-commonjs@28.0.1(rollup@4.59.0)':
dependencies:
@@ -14882,7 +14953,7 @@ snapshots:
'@sentry/core@10.27.0': {}
'@sentry/nextjs@10.27.0(@opentelemetry/context-async-hooks@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0))(next@16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(webpack@5.104.1)':
'@sentry/nextjs@10.27.0(@opentelemetry/context-async-hooks@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0))(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(webpack@5.104.1)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.39.0
@@ -14895,7 +14966,7 @@ snapshots:
'@sentry/react': 10.27.0(react@19.2.5)
'@sentry/vercel-edge': 10.27.0
'@sentry/webpack-plugin': 4.7.0(webpack@5.104.1)
next: 16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
next: 16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
resolve: 1.22.8
rollup: 4.59.0
stacktrace-parser: 0.1.11
@@ -15524,7 +15595,7 @@ snapshots:
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.28.6
'@babel/code-frame': 7.29.0
'@babel/runtime': 7.28.6
'@types/aria-query': 5.0.4
aria-query: 5.3.0
@@ -15571,24 +15642,24 @@ snapshots:
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.28.6
'@babel/types': 7.28.6
'@babel/parser': 7.29.0
'@babel/types': 7.29.0
'@types/babel__generator': 7.27.0
'@types/babel__template': 7.4.4
'@types/babel__traverse': 7.28.0
'@types/babel__generator@7.27.0':
dependencies:
'@babel/types': 7.28.6
'@babel/types': 7.29.0
'@types/babel__template@7.4.4':
dependencies:
'@babel/parser': 7.28.6
'@babel/types': 7.28.6
'@babel/parser': 7.29.0
'@babel/types': 7.29.0
'@types/babel__traverse@7.28.0':
dependencies:
'@babel/types': 7.28.6
'@babel/types': 7.29.0
'@types/chai@5.2.3':
dependencies:
@@ -15880,7 +15951,7 @@ snapshots:
'@typescript-eslint/visitor-keys': 8.53.0
debug: 4.4.3
minimatch: 9.0.7
semver: 7.7.3
semver: 7.6.3
tinyglobby: 0.2.15
ts-api-utils: 2.4.0(typescript@5.5.4)
typescript: 5.5.4
@@ -15993,12 +16064,12 @@ snapshots:
'@vercel/oidc@3.0.5': {}
'@vitejs/plugin-react@5.1.2(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))':
'@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))':
dependencies:
'@babel/core': 7.28.6
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6)
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.6)
'@rolldown/pluginutils': 1.0.0-beta.53
'@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0)
'@rolldown/pluginutils': 1.0.0-rc.3
'@types/babel__core': 7.20.5
react-refresh: 0.18.0
vite: 7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)
@@ -17242,7 +17313,7 @@ snapshots:
object.fromentries: 2.0.8
object.groupby: 1.0.3
object.values: 1.2.1
semver: 6.3.1
semver: 7.6.3
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
@@ -17279,7 +17350,7 @@ snapshots:
ignore: 5.3.2
minimatch: 3.1.4
resolve: 1.22.11
semver: 6.3.1
semver: 7.6.3
eslint-plugin-prettier@5.5.1(@types/eslint@9.6.1)(eslint-config-prettier@10.1.5(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.6.2):
dependencies:
@@ -17320,7 +17391,7 @@ snapshots:
object.values: 1.2.1
prop-types: 15.8.1
resolve: 2.0.0-next.5
semver: 6.3.1
semver: 7.6.3
string.prototype.matchall: 4.0.12
string.prototype.repeat: 1.0.0
@@ -18048,7 +18119,7 @@ snapshots:
is-bun-module@2.0.0:
dependencies:
semver: 7.7.3
semver: 7.6.3
is-callable@1.2.7: {}
@@ -18349,7 +18420,7 @@ snapshots:
chalk: 4.1.2
console-table-printer: 2.15.0
p-queue: 6.6.2
semver: 7.7.3
semver: 7.6.3
uuid: 10.0.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
@@ -18483,7 +18554,7 @@ snapshots:
make-dir@4.0.0:
dependencies:
semver: 7.7.3
semver: 7.6.3
markdown-table@3.0.4: {}
@@ -19024,19 +19095,19 @@ snapshots:
neo-async@2.6.2: {}
next-auth@5.0.0-beta.30(next@16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5):
next-auth@5.0.0-beta.30(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5):
dependencies:
'@auth/core': 0.41.0
next: 16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
next: 16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react: 19.2.5
next-themes@0.2.1(next@16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
next-themes@0.2.1(next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
next: 16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
next: 16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
next@16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
'@next/env': 16.2.3
'@swc/helpers': 0.5.15
@@ -19045,7 +19116,7 @@ snapshots:
postcss: 8.4.31
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
styled-jsx: 5.1.6(@babel/core@7.28.6)(react@19.2.5)
styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.5)
optionalDependencies:
'@next/swc-darwin-arm64': 16.2.3
'@next/swc-darwin-x64': 16.2.3
@@ -19910,9 +19981,7 @@ snapshots:
dependencies:
compute-scroll-into-view: 3.1.1
semver@6.3.1: {}
semver@7.7.3: {}
semver@7.6.3: {}
send@1.2.1:
dependencies:
@@ -20015,7 +20084,7 @@ snapshots:
dependencies:
color: 4.2.3
detect-libc: 2.1.2
semver: 7.7.3
semver: 7.6.3
optionalDependencies:
'@img/sharp-darwin-arm64': 0.33.5
'@img/sharp-darwin-x64': 0.33.5
@@ -20041,7 +20110,7 @@ snapshots:
dependencies:
'@img/colour': 1.0.0
detect-libc: 2.1.2
semver: 7.7.3
semver: 7.6.3
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
@@ -20309,12 +20378,12 @@ snapshots:
dependencies:
inline-style-parser: 0.2.7
styled-jsx@5.1.6(@babel/core@7.28.6)(react@19.2.5):
styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.5):
dependencies:
client-only: 0.0.1
react: 19.2.5
optionalDependencies:
'@babel/core': 7.28.6
'@babel/core': 7.29.0
stylis@4.3.6: {}

View File

@@ -15,6 +15,7 @@ export interface FilterOption {
key: string;
labelCheckboxGroup: string;
values: string[];
width?: "default" | "wide";
valueLabelMapping?: Array<{ [uid: string]: FilterEntity }>;
labelFormatter?: (value: string) => string;
index?: number;