mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-04-16 17:47:47 +00:00
Compare commits
14 Commits
master
...
refactor/i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec4d4f4095 | ||
|
|
ba08476631 | ||
|
|
b959584fb6 | ||
|
|
1b312fbd6b | ||
|
|
c85b51132f | ||
|
|
35f85ec053 | ||
|
|
fe89002ee9 | ||
|
|
090a035150 | ||
|
|
a511b70b08 | ||
|
|
ca6d5a5f47 | ||
|
|
e10d01ce77 | ||
|
|
be42b84daf | ||
|
|
97cc324393 | ||
|
|
eef02f25b8 |
@@ -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
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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),
|
||||
|
||||
16
ui/app/(prowler)/compliance/page.test.tsx
Normal file
16
ui/app/(prowler)/compliance/page.test.tsx
Normal 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]");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
30
ui/components/compliance/compliance-card.test.tsx
Normal file
30
ui/components/compliance/compliance-card.test.tsx
Normal 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"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
133
ui/components/compliance/compliance-download-container.test.tsx
Normal file
133
ui/components/compliance/compliance-download-container.test.tsx
Normal 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",
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
|
||||
70
ui/components/compliance/compliance-overview-grid.tsx
Normal file
70
ui/components/compliance/compliance-overview-grid.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
32
ui/components/compliance/threatscore-badge.test.tsx
Normal file
32
ui/components/compliance/threatscore-badge.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
42
ui/components/shadcn/progress.tsx
Normal file
42
ui/components/shadcn/progress.tsx
Normal 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 };
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
15
ui/hooks/use-finding-group-resource-state.test.ts
Normal file
15
ui/hooks/use-finding-group-resource-state.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -83,6 +83,7 @@ export function useFindingGroupResourceState({
|
||||
checkId: group.checkId,
|
||||
totalResourceCount: totalCount ?? group.resourcesTotal,
|
||||
onRequestMoreResources: loadMore,
|
||||
includeMutedInOtherFindings: true,
|
||||
});
|
||||
|
||||
const handleDrawerMuteComplete = () => {
|
||||
|
||||
@@ -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",
|
||||
@@ -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
223
ui/pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user