mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-31 15:54:24 +00:00
feat(ui): add provider filtering to the compliance overview
- Mount provider type/account and provider group selectors in the compliance filter bar; selecting a scan or provider filter clears the other (XOR) - Aggregate frameworks across the latest scan per matching provider; hide the ThreatScore badge while provider filters are active - Hide the per-scan CIS PDF and carry provider filters into the card drill-down in aggregated mode Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
9b4d5b01ed
commit
fd0c875065
7 files changed
+320
-57
No files matched your search
@@ -27,4 +27,21 @@ describe("ComplianceCard", () => {
|
||||
expect(source).toContain('orientation="column"');
|
||||
expect(source).toContain('buttonWidth="icon"');
|
||||
});
|
||||
|
||||
it("derives aggregated mode from the provider filters in the URL", () => {
|
||||
expect(source).toContain("extractComplianceProviderFilters");
|
||||
expect(source).toContain("const isAggregated =");
|
||||
});
|
||||
|
||||
it("hides the per-scan PDF download in aggregated mode", () => {
|
||||
expect(source).toContain("{!isAggregated && (");
|
||||
expect(source).toContain("<ComplianceDownloadContainer");
|
||||
});
|
||||
|
||||
it("carries provider filters (not scanId) into the drill-down when aggregated", () => {
|
||||
expect(source).toContain(
|
||||
"providerFilters: isAggregated ? providerFilters : undefined",
|
||||
);
|
||||
expect(source).toContain("scanId: isAggregated ? null : scanId");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { buildComplianceDetailPath } from "@/lib/compliance/compliance-detail-url";
|
||||
import { extractComplianceProviderFilters } from "@/lib/compliance/compliance-provider-filters";
|
||||
import { getReportTypeForCompliance } from "@/lib/compliance/compliance-report-types";
|
||||
import {
|
||||
getScoreIndicatorClass,
|
||||
@@ -55,6 +56,13 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
const router = useRouter();
|
||||
const hasRegionFilter = searchParams.has("filter[region__in]");
|
||||
|
||||
// Aggregated mode: provider filters replace the single-scan scope, so per-scan
|
||||
// affordances (CIS PDF) are hidden and the drill-down carries provider filters.
|
||||
const providerFilters = extractComplianceProviderFilters(
|
||||
new URLSearchParams(searchParams.toString()),
|
||||
);
|
||||
const isAggregated = Object.keys(providerFilters).length > 0;
|
||||
|
||||
const formatTitle = (title: string) => {
|
||||
return title.split("-").join(" ");
|
||||
};
|
||||
@@ -75,8 +83,9 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
title,
|
||||
complianceId: id,
|
||||
version,
|
||||
scanId,
|
||||
scanId: isAggregated ? null : scanId,
|
||||
regionFilter: searchParams.get("filter[region__in]"),
|
||||
providerFilters: isAggregated ? providerFilters : undefined,
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -88,32 +97,34 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
className="relative cursor-pointer transition-shadow hover:shadow-md"
|
||||
onClick={navigateToDetail}
|
||||
>
|
||||
<div
|
||||
className="absolute top-2 right-2 z-10"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
role="group"
|
||||
tabIndex={0}
|
||||
>
|
||||
<ComplianceDownloadContainer
|
||||
compact
|
||||
orientation="column"
|
||||
buttonWidth="icon"
|
||||
presentation="dropdown"
|
||||
scanId={scanId}
|
||||
complianceId={complianceId}
|
||||
reportType={getReportTypeForCompliance(
|
||||
title,
|
||||
complianceId,
|
||||
isLatestCisForProvider,
|
||||
)}
|
||||
disabled={hasRegionFilter}
|
||||
/>
|
||||
</div>
|
||||
{!isAggregated && (
|
||||
<div
|
||||
className="absolute top-2 right-2 z-10"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
role="group"
|
||||
tabIndex={0}
|
||||
>
|
||||
<ComplianceDownloadContainer
|
||||
compact
|
||||
orientation="column"
|
||||
buttonWidth="icon"
|
||||
presentation="dropdown"
|
||||
scanId={scanId}
|
||||
complianceId={complianceId}
|
||||
reportType={getReportTypeForCompliance(
|
||||
title,
|
||||
complianceId,
|
||||
isLatestCisForProvider,
|
||||
)}
|
||||
disabled={hasRegionFilter}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CardContent className="p-0">
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex items-center gap-3 pr-9">
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ComplianceFilters } from "./compliance-filters";
|
||||
|
||||
const { pushMock, updateFilterMock } = vi.hoisted(() => ({
|
||||
pushMock: vi.fn(),
|
||||
updateFilterMock: vi.fn(),
|
||||
}));
|
||||
|
||||
let currentSearchParams = new URLSearchParams();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
useSearchParams: () => currentSearchParams,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-url-filters", () => ({
|
||||
useUrlFilters: () => ({ updateFilter: updateFilterMock }),
|
||||
}));
|
||||
|
||||
vi.mock("./scan-selector", () => ({
|
||||
ScanSelector: ({
|
||||
onSelectionChange,
|
||||
}: {
|
||||
onSelectionChange: (key: string) => void;
|
||||
}) => (
|
||||
<button
|
||||
data-testid="scan-selector"
|
||||
onClick={() => onSelectionChange("scan-2")}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/filters/provider-account-selectors", () => ({
|
||||
ProviderAccountSelectors: ({
|
||||
paramsToDeleteOnChange,
|
||||
}: {
|
||||
paramsToDeleteOnChange?: string[];
|
||||
}) => (
|
||||
<div
|
||||
data-testid="provider-account-selectors"
|
||||
data-params={(paramsToDeleteOnChange ?? []).join(",")}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/app/(prowler)/_overview/_components/provider-group-selector",
|
||||
() => ({
|
||||
ProviderGroupSelector: ({
|
||||
paramsToDeleteOnChange,
|
||||
}: {
|
||||
paramsToDeleteOnChange?: string[];
|
||||
}) => (
|
||||
<div
|
||||
data-testid="provider-group-selector"
|
||||
data-params={(paramsToDeleteOnChange ?? []).join(",")}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/components/filters/clear-filters-button", () => ({
|
||||
ClearFiltersButton: () => <div data-testid="clear-filters" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/select/multiselect", () => ({
|
||||
MultiSelect: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="region-multiselect">{children}</div>
|
||||
),
|
||||
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
|
||||
<span>{placeholder}</span>
|
||||
),
|
||||
MultiSelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
MultiSelectItem: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
MultiSelectSelectAll: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
MultiSelectSeparator: () => <hr />,
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
scans: [],
|
||||
uniqueRegions: ["eu-west-1"],
|
||||
selectedScanId: "scan-1",
|
||||
providers: [],
|
||||
providerGroups: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
currentSearchParams = new URLSearchParams();
|
||||
});
|
||||
|
||||
describe("ComplianceFilters", () => {
|
||||
it("renders the scan, provider type/account, provider group and region selectors", () => {
|
||||
render(<ComplianceFilters {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("scan-selector")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId("provider-account-selectors"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("provider-group-selector")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("region-multiselect")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("wires the provider selectors to clear scanId + page on change (reverse XOR)", () => {
|
||||
render(<ComplianceFilters {...defaultProps} />);
|
||||
|
||||
for (const testId of [
|
||||
"provider-account-selectors",
|
||||
"provider-group-selector",
|
||||
]) {
|
||||
const params = screen.getByTestId(testId).getAttribute("data-params");
|
||||
expect(params).toContain("scanId");
|
||||
expect(params).toContain("page");
|
||||
}
|
||||
});
|
||||
|
||||
it("clears provider-scope filters and page when a scan is selected", () => {
|
||||
currentSearchParams = new URLSearchParams(
|
||||
"scanId=scan-1&filter[provider_type__in]=aws&filter[provider_id__in]=p1&filter[provider_groups__in]=g1&filter[region__in]=eu-west-1&page=2",
|
||||
);
|
||||
|
||||
render(<ComplianceFilters {...defaultProps} />);
|
||||
fireEvent.click(screen.getByTestId("scan-selector"));
|
||||
|
||||
expect(pushMock).toHaveBeenCalledTimes(1);
|
||||
const pushedUrl = new URL(
|
||||
pushMock.mock.calls[0][0] as string,
|
||||
"https://example.com",
|
||||
);
|
||||
const params = pushedUrl.searchParams;
|
||||
|
||||
expect(params.get("scanId")).toBe("scan-2");
|
||||
expect(params.get("filter[provider_type__in]")).toBeNull();
|
||||
expect(params.get("filter[provider_id__in]")).toBeNull();
|
||||
expect(params.get("filter[provider_groups__in]")).toBeNull();
|
||||
expect(params.get("page")).toBeNull();
|
||||
// region is independent of the scan/provider XOR and must survive
|
||||
expect(params.get("filter[region__in]")).toBe("eu-west-1");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { ProviderGroupSelector } from "@/app/(prowler)/_overview/_components/provider-group-selector";
|
||||
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
|
||||
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
|
||||
import {
|
||||
MultiSelect,
|
||||
MultiSelectContent,
|
||||
@@ -13,27 +15,43 @@ import {
|
||||
MultiSelectValue,
|
||||
} from "@/components/shadcn/select/multiselect";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import { COMPLIANCE_PROVIDER_FILTER_KEYS } from "@/lib/compliance/compliance-provider-filters";
|
||||
import type { ProviderGroup } from "@/types/components";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
import { ScanSelector, SelectScanComplianceDataProps } from "./scan-selector";
|
||||
|
||||
// Clearing scanId/page is the inverse of the provider selectors'
|
||||
// paramsToDeleteOnChange — together they enforce the backend scan_id ⊕ provider XOR.
|
||||
const PROVIDER_PARAMS_TO_DELETE = ["scanId", "page"];
|
||||
const SELECTOR_WIDTH = "w-full sm:max-w-[280px] sm:min-w-[200px] sm:flex-1";
|
||||
|
||||
interface ComplianceFiltersProps {
|
||||
scans: SelectScanComplianceDataProps["scans"];
|
||||
uniqueRegions: string[];
|
||||
selectedScanId: string;
|
||||
/** Null in aggregated mode (provider filters drive the scope, no single scan). */
|
||||
selectedScanId: string | null;
|
||||
providers: ProviderProps[];
|
||||
providerGroups: ProviderGroup[];
|
||||
}
|
||||
|
||||
export const ComplianceFilters = ({
|
||||
scans,
|
||||
uniqueRegions,
|
||||
selectedScanId,
|
||||
providers,
|
||||
providerGroups,
|
||||
}: ComplianceFiltersProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { updateFilter } = useUrlFilters();
|
||||
|
||||
// XOR: choosing a single scan clears any active provider-scope filters.
|
||||
const handleScanChange = (selectedKey: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("scanId", selectedKey);
|
||||
COMPLIANCE_PROVIDER_FILTER_KEYS.forEach((key) => params.delete(key));
|
||||
params.delete("page");
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
@@ -41,16 +59,31 @@ export const ComplianceFilters = ({
|
||||
searchParams.get("filter[region__in]")?.split(",").filter(Boolean) ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex max-w-4xl flex-wrap items-center gap-4">
|
||||
<div className="flex 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}
|
||||
selectedScanId={selectedScanId ?? ""}
|
||||
onSelectionChange={handleScanChange}
|
||||
/>
|
||||
</div>
|
||||
{/* Provider-scope filters: selecting any switches to aggregated mode and clears scanId. */}
|
||||
<ProviderAccountSelectors
|
||||
providers={providers}
|
||||
accountFilterKey="provider_id__in"
|
||||
accountValue="id"
|
||||
paramsToDeleteOnChange={PROVIDER_PARAMS_TO_DELETE}
|
||||
providerSelectorClassName={SELECTOR_WIDTH}
|
||||
accountSelectorClassName={SELECTOR_WIDTH}
|
||||
/>
|
||||
<div className={SELECTOR_WIDTH}>
|
||||
<ProviderGroupSelector
|
||||
groups={providerGroups}
|
||||
paramsToDeleteOnChange={PROVIDER_PARAMS_TO_DELETE}
|
||||
/>
|
||||
</div>
|
||||
{uniqueRegions.length > 0 && (
|
||||
<div className="w-full sm:max-w-[280px] sm:min-w-[200px] sm:flex-1">
|
||||
<div className={SELECTOR_WIDTH}>
|
||||
<MultiSelect
|
||||
values={regionValues}
|
||||
onValuesChange={(values) => updateFilter("region__in", values)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ComplianceCard } from "@/components/compliance/compliance-card";
|
||||
import { OnboardingTrigger, PageReady } from "@/components/onboarding";
|
||||
import { DataTableSearch } from "@/components/ui/table/data-table-search";
|
||||
import { buildComplianceDetailPath } from "@/lib/compliance/compliance-detail-url";
|
||||
import { extractComplianceProviderFilters } from "@/lib/compliance/compliance-provider-filters";
|
||||
import { getFlowById } from "@/lib/onboarding";
|
||||
import { createViewComplianceTourStepHandlers } from "@/lib/tours/view-compliance.tour";
|
||||
import type { ComplianceOverviewData } from "@/types/compliance";
|
||||
@@ -43,6 +44,12 @@ export const ComplianceOverviewGrid = ({
|
||||
const searchParams = useSearchParams();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// Aggregated mode: provider filters in the URL replace the single-scan scope.
|
||||
const providerFilters = extractComplianceProviderFilters(
|
||||
new URLSearchParams(searchParams.toString()),
|
||||
);
|
||||
const isAggregated = Object.keys(providerFilters).length > 0;
|
||||
|
||||
const filteredFrameworks = frameworks.filter((compliance) =>
|
||||
compliance.attributes.framework
|
||||
.toLowerCase()
|
||||
@@ -62,8 +69,9 @@ export const ComplianceOverviewGrid = ({
|
||||
title: first.attributes.framework,
|
||||
complianceId: first.id,
|
||||
version: first.attributes.version,
|
||||
scanId,
|
||||
scanId: isAggregated ? null : scanId,
|
||||
regionFilter: searchParams.get("filter[region__in]"),
|
||||
providerFilters: isAggregated ? providerFilters : undefined,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user