mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 17:40:25 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
542a6e458b | ||
|
|
0a2abbe4a9 | ||
|
|
1a60dc478a | ||
|
|
c3a09cdb14 | ||
|
|
fd0c875065 | ||
|
|
9b4d5b01ed | ||
|
|
eab9eebacf | ||
|
|
7fd281cf02 | ||
|
|
d90e4449f9 | ||
|
|
f08d7c9f15 | ||
|
|
170507a35b | ||
|
|
9d6ea860b9 | ||
|
|
5440733d50 | ||
|
|
4649032ec9 | ||
|
|
1bc9fc2782 | ||
|
|
11bab48003 | ||
|
|
fa904f84ee | ||
|
|
c5f86da879 |
@@ -7,6 +7,8 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
### 🚀 Added
|
||||
|
||||
- Controlled `402` and `403` Server Action error messages for alert seed and mutation flows [(#11629)](https://github.com/prowler-cloud/prowler/pull/11629)
|
||||
- Filter the Overview, Findings, Resources, Scans, and Providers views by provider group [(#11659)](https://github.com/prowler-cloud/prowler/pull/11659)
|
||||
- Filter the Compliance overview and detail pages by provider [(#11668)](https://github.com/prowler-cloud/prowler/pull/11668)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted(
|
||||
() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
handleApiResponseMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
getComplianceOverviewMetadataInfo,
|
||||
getComplianceRequirements,
|
||||
getCompliancesOverview,
|
||||
} from "./compliances";
|
||||
|
||||
const calledUrl = () => new URL(fetchMock.mock.calls[0][0] as string);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
handleApiResponseMock.mockResolvedValue({ data: [] });
|
||||
});
|
||||
|
||||
describe("getCompliancesOverview", () => {
|
||||
it("sends scan_id and region in scan mode", async () => {
|
||||
await getCompliancesOverview({ scanId: "scan-1", region: "eu-west-1" });
|
||||
|
||||
const url = calledUrl();
|
||||
expect(url.searchParams.get("filter[scan_id]")).toBe("scan-1");
|
||||
expect(url.searchParams.get("filter[region__in]")).toBe("eu-west-1");
|
||||
});
|
||||
|
||||
it("forwards provider filters and omits scan_id in aggregated mode", async () => {
|
||||
await getCompliancesOverview({
|
||||
scanId: "scan-1",
|
||||
filters: { "filter[provider_type__in]": "aws,gcp" },
|
||||
});
|
||||
|
||||
const url = calledUrl();
|
||||
expect(url.searchParams.get("filter[provider_type__in]")).toBe("aws,gcp");
|
||||
// XOR: provider filters present -> never send scan_id (avoids backend 400)
|
||||
expect(url.searchParams.get("filter[scan_id]")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getComplianceOverviewMetadataInfo", () => {
|
||||
it("forwards provider filters", async () => {
|
||||
await getComplianceOverviewMetadataInfo({
|
||||
filters: { "filter[provider_groups__in]": "g1,g2" },
|
||||
});
|
||||
|
||||
expect(calledUrl().searchParams.get("filter[provider_groups__in]")).toBe(
|
||||
"g1,g2",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getComplianceRequirements", () => {
|
||||
it("appends compliance_id and scan_id in scan mode", async () => {
|
||||
await getComplianceRequirements({
|
||||
complianceId: "cis_2.0_aws",
|
||||
scanId: "scan-1",
|
||||
});
|
||||
|
||||
const url = calledUrl();
|
||||
expect(url.searchParams.get("filter[compliance_id]")).toBe("cis_2.0_aws");
|
||||
expect(url.searchParams.get("filter[scan_id]")).toBe("scan-1");
|
||||
});
|
||||
|
||||
it("forwards provider filters and omits scan_id in aggregated mode", async () => {
|
||||
await getComplianceRequirements({
|
||||
complianceId: "cis_2.0_aws",
|
||||
scanId: "scan-1",
|
||||
filters: { "filter[provider_id__in]": "p1,p2" },
|
||||
});
|
||||
|
||||
const url = calledUrl();
|
||||
expect(url.searchParams.get("filter[compliance_id]")).toBe("cis_2.0_aws");
|
||||
expect(url.searchParams.get("filter[provider_id__in]")).toBe("p1,p2");
|
||||
expect(url.searchParams.get("filter[scan_id]")).toBeNull();
|
||||
});
|
||||
|
||||
it("omits scan_id when no scan is provided", async () => {
|
||||
await getComplianceRequirements({ complianceId: "cis_2.0_aws" });
|
||||
|
||||
expect(calledUrl().searchParams.get("filter[scan_id]")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import {
|
||||
type ComplianceFilters,
|
||||
type ComplianceProviderFilters,
|
||||
hasComplianceProviderFilters,
|
||||
} from "@/lib/compliance/compliance-provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
export const getCompliancesOverview = async ({
|
||||
@@ -10,7 +15,7 @@ export const getCompliancesOverview = async ({
|
||||
}: {
|
||||
scanId?: string;
|
||||
region?: string | string[];
|
||||
filters?: Record<string, string | string[] | undefined>;
|
||||
filters?: ComplianceProviderFilters;
|
||||
} = {}) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
@@ -27,7 +32,10 @@ export const getCompliancesOverview = async ({
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => setParam(key, value));
|
||||
|
||||
setParam("filter[scan_id]", scanId);
|
||||
// XOR: the backend rejects filter[scan_id] together with provider filters.
|
||||
if (!hasComplianceProviderFilters(filters)) {
|
||||
setParam("filter[scan_id]", scanId);
|
||||
}
|
||||
setParam("filter[region__in]", region);
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
@@ -46,7 +54,7 @@ export const getComplianceOverviewMetadataInfo = async ({
|
||||
filters = {},
|
||||
}: {
|
||||
sort?: string;
|
||||
filters?: Record<string, string | string[] | undefined>;
|
||||
filters?: ComplianceFilters;
|
||||
} = {}) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
@@ -111,22 +119,31 @@ export const getComplianceRequirements = async ({
|
||||
complianceId,
|
||||
scanId,
|
||||
region,
|
||||
filters = {},
|
||||
}: {
|
||||
complianceId: string;
|
||||
scanId: string;
|
||||
scanId?: string;
|
||||
region?: string | string[];
|
||||
filters?: ComplianceProviderFilters;
|
||||
}) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
try {
|
||||
const url = new URL(`${apiBaseUrl}/compliance-overviews/requirements`);
|
||||
url.searchParams.append("filter[compliance_id]", complianceId);
|
||||
url.searchParams.append("filter[scan_id]", scanId);
|
||||
|
||||
// Forward provider-scope filters (aggregated mode); XOR with scan_id.
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value && value.trim() !== "") url.searchParams.append(key, value);
|
||||
});
|
||||
|
||||
if (scanId && !hasComplianceProviderFilters(filters)) {
|
||||
url.searchParams.append("filter[scan_id]", scanId);
|
||||
}
|
||||
|
||||
if (region) {
|
||||
const regionValue = Array.isArray(region) ? region.join(",") : region;
|
||||
url.searchParams.append("filter[region__in]", regionValue);
|
||||
//remove page param
|
||||
}
|
||||
url.searchParams.delete("page");
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import type { FindingsFilterParam } from "@/actions/findings/findings-filters";
|
||||
import {
|
||||
apiBaseUrl,
|
||||
composeSort,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
} from "@/lib";
|
||||
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { FilterParam } from "@/types/filters";
|
||||
|
||||
/**
|
||||
* Maps filter[search] to filter[check_title__icontains] for finding-groups.
|
||||
@@ -39,7 +39,7 @@ function mapSearchFilter(
|
||||
* finding-group resources sub-endpoint. These must be stripped before
|
||||
* calling the resources API to avoid empty results.
|
||||
*/
|
||||
const FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS: FilterParam[] = [
|
||||
const FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS: FindingsFilterParam[] = [
|
||||
"filter[service__in]",
|
||||
"filter[scan__in]",
|
||||
"filter[scan_id]",
|
||||
@@ -53,7 +53,7 @@ function normalizeFindingGroupResourceFilters(
|
||||
Object.entries(filters).filter(
|
||||
([key]) =>
|
||||
!FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS.includes(
|
||||
key as FilterParam,
|
||||
key as FindingsFilterParam,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { FILTER_FIELD, FilterParam } from "@/types/filters";
|
||||
|
||||
/**
|
||||
* URL filter param keys the findings view supports, e.g. `filter[severity__in]`.
|
||||
* Composed from the shared fields it uses plus a few findings-only extras
|
||||
* (alternate scan/date/delta forms not used by other views).
|
||||
*/
|
||||
export type FindingsFilterParam = FilterParam<
|
||||
// findings uses provider_id, not provider_uid
|
||||
| (typeof FILTER_FIELD)[
|
||||
| "PROVIDER_TYPE"
|
||||
| "PROVIDER_ID"
|
||||
| "PROVIDER_GROUPS"
|
||||
| "REGION"
|
||||
| "SERVICE"
|
||||
| "SEVERITY"
|
||||
| "STATUS"
|
||||
| "DELTA"
|
||||
| "RESOURCE_TYPE"
|
||||
| "CATEGORY"
|
||||
| "RESOURCE_GROUPS"
|
||||
| "SCAN"]
|
||||
// findings-only extras
|
||||
| "delta__in"
|
||||
| "scan"
|
||||
| "scan_id"
|
||||
| "scan_id__in"
|
||||
| "inserted_at"
|
||||
| "inserted_at__gte"
|
||||
| "inserted_at__lte"
|
||||
| "muted"
|
||||
>;
|
||||
@@ -0,0 +1,138 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
fetchMock,
|
||||
getAuthHeadersMock,
|
||||
handleApiErrorMock,
|
||||
handleApiResponseMock,
|
||||
} = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
handleApiErrorMock: vi.fn(),
|
||||
handleApiResponseMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/cache", () => ({
|
||||
revalidatePath: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
getErrorMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiError: handleApiErrorMock,
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
}));
|
||||
|
||||
import { getAllProviderGroups } from "./manage-groups";
|
||||
|
||||
const makeGroup = (id: string, name: string) => ({
|
||||
type: "provider-groups" as const,
|
||||
id,
|
||||
attributes: { name, inserted_at: "", updated_at: "" },
|
||||
relationships: {
|
||||
providers: { meta: { count: 0 }, data: [] },
|
||||
roles: { meta: { count: 0 }, data: [] },
|
||||
},
|
||||
links: { self: "" },
|
||||
});
|
||||
|
||||
const makePage = (
|
||||
data: ReturnType<typeof makeGroup>[],
|
||||
page: number,
|
||||
pages: number,
|
||||
) => ({
|
||||
links: { first: "", last: "", next: null, prev: null },
|
||||
data,
|
||||
meta: { pagination: { page, pages, count: data.length } },
|
||||
});
|
||||
|
||||
describe("getAllProviderGroups", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
});
|
||||
|
||||
it("merges every page into a single response with collapsed pagination", async () => {
|
||||
handleApiResponseMock
|
||||
.mockResolvedValueOnce(
|
||||
makePage(
|
||||
[makeGroup("g1", "Group 1"), makeGroup("g2", "Group 2")],
|
||||
1,
|
||||
2,
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(makePage([makeGroup("g3", "Group 3")], 2, 2));
|
||||
|
||||
const result = await getAllProviderGroups();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(result?.data.map((group) => group.id)).toEqual(["g1", "g2", "g3"]);
|
||||
expect(result?.meta.pagination).toMatchObject({
|
||||
page: 1,
|
||||
pages: 1,
|
||||
count: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("stops after the first page when there is only one page", async () => {
|
||||
handleApiResponseMock.mockResolvedValueOnce(
|
||||
makePage([makeGroup("g1", "Group 1")], 1, 1),
|
||||
);
|
||||
|
||||
const result = await getAllProviderGroups();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(result?.data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns undefined when the first page has no data", async () => {
|
||||
handleApiResponseMock.mockResolvedValueOnce(makePage([], 1, 1));
|
||||
|
||||
const result = await getAllProviderGroups();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when the request throws", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("network down"));
|
||||
|
||||
const result = await getAllProviderGroups();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when a later page resolves to an error payload", async () => {
|
||||
handleApiResponseMock
|
||||
.mockResolvedValueOnce(makePage([makeGroup("g1", "Group 1")], 1, 2))
|
||||
.mockResolvedValueOnce({ error: "Forbidden", status: 403 });
|
||||
|
||||
const result = await getAllProviderGroups();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined instead of a truncated list when the max-page cap is hit", async () => {
|
||||
// Given an API that always reports more pages than the 50-page safety cap
|
||||
handleApiResponseMock.mockImplementation((response: Response) => {
|
||||
void response;
|
||||
return Promise.resolve(makePage([makeGroup("g", "Group")], 1, 9999));
|
||||
});
|
||||
|
||||
// When fetching every page
|
||||
const result = await getAllProviderGroups();
|
||||
|
||||
// Then it must not return a partial/truncated list; bail out instead
|
||||
expect(result).toBeUndefined();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(50);
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,87 @@ export const getProviderGroups = async ({
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches all provider groups by iterating through every page.
|
||||
* Used to populate filter dropdowns (e.g. the Provider Group selector) without
|
||||
* the pagination cap that `getProviderGroups` applies for the management table.
|
||||
*/
|
||||
export const getAllProviderGroups = async (): Promise<
|
||||
ProviderGroupsResponse | undefined
|
||||
> => {
|
||||
const pageSize = 100; // Larger page size to minimize API calls
|
||||
const maxPages = 50; // Safety limit: 50 pages × 100 = 5000 groups max
|
||||
let currentPage = 1;
|
||||
const allGroups: ProviderGroupsResponse["data"] = [];
|
||||
let lastResponse: ProviderGroupsResponse | undefined;
|
||||
let hasMorePages = true;
|
||||
|
||||
try {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
while (hasMorePages && currentPage <= maxPages) {
|
||||
const url = new URL(`${apiBaseUrl}/provider-groups`);
|
||||
url.searchParams.append("page[number]", currentPage.toString());
|
||||
url.searchParams.append("page[size]", pageSize.toString());
|
||||
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
const data = (await handleApiResponse(response)) as
|
||||
| ProviderGroupsResponse
|
||||
| { error: string; status?: number }
|
||||
| undefined;
|
||||
|
||||
// A later page resolving to an API error payload must abort rather than
|
||||
// be treated as "no more pages", which would silently truncate groups.
|
||||
if (data && "error" in data) {
|
||||
console.error("Error fetching all provider groups:", data.error);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!data?.data || data.data.length === 0) {
|
||||
hasMorePages = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
allGroups.push(...data.data);
|
||||
lastResponse = data;
|
||||
|
||||
const totalPages = data.meta?.pagination?.pages || 1;
|
||||
if (currentPage >= totalPages) {
|
||||
hasMorePages = false;
|
||||
} else {
|
||||
currentPage++;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMorePages && currentPage > maxPages) {
|
||||
console.error(
|
||||
`Error fetching all provider groups: exceeded max page limit (${maxPages})`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (lastResponse) {
|
||||
return {
|
||||
...lastResponse,
|
||||
data: allGroups,
|
||||
meta: {
|
||||
...lastResponse.meta,
|
||||
pagination: {
|
||||
...lastResponse.meta?.pagination,
|
||||
page: 1,
|
||||
pages: 1,
|
||||
count: allGroups.length,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
console.error("Error fetching all provider groups:", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const getProviderGroupInfoById = async (providerGroupId: string) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}/provider-groups/${providerGroupId}`);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { FILTER_FIELD, FilterParam } from "@/types/filters";
|
||||
|
||||
/**
|
||||
* URL filter param keys the overview dashboard scopes its widgets by. Overview has
|
||||
* no single action; its widgets read these keys from the URL filters.
|
||||
*/
|
||||
export type OverviewFilterParam = FilterParam<
|
||||
(typeof FILTER_FIELD)["PROVIDER_TYPE" | "PROVIDER_ID" | "PROVIDER_GROUPS"]
|
||||
>;
|
||||
|
||||
/** The `filter[...]` keys overview widgets read from the URL. */
|
||||
export const OVERVIEW_FILTER_PARAM = {
|
||||
PROVIDER_TYPE: `filter[${FILTER_FIELD.PROVIDER_TYPE}]`,
|
||||
PROVIDER_ID: `filter[${FILTER_FIELD.PROVIDER_ID}]`,
|
||||
PROVIDER_GROUPS: `filter[${FILTER_FIELD.PROVIDER_GROUPS}]`,
|
||||
} as const satisfies Record<string, OverviewFilterParam>;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { FILTER_FIELD, FilterParam } from "@/types/filters";
|
||||
import { PROVIDERS_PAGE_FILTER } from "@/types/providers-table";
|
||||
|
||||
/**
|
||||
* URL filter param keys the providers list supports, e.g. `filter[provider__in]`.
|
||||
* Provider scope plus its providers-only extras (`provider__in` API param,
|
||||
* `connected` status).
|
||||
*/
|
||||
export type ProvidersFilterParam = FilterParam<
|
||||
| (typeof FILTER_FIELD)["PROVIDER_TYPE" | "PROVIDER_GROUPS" | "PROVIDER_UID"]
|
||||
| (typeof PROVIDERS_PAGE_FILTER)["PROVIDER" | "STATUS"]
|
||||
>;
|
||||
|
||||
/** `filter[...]` keys used when mapping the provider-type filter to the API param. */
|
||||
export const PROVIDERS_FILTER_PARAM = {
|
||||
PROVIDER: `filter[${PROVIDERS_PAGE_FILTER.PROVIDER}]`,
|
||||
PROVIDER_TYPE: `filter[${PROVIDERS_PAGE_FILTER.PROVIDER_TYPE}]`,
|
||||
} as const satisfies Record<string, ProvidersFilterParam>;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { FILTER_FIELD, FilterParam } from "@/types/filters";
|
||||
|
||||
/**
|
||||
* URL filter param keys the resources view supports, e.g. `filter[type__in]`.
|
||||
* The shared core plus its resources-only dimensions (`type__in`, `groups__in`).
|
||||
*/
|
||||
export type ResourcesFilterParam = FilterParam<
|
||||
| (typeof FILTER_FIELD)[
|
||||
| "PROVIDER_TYPE"
|
||||
| "PROVIDER_ID"
|
||||
| "PROVIDER_GROUPS"
|
||||
| "REGION"
|
||||
| "SERVICE"]
|
||||
| "type__in"
|
||||
| "groups__in"
|
||||
>;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { FILTER_FIELD, FilterParam } from "@/types/filters";
|
||||
|
||||
/**
|
||||
* Provider filter fields used to match/clear synthetic pending scan rows — the
|
||||
* `__in` forms (shared with real scan rows) plus the exact forms, and the
|
||||
* provider-group `__in` form so pending rows honor the group filter too.
|
||||
*/
|
||||
export const SCANS_PROVIDER_FILTER_FIELD = {
|
||||
PROVIDER_UID_IN: FILTER_FIELD.PROVIDER_UID,
|
||||
PROVIDER_UID: "provider_uid",
|
||||
PROVIDER_TYPE_IN: FILTER_FIELD.PROVIDER_TYPE,
|
||||
PROVIDER_TYPE: "provider_type",
|
||||
PROVIDER_GROUPS_IN: FILTER_FIELD.PROVIDER_GROUPS,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* URL filter param keys the scans view supports, e.g. `filter[state__in]`.
|
||||
* Provider scope (scans uses provider_uid, not provider_id) including provider
|
||||
* groups and the exact pending-row provider forms, plus the scans-only dimensions.
|
||||
*/
|
||||
export type ScansFilterParam = FilterParam<
|
||||
| (typeof SCANS_PROVIDER_FILTER_FIELD)[keyof typeof SCANS_PROVIDER_FILTER_FIELD]
|
||||
| "state__in"
|
||||
| "trigger"
|
||||
>;
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ProviderProps } from "@/types/providers";
|
||||
|
||||
import {
|
||||
filterProvidersByScope,
|
||||
parseFilterIds,
|
||||
scopeProvidersByGroup,
|
||||
} from "./provider-scope";
|
||||
|
||||
const makeProvider = (
|
||||
id: string,
|
||||
provider: string,
|
||||
groupIds: string[] = [],
|
||||
): ProviderProps =>
|
||||
({
|
||||
id,
|
||||
attributes: { provider },
|
||||
relationships: {
|
||||
provider_groups: {
|
||||
data: groupIds.map((gid) => ({ type: "provider-groups", id: gid })),
|
||||
},
|
||||
},
|
||||
}) as unknown as ProviderProps;
|
||||
|
||||
describe("parseFilterIds", () => {
|
||||
it("returns an empty array for undefined", () => {
|
||||
// Given / When / Then
|
||||
expect(parseFilterIds(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns an empty array for an empty string", () => {
|
||||
// Given an empty param value (e.g. "filter[provider_groups__in]=")
|
||||
// When / Then it must not produce a [""] match
|
||||
expect(parseFilterIds("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops whitespace-only and empty segments", () => {
|
||||
// Given a blank/whitespace value
|
||||
// When / Then
|
||||
expect(parseFilterIds(" ")).toEqual([]);
|
||||
expect(parseFilterIds(",")).toEqual([]);
|
||||
expect(parseFilterIds("a,,b")).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("splits and trims comma-separated ids", () => {
|
||||
expect(parseFilterIds(" a , b ")).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("normalizes array param values", () => {
|
||||
expect(parseFilterIds(["a", "", "b"])).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scopeProvidersByGroup", () => {
|
||||
const providers = [
|
||||
makeProvider("p1", "aws", ["g1"]),
|
||||
makeProvider("p2", "gcp", ["g2"]),
|
||||
makeProvider("p3", "azure", []),
|
||||
];
|
||||
|
||||
it("returns every provider when no group is selected", () => {
|
||||
expect(scopeProvidersByGroup(providers, [])).toEqual(providers);
|
||||
});
|
||||
|
||||
it("keeps only providers that belong to a selected group", () => {
|
||||
// When scoping to g1
|
||||
const result = scopeProvidersByGroup(providers, ["g1"]);
|
||||
|
||||
// Then only the g1 member remains
|
||||
expect(result.map((p) => p.id)).toEqual(["p1"]);
|
||||
});
|
||||
|
||||
it("excludes providers with no group memberships", () => {
|
||||
expect(scopeProvidersByGroup(providers, ["g2"]).map((p) => p.id)).toEqual([
|
||||
"p2",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterProvidersByScope", () => {
|
||||
const providers = [
|
||||
makeProvider("p1", "aws", ["g1"]),
|
||||
makeProvider("p2", "gcp", ["g1"]),
|
||||
makeProvider("p3", "aws", ["g2"]),
|
||||
makeProvider("p4", "azure", []),
|
||||
];
|
||||
|
||||
it("returns every provider when no dimension is set", () => {
|
||||
const result = filterProvidersByScope(providers, {
|
||||
providerIds: [],
|
||||
providerTypes: [],
|
||||
providerGroupIds: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(providers);
|
||||
});
|
||||
|
||||
it("filters by provider id", () => {
|
||||
const result = filterProvidersByScope(providers, {
|
||||
providerIds: ["p2"],
|
||||
providerTypes: [],
|
||||
providerGroupIds: [],
|
||||
});
|
||||
|
||||
expect(result.map((p) => p.id)).toEqual(["p2"]);
|
||||
});
|
||||
|
||||
it("filters by provider type case-insensitively", () => {
|
||||
const result = filterProvidersByScope(providers, {
|
||||
providerIds: [],
|
||||
providerTypes: ["AWS"],
|
||||
providerGroupIds: [],
|
||||
});
|
||||
|
||||
expect(result.map((p) => p.id)).toEqual(["p1", "p3"]);
|
||||
});
|
||||
|
||||
it("filters by provider group", () => {
|
||||
const result = filterProvidersByScope(providers, {
|
||||
providerIds: [],
|
||||
providerTypes: [],
|
||||
providerGroupIds: ["g1"],
|
||||
});
|
||||
|
||||
expect(result.map((p) => p.id)).toEqual(["p1", "p2"]);
|
||||
});
|
||||
|
||||
it("composes group AND type (the risk-plot regression)", () => {
|
||||
// Given both a group and a type filter are active
|
||||
// When combining group g1 with type aws
|
||||
const result = filterProvidersByScope(providers, {
|
||||
providerIds: [],
|
||||
providerTypes: ["aws"],
|
||||
providerGroupIds: ["g1"],
|
||||
});
|
||||
|
||||
// Then only providers matching BOTH survive (p1), not all aws or all g1
|
||||
expect(result.map((p) => p.id)).toEqual(["p1"]);
|
||||
});
|
||||
|
||||
it("composes id AND group", () => {
|
||||
// p3 is aws/g2; selecting it together with group g1 yields nothing
|
||||
const result = filterProvidersByScope(providers, {
|
||||
providerIds: ["p3"],
|
||||
providerTypes: [],
|
||||
providerGroupIds: ["g1"],
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("composes all three dimensions", () => {
|
||||
const result = filterProvidersByScope(providers, {
|
||||
providerIds: ["p1", "p2"],
|
||||
providerTypes: ["aws"],
|
||||
providerGroupIds: ["g1"],
|
||||
});
|
||||
|
||||
expect(result.map((p) => p.id)).toEqual(["p1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ProviderProps } from "@/types/providers";
|
||||
|
||||
export interface ProviderScopeFilters {
|
||||
providerIds: string[];
|
||||
providerTypes: string[];
|
||||
providerGroupIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a comma-separated filter param into trimmed, non-empty ids.
|
||||
* Guards against blank values (e.g. an empty "filter[...]=" param) so they are
|
||||
* treated as "no filter" instead of matching against an empty-string id.
|
||||
*/
|
||||
export const parseFilterIds = (
|
||||
value: string | string[] | undefined,
|
||||
): string[] => {
|
||||
if (value === undefined) return [];
|
||||
const raw = Array.isArray(value) ? value.join(",") : value;
|
||||
return raw
|
||||
.split(",")
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id.length > 0);
|
||||
};
|
||||
|
||||
const belongsToGroup = (provider: ProviderProps, groupIds: string[]): boolean =>
|
||||
provider.relationships.provider_groups?.data?.some((group) =>
|
||||
groupIds.includes(group.id),
|
||||
) ?? false;
|
||||
|
||||
/**
|
||||
* Keep only providers belonging to one of the selected groups. An empty group
|
||||
* list means "no group filter" and returns every provider unchanged.
|
||||
*/
|
||||
export const scopeProvidersByGroup = (
|
||||
providers: ProviderProps[],
|
||||
groupIds: string[],
|
||||
): ProviderProps[] =>
|
||||
groupIds.length === 0
|
||||
? providers
|
||||
: providers.filter((p) => belongsToGroup(p, groupIds));
|
||||
|
||||
/**
|
||||
* Filter providers by every active scope dimension (id, type, group) combined
|
||||
* with AND. Each empty dimension is skipped, so a provider is kept only when it
|
||||
* satisfies all the filters that are actually set.
|
||||
*/
|
||||
export const filterProvidersByScope = (
|
||||
providers: ProviderProps[],
|
||||
{ providerIds, providerTypes, providerGroupIds }: ProviderScopeFilters,
|
||||
): ProviderProps[] => {
|
||||
const normalizedTypes = providerTypes.map((type) => type.toLowerCase());
|
||||
|
||||
return providers.filter((provider) => {
|
||||
if (providerIds.length > 0 && !providerIds.includes(provider.id)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
normalizedTypes.length > 0 &&
|
||||
!normalizedTypes.includes(provider.attributes.provider.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
providerGroupIds.length > 0 &&
|
||||
!belongsToGroup(provider, providerGroupIds)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
+21
-11
@@ -3,11 +3,16 @@ import {
|
||||
getFindingsBySeverity,
|
||||
SeverityByProviderType,
|
||||
} from "@/actions/overview";
|
||||
import { OVERVIEW_FILTER_PARAM } from "@/actions/overview/overview-filters";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { SankeyChart } from "@/components/graphs/sankey-chart";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { pickFilterParams } from "../../_lib/filter-params";
|
||||
import {
|
||||
parseFilterIds,
|
||||
scopeProvidersByGroup,
|
||||
} from "../../_lib/provider-scope";
|
||||
|
||||
export async function RiskPipelineViewSSR({
|
||||
searchParams,
|
||||
@@ -16,27 +21,31 @@ export async function RiskPipelineViewSSR({
|
||||
}) {
|
||||
const filters = pickFilterParams(searchParams);
|
||||
|
||||
const providerTypeFilter = filters["filter[provider_type__in]"];
|
||||
const providerIdFilter = filters["filter[provider_id__in]"];
|
||||
const providerTypeFilter = filters[OVERVIEW_FILTER_PARAM.PROVIDER_TYPE];
|
||||
const providerIdFilter = filters[OVERVIEW_FILTER_PARAM.PROVIDER_ID];
|
||||
const providerGroupsFilter = filters[OVERVIEW_FILTER_PARAM.PROVIDER_GROUPS];
|
||||
|
||||
// Fetch providers list to know account types
|
||||
const providersListResponse = await getAllProviders();
|
||||
const allProviders = providersListResponse?.data || [];
|
||||
|
||||
// Scope the provider set to the selected groups so we enumerate only their
|
||||
// provider types below (the per-type API calls also carry the group filter).
|
||||
const selectedGroupIds = parseFilterIds(providerGroupsFilter);
|
||||
const scopedProviders = scopeProvidersByGroup(allProviders, selectedGroupIds);
|
||||
|
||||
// Build severityByProviderType based on filters
|
||||
const severityByProviderType: SeverityByProviderType = {};
|
||||
let selectedProviderTypes: string[] | undefined;
|
||||
|
||||
if (providerIdFilter) {
|
||||
// Case: Accounts are selected - group by provider type and make parallel calls
|
||||
const selectedAccountIds = String(providerIdFilter)
|
||||
.split(",")
|
||||
.map((id) => id.trim());
|
||||
const selectedAccountIds = parseFilterIds(providerIdFilter);
|
||||
|
||||
// Group selected accounts by provider type
|
||||
const accountsByType: Record<string, string[]> = {};
|
||||
for (const accountId of selectedAccountIds) {
|
||||
const provider = allProviders.find((p) => p.id === accountId);
|
||||
const provider = scopedProviders.find((p) => p.id === accountId);
|
||||
if (provider) {
|
||||
const type = provider.attributes.provider.toLowerCase();
|
||||
if (!accountsByType[type]) {
|
||||
@@ -70,9 +79,9 @@ export async function RiskPipelineViewSSR({
|
||||
}
|
||||
} else if (providerTypeFilter) {
|
||||
// Case: Provider types are selected - make parallel calls for each type
|
||||
selectedProviderTypes = String(providerTypeFilter)
|
||||
.split(",")
|
||||
.map((t) => t.trim().toLowerCase());
|
||||
selectedProviderTypes = parseFilterIds(providerTypeFilter).map((type) =>
|
||||
type.toLowerCase(),
|
||||
);
|
||||
|
||||
const severityPromises = selectedProviderTypes.map(async (providerType) => {
|
||||
const response = await getFindingsBySeverity({
|
||||
@@ -93,9 +102,10 @@ export async function RiskPipelineViewSSR({
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Case: No filters - get all provider types and make parallel calls
|
||||
// Case: No account/type filter - enumerate provider types (scoped to the
|
||||
// selected groups when a group filter is active) and make parallel calls.
|
||||
const allProviderTypes = Array.from(
|
||||
new Set(allProviders.map((p) => p.attributes.provider.toLowerCase())),
|
||||
new Set(scopedProviders.map((p) => p.attributes.provider.toLowerCase())),
|
||||
);
|
||||
|
||||
const severityPromises = allProviderTypes.map(async (providerType) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import { OVERVIEW_FILTER_PARAM } from "@/actions/overview/overview-filters";
|
||||
import {
|
||||
adaptToRiskPlotData,
|
||||
getProvidersRiskData,
|
||||
@@ -8,6 +9,10 @@ import { getAllProviders } from "@/actions/providers";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { pickFilterParams } from "../../_lib/filter-params";
|
||||
import {
|
||||
filterProvidersByScope,
|
||||
parseFilterIds,
|
||||
} from "../../_lib/provider-scope";
|
||||
import { RiskPlotClient } from "./risk-plot-client";
|
||||
|
||||
export async function RiskPlotSSR({
|
||||
@@ -17,31 +22,19 @@ export async function RiskPlotSSR({
|
||||
}) {
|
||||
const filters = pickFilterParams(searchParams);
|
||||
|
||||
const providerTypeFilter = filters["filter[provider_type__in]"];
|
||||
const providerIdFilter = filters["filter[provider_id__in]"];
|
||||
|
||||
// Fetch all providers
|
||||
const providersListResponse = await getAllProviders();
|
||||
const allProviders = providersListResponse?.data || [];
|
||||
|
||||
// Filter providers based on search params
|
||||
let filteredProviders = allProviders;
|
||||
|
||||
if (providerIdFilter) {
|
||||
// Filter by specific provider IDs
|
||||
const selectedIds = String(providerIdFilter)
|
||||
.split(",")
|
||||
.map((id) => id.trim());
|
||||
filteredProviders = allProviders.filter((p) => selectedIds.includes(p.id));
|
||||
} else if (providerTypeFilter) {
|
||||
// Filter by provider types
|
||||
const selectedTypes = String(providerTypeFilter)
|
||||
.split(",")
|
||||
.map((t) => t.trim().toLowerCase());
|
||||
filteredProviders = allProviders.filter((p) =>
|
||||
selectedTypes.includes(p.attributes.provider.toLowerCase()),
|
||||
);
|
||||
}
|
||||
// Compose every active provider-scope filter with AND so combining e.g. a
|
||||
// group and a type narrows to providers matching both.
|
||||
const filteredProviders = filterProvidersByScope(allProviders, {
|
||||
providerIds: parseFilterIds(filters[OVERVIEW_FILTER_PARAM.PROVIDER_ID]),
|
||||
providerTypes: parseFilterIds(filters[OVERVIEW_FILTER_PARAM.PROVIDER_TYPE]),
|
||||
providerGroupIds: parseFilterIds(
|
||||
filters[OVERVIEW_FILTER_PARAM.PROVIDER_GROUPS],
|
||||
),
|
||||
});
|
||||
|
||||
// No providers to show
|
||||
if (filteredProviders.length === 0) {
|
||||
|
||||
+11
-4
@@ -3,6 +3,7 @@
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { OVERVIEW_FILTER_PARAM } from "@/actions/overview/overview-filters";
|
||||
import { getSeverityTrendsByTimeRange } from "@/actions/overview/severity-trends";
|
||||
import { LineChart } from "@/components/graphs/line-chart";
|
||||
import { LineConfig, LineDataPoint } from "@/components/graphs/types";
|
||||
@@ -42,10 +43,16 @@ export const FindingSeverityOverTime = ({
|
||||
|
||||
const getActiveProviderFilters = (): Record<string, string> => {
|
||||
const filters: Record<string, string> = {};
|
||||
const providerType = searchParams.get("filter[provider_type__in]");
|
||||
const providerId = searchParams.get("filter[provider_id__in]");
|
||||
if (providerType) filters["filter[provider_type__in]"] = providerType;
|
||||
if (providerId) filters["filter[provider_id__in]"] = providerId;
|
||||
const providerType = searchParams.get(OVERVIEW_FILTER_PARAM.PROVIDER_TYPE);
|
||||
const providerId = searchParams.get(OVERVIEW_FILTER_PARAM.PROVIDER_ID);
|
||||
const providerGroups = searchParams.get(
|
||||
OVERVIEW_FILTER_PARAM.PROVIDER_GROUPS,
|
||||
);
|
||||
if (providerType)
|
||||
filters[OVERVIEW_FILTER_PARAM.PROVIDER_TYPE] = providerType;
|
||||
if (providerId) filters[OVERVIEW_FILTER_PARAM.PROVIDER_ID] = providerId;
|
||||
if (providerGroups)
|
||||
filters[OVERVIEW_FILTER_PARAM.PROVIDER_GROUPS] = providerGroups;
|
||||
return filters;
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
|
||||
import {
|
||||
type ComplianceProviderFilters,
|
||||
extractComplianceProviderFilters,
|
||||
hasComplianceProviderFilters,
|
||||
} from "@/lib/compliance/compliance-provider-filters";
|
||||
import {
|
||||
getReportTypeForCompliance,
|
||||
pickLatestCisPerProvider,
|
||||
@@ -37,15 +42,19 @@ import {
|
||||
Framework,
|
||||
RequirementsTotals,
|
||||
} from "@/types/compliance";
|
||||
import { SearchParamsProps } from "@/types/components";
|
||||
import { ScanEntity } from "@/types/scans";
|
||||
|
||||
interface ComplianceDetailSearchParams {
|
||||
interface ComplianceDetailSearchParams extends SearchParamsProps {
|
||||
complianceId: string;
|
||||
version?: string;
|
||||
scanId?: string;
|
||||
section?: string;
|
||||
"filter[region__in]"?: string;
|
||||
"filter[cis_profile_level]"?: string;
|
||||
"filter[provider_type__in]"?: string;
|
||||
"filter[provider_id__in]"?: string;
|
||||
"filter[provider_groups__in]"?: string;
|
||||
page?: string;
|
||||
pageSize?: string;
|
||||
}
|
||||
@@ -64,6 +73,10 @@ export default async function ComplianceDetail({
|
||||
const cisProfileFilter = resolvedSearchParams["filter[cis_profile_level]"];
|
||||
const logoPath = getComplianceIcon(compliancetitle);
|
||||
|
||||
const hasProviderFilters = hasComplianceProviderFilters(resolvedSearchParams);
|
||||
const providerFilters =
|
||||
extractComplianceProviderFilters(resolvedSearchParams);
|
||||
|
||||
// Create a key that excludes pagination parameters to preserve accordion state avoiding reloads with pagination
|
||||
const paramsForKey = Object.fromEntries(
|
||||
Object.entries(resolvedSearchParams).filter(
|
||||
@@ -78,14 +91,15 @@ export default async function ComplianceDetail({
|
||||
: `${formattedTitle}`;
|
||||
|
||||
let selectedScan: ScanEntity | null = null;
|
||||
const selectedScanId = scanId || null;
|
||||
// Aggregated mode ignores scanId entirely (backend XOR); provider filters drive scope.
|
||||
const selectedScanId = hasProviderFilters ? null : scanId || null;
|
||||
|
||||
const [metadataInfoData, attributesData, selectedScanResponse] =
|
||||
await Promise.all([
|
||||
getComplianceOverviewMetadataInfo({
|
||||
filters: {
|
||||
"filter[scan_id]": selectedScanId ?? undefined,
|
||||
},
|
||||
filters: hasProviderFilters
|
||||
? providerFilters
|
||||
: { "filter[scan_id]": selectedScanId ?? undefined },
|
||||
}),
|
||||
getComplianceAttributes(complianceId, selectedScanId ?? undefined),
|
||||
selectedScanId
|
||||
@@ -238,6 +252,8 @@ export default async function ComplianceDetail({
|
||||
attributesData={attributesData}
|
||||
threatScoreData={threatScoreData}
|
||||
targetSection={section}
|
||||
hasProviderFilters={hasProviderFilters}
|
||||
providerFilters={providerFilters}
|
||||
/>
|
||||
</Suspense>
|
||||
</ContentLayout>
|
||||
@@ -252,6 +268,8 @@ const SSRComplianceContent = async ({
|
||||
attributesData,
|
||||
threatScoreData,
|
||||
targetSection,
|
||||
hasProviderFilters,
|
||||
providerFilters,
|
||||
}: {
|
||||
complianceId: string;
|
||||
scanId: string;
|
||||
@@ -263,15 +281,18 @@ const SSRComplianceContent = async ({
|
||||
sectionScores: Record<string, number>;
|
||||
} | null;
|
||||
targetSection?: string;
|
||||
hasProviderFilters: boolean;
|
||||
providerFilters: ComplianceProviderFilters;
|
||||
}) => {
|
||||
const requirementsData = await getComplianceRequirements({
|
||||
complianceId,
|
||||
scanId,
|
||||
scanId: scanId || undefined,
|
||||
region,
|
||||
filters: hasProviderFilters ? providerFilters : undefined,
|
||||
});
|
||||
const type = requirementsData?.data?.[0]?.type;
|
||||
|
||||
if (!scanId || type === "tasks") {
|
||||
if ((!scanId && !hasProviderFilters) || type === "tasks") {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-[minmax(280px,400px)_1fr]">
|
||||
|
||||
@@ -13,4 +13,15 @@ describe("Compliance overview page", () => {
|
||||
expect(source).toContain("ComplianceOverviewGrid");
|
||||
expect(source).not.toContain("filter[search]");
|
||||
});
|
||||
|
||||
it("switches to aggregated mode when provider filters are present", () => {
|
||||
expect(source).toContain("hasComplianceProviderFilters");
|
||||
expect(source).toContain("extractComplianceProviderFilters");
|
||||
});
|
||||
|
||||
it("feeds the provider and provider-group selectors", () => {
|
||||
expect(source).toContain("getAllProviders");
|
||||
expect(source).toContain("getAllProviderGroups");
|
||||
expect(source).toContain("providerGroups={");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
getComplianceOverviewMetadataInfo,
|
||||
getCompliancesOverview,
|
||||
} from "@/actions/compliances";
|
||||
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
|
||||
import { getThreatScore } from "@/actions/overview";
|
||||
import { getAllProviders } from "@/actions/providers/providers";
|
||||
import { getScans } from "@/actions/scans";
|
||||
import {
|
||||
ComplianceSkeletonGrid,
|
||||
@@ -17,6 +19,11 @@ import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overv
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import {
|
||||
type ComplianceProviderFilters,
|
||||
extractComplianceProviderFilters,
|
||||
hasComplianceProviderFilters,
|
||||
} from "@/lib/compliance/compliance-provider-filters";
|
||||
import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types";
|
||||
import {
|
||||
ExpandedScanData,
|
||||
@@ -34,16 +41,24 @@ export default async function Compliance({
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
|
||||
|
||||
const scansData = await getScans({
|
||||
filters: {
|
||||
"filter[state]": "completed",
|
||||
},
|
||||
pageSize: 50,
|
||||
fields: {
|
||||
scans: "name,completed_at,provider",
|
||||
},
|
||||
include: "provider",
|
||||
});
|
||||
const hasProviderFilters = hasComplianceProviderFilters(resolvedSearchParams);
|
||||
const providerFilters =
|
||||
extractComplianceProviderFilters(resolvedSearchParams);
|
||||
|
||||
const [scansData, providersData, providerGroupsData] = await Promise.all([
|
||||
getScans({
|
||||
filters: {
|
||||
"filter[state]": "completed",
|
||||
},
|
||||
pageSize: 50,
|
||||
fields: {
|
||||
scans: "name,completed_at,provider",
|
||||
},
|
||||
include: "provider",
|
||||
}),
|
||||
getAllProviders(),
|
||||
getAllProviderGroups(),
|
||||
]);
|
||||
|
||||
if (!scansData?.data) {
|
||||
return (
|
||||
@@ -90,9 +105,12 @@ export default async function Compliance({
|
||||
const scanIdFromUrl = Array.isArray(scanIdParam)
|
||||
? scanIdParam[0]
|
||||
: scanIdParam;
|
||||
const selectedScanId: string | null =
|
||||
scanIdFromUrl || expandedScansData[0]?.id || null;
|
||||
const onboardingAction = selectedScanId
|
||||
// Aggregated mode ignores scanId entirely (backend XOR); provider filters drive scope.
|
||||
const selectedScanId: string | null = hasProviderFilters
|
||||
? null
|
||||
: scanIdFromUrl || expandedScansData[0]?.id || null;
|
||||
const hasScope = hasProviderFilters || Boolean(selectedScanId);
|
||||
const onboardingAction = hasScope
|
||||
? { flowId: "view-compliance" }
|
||||
: {
|
||||
flowId: "view-compliance",
|
||||
@@ -115,13 +133,15 @@ export default async function Compliance({
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const metadataInfoData = selectedScanId
|
||||
? await getComplianceOverviewMetadataInfo({
|
||||
filters: {
|
||||
"filter[scan_id]": selectedScanId,
|
||||
},
|
||||
})
|
||||
: { data: { attributes: { regions: [] } } };
|
||||
const metadataInfoData = hasProviderFilters
|
||||
? await getComplianceOverviewMetadataInfo({ filters: providerFilters })
|
||||
: selectedScanId
|
||||
? await getComplianceOverviewMetadataInfo({
|
||||
filters: {
|
||||
"filter[scan_id]": selectedScanId,
|
||||
},
|
||||
})
|
||||
: { data: { attributes: { regions: [] } } };
|
||||
|
||||
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
|
||||
|
||||
@@ -146,13 +166,15 @@ export default async function Compliance({
|
||||
icon="lucide:shield-check"
|
||||
onboardingAction={onboardingAction}
|
||||
>
|
||||
{selectedScanId ? (
|
||||
{hasScope ? (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<ComplianceFilters
|
||||
scans={expandedScansData}
|
||||
uniqueRegions={uniqueRegions}
|
||||
selectedScanId={selectedScanId}
|
||||
providers={providersData?.data ?? []}
|
||||
providerGroups={providerGroupsData?.data ?? []}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -182,6 +204,8 @@ export default async function Compliance({
|
||||
searchParams={resolvedSearchParams}
|
||||
scanId={selectedScanId}
|
||||
selectedScan={selectedScanData}
|
||||
hasProviderFilters={hasProviderFilters}
|
||||
providerFilters={providerFilters}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
@@ -196,15 +220,23 @@ const SSRComplianceGrid = async ({
|
||||
searchParams,
|
||||
scanId,
|
||||
selectedScan,
|
||||
hasProviderFilters,
|
||||
providerFilters,
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
scanId: string | null;
|
||||
selectedScan?: ScanEntity;
|
||||
hasProviderFilters: boolean;
|
||||
providerFilters: ComplianceProviderFilters;
|
||||
}) => {
|
||||
const regionFilter = searchParams["filter[region__in]"]?.toString() || "";
|
||||
|
||||
const compliancesData =
|
||||
scanId && scanId.trim() !== ""
|
||||
const compliancesData = hasProviderFilters
|
||||
? await getCompliancesOverview({
|
||||
region: regionFilter,
|
||||
filters: providerFilters,
|
||||
})
|
||||
: scanId && scanId.trim() !== ""
|
||||
? await getCompliancesOverview({
|
||||
scanId,
|
||||
region: regionFilter,
|
||||
@@ -230,8 +262,9 @@ const SSRComplianceGrid = async ({
|
||||
<Alert variant="info">
|
||||
<Info className="size-4" />
|
||||
<AlertDescription>
|
||||
This scan has no compliance data available yet, please select a
|
||||
different one.
|
||||
{hasProviderFilters
|
||||
? "No completed scans match the selected providers."
|
||||
: "This scan has no compliance data available yet, please select a different one."}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getLatestFindingGroups,
|
||||
} from "@/actions/finding-groups";
|
||||
import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings";
|
||||
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { getScan, getScans } from "@/actions/scans";
|
||||
import { SeedFromFindingsButton } from "@/app/(prowler)/alerts/_components";
|
||||
@@ -36,8 +37,9 @@ export default async function Findings({
|
||||
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
|
||||
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
|
||||
|
||||
const [providersData, scansData] = await Promise.all([
|
||||
const [providersData, providerGroupsData, scansData] = await Promise.all([
|
||||
getAllProviders(),
|
||||
getAllProviderGroups(),
|
||||
getScans({ pageSize: 50 }),
|
||||
]);
|
||||
|
||||
@@ -99,6 +101,7 @@ export default async function Findings({
|
||||
<div className="mb-6">
|
||||
<FindingsFilters
|
||||
providers={providersData?.data || []}
|
||||
providerGroups={providerGroupsData?.data || []}
|
||||
completedScanIds={completedScanIds}
|
||||
scanDetails={scanDetails}
|
||||
uniqueRegions={uniqueRegions}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
|
||||
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
@@ -38,12 +40,16 @@ export default async function Home({
|
||||
searchParams: Promise<SearchParamsProps>;
|
||||
}) {
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const providersData = await getAllProviders();
|
||||
const [providersData, providerGroupsData] = await Promise.all([
|
||||
getAllProviders(),
|
||||
getAllProviderGroups(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<ContentLayout title="Overview" icon="lucide:square-chart-gantt">
|
||||
<div className="xxl:grid-cols-4 mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
<ProviderAccountSelectors providers={providersData?.data ?? []} />
|
||||
<ProviderGroupSelector groups={providerGroupsData?.data ?? []} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 xl:flex-row xl:flex-wrap xl:items-stretch">
|
||||
|
||||
@@ -118,6 +118,7 @@ const ProvidersTabContent = async ({
|
||||
isCloud={process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"}
|
||||
filters={providersView.filters}
|
||||
providers={providersView.providers}
|
||||
providerGroups={providersView.providerGroups}
|
||||
metadata={providersView.metadata}
|
||||
rows={providersView.rows}
|
||||
/>
|
||||
|
||||
@@ -18,6 +18,10 @@ const schedulesActionsMock = vi.hoisted(() => ({
|
||||
getSchedules: vi.fn(),
|
||||
}));
|
||||
|
||||
const manageGroupsActionsMock = vi.hoisted(() => ({
|
||||
getAllProviderGroups: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/providers", () => providersActionsMock);
|
||||
vi.mock(
|
||||
"@/actions/organizations/organizations",
|
||||
@@ -25,6 +29,7 @@ vi.mock(
|
||||
);
|
||||
vi.mock("@/actions/scans", () => scansActionsMock);
|
||||
vi.mock("@/actions/schedules", () => schedulesActionsMock);
|
||||
vi.mock("@/actions/manage-groups/manage-groups", () => manageGroupsActionsMock);
|
||||
|
||||
import { SearchParamsProps } from "@/types";
|
||||
import { ProvidersApiResponse } from "@/types/providers";
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
|
||||
import {
|
||||
listOrganizationsSafe,
|
||||
listOrganizationUnitsSafe,
|
||||
} from "@/actions/organizations/organizations";
|
||||
import { getAllProviders, getProviders } from "@/actions/providers";
|
||||
import { PROVIDERS_FILTER_PARAM } from "@/actions/providers/providers-filters";
|
||||
import { getScans } from "@/actions/scans";
|
||||
import { getSchedules } from "@/actions/schedules";
|
||||
import {
|
||||
@@ -465,13 +467,12 @@ export async function loadProvidersAccountsViewData({
|
||||
|
||||
// Map provider_type__in (used by ProviderTypeSelector) to provider__in (API param)
|
||||
const providerTypeFilter =
|
||||
providerFilters[`filter[${PROVIDERS_PAGE_FILTER.PROVIDER_TYPE}]`];
|
||||
providerFilters[PROVIDERS_FILTER_PARAM.PROVIDER_TYPE];
|
||||
if (providerTypeFilter) {
|
||||
providerFilters[`filter[${PROVIDERS_PAGE_FILTER.PROVIDER}]`] =
|
||||
providerTypeFilter;
|
||||
providerFilters[PROVIDERS_FILTER_PARAM.PROVIDER] = providerTypeFilter;
|
||||
}
|
||||
|
||||
delete providerFilters[`filter[${PROVIDERS_PAGE_FILTER.PROVIDER_TYPE}]`];
|
||||
delete providerFilters[PROVIDERS_FILTER_PARAM.PROVIDER_TYPE];
|
||||
|
||||
const emptyOrganizationsResponse: OrganizationListResponse = {
|
||||
data: [],
|
||||
@@ -483,6 +484,7 @@ export async function loadProvidersAccountsViewData({
|
||||
const [
|
||||
providersResponse,
|
||||
allProvidersResponse,
|
||||
allProviderGroupsResponse,
|
||||
scansResponse,
|
||||
schedulesResponse,
|
||||
organizationsResponse,
|
||||
@@ -500,6 +502,8 @@ export async function loadProvidersAccountsViewData({
|
||||
// Unfiltered fetch for ProviderTypeSelector — only needs distinct types;
|
||||
// TODO: Replace with a dedicated lightweight endpoint when available.
|
||||
resolveActionResult(getAllProviders()),
|
||||
// Unfiltered fetch for the Provider Group selector dropdown.
|
||||
resolveActionResult(getAllProviderGroups()),
|
||||
// Fetch active scheduled scans to flag providers whose schedule has fired.
|
||||
resolveActionResult(
|
||||
getScans({
|
||||
@@ -545,6 +549,7 @@ export async function loadProvidersAccountsViewData({
|
||||
filters: createProvidersFilters(),
|
||||
metadata: providersResponse?.meta,
|
||||
providers: allProvidersResponse?.data ?? [],
|
||||
providerGroups: allProviderGroupsResponse?.data ?? [],
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import {
|
||||
getLatestMetadataInfo,
|
||||
@@ -37,19 +38,23 @@ export default async function Resources({
|
||||
|
||||
const initialResourceId = resolvedSearchParams.resourceId?.toString();
|
||||
|
||||
const [metadataInfoData, providersData, resourceByIdData] = await Promise.all(
|
||||
[
|
||||
(hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({
|
||||
query,
|
||||
filters: outputFilters,
|
||||
sort: encodedSort,
|
||||
}),
|
||||
getAllProviders(),
|
||||
initialResourceId
|
||||
? getResourceById(initialResourceId, { include: ["provider"] })
|
||||
: Promise.resolve(undefined),
|
||||
],
|
||||
);
|
||||
const [
|
||||
metadataInfoData,
|
||||
providersData,
|
||||
providerGroupsData,
|
||||
resourceByIdData,
|
||||
] = await Promise.all([
|
||||
(hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({
|
||||
query,
|
||||
filters: outputFilters,
|
||||
sort: encodedSort,
|
||||
}),
|
||||
getAllProviders(),
|
||||
getAllProviderGroups(),
|
||||
initialResourceId
|
||||
? getResourceById(initialResourceId, { include: ["provider"] })
|
||||
: Promise.resolve(undefined),
|
||||
]);
|
||||
|
||||
const processedResource = resourceByIdData?.data
|
||||
? (() => {
|
||||
@@ -80,6 +85,7 @@ export default async function Resources({
|
||||
<div className="mb-6">
|
||||
<ResourcesFilters
|
||||
providers={providersData?.data || []}
|
||||
providerGroups={providerGroupsData?.data || []}
|
||||
uniqueRegions={uniqueRegions}
|
||||
uniqueServices={uniqueServices}
|
||||
uniqueResourceTypes={uniqueResourceTypes}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { getScans } from "@/actions/scans";
|
||||
import {
|
||||
SCANS_PROVIDER_FILTER_FIELD,
|
||||
type ScansFilterParam,
|
||||
} from "@/actions/scans/scans-filters";
|
||||
import { getSchedules } from "@/actions/schedules";
|
||||
import { auth } from "@/auth.config";
|
||||
import { PageReady } from "@/components/onboarding";
|
||||
@@ -33,28 +38,21 @@ import {
|
||||
|
||||
const ACTIVE_SCAN_COUNT_PAGE_SIZE = 1;
|
||||
// Pending schedule rows are derived from provider schedules, but must honor the
|
||||
// same provider filters as real scan rows. Keep these filter keys typed locally
|
||||
// without narrowing the global SearchParamsProps shape used by Next pages.
|
||||
const PENDING_ROW_PROVIDER_FILTER = {
|
||||
PROVIDER_UID_IN: "provider_uid__in",
|
||||
PROVIDER_UID: "provider_uid",
|
||||
PROVIDER_TYPE_IN: "provider_type__in",
|
||||
PROVIDER_TYPE: "provider_type",
|
||||
} as const;
|
||||
|
||||
type PendingRowProviderFilter =
|
||||
(typeof PENDING_ROW_PROVIDER_FILTER)[keyof typeof PENDING_ROW_PROVIDER_FILTER];
|
||||
type PendingRowProviderFilterParam = `filter[${PendingRowProviderFilter}]`;
|
||||
|
||||
// same provider filters as real scan rows. The filter keys live with the scans
|
||||
// action (SCANS_PROVIDER_FILTER_FIELD) so they stay in sync with ScansFilterParam.
|
||||
const PROVIDER_UID_FILTER_KEYS = [
|
||||
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_UID_IN}]`,
|
||||
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_UID}]`,
|
||||
] as const satisfies ReadonlyArray<PendingRowProviderFilterParam>;
|
||||
`filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER_UID_IN}]`,
|
||||
`filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER_UID}]`,
|
||||
] as const satisfies ReadonlyArray<ScansFilterParam>;
|
||||
|
||||
const PROVIDER_TYPE_FILTER_KEYS = [
|
||||
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_TYPE_IN}]`,
|
||||
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_TYPE}]`,
|
||||
] as const satisfies ReadonlyArray<PendingRowProviderFilterParam>;
|
||||
`filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER_TYPE_IN}]`,
|
||||
`filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER_TYPE}]`,
|
||||
] as const satisfies ReadonlyArray<ScansFilterParam>;
|
||||
|
||||
const PROVIDER_GROUP_FILTER_KEYS = [
|
||||
`filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER_GROUPS_IN}]`,
|
||||
] as const satisfies ReadonlyArray<ScansFilterParam>;
|
||||
|
||||
const getFilterSearchQuery = (
|
||||
filters: Record<string, string | string[]>,
|
||||
@@ -77,7 +75,7 @@ const parseCsvParam = (value?: string | string[]): string[] => {
|
||||
|
||||
const getFirstSearchParam = (
|
||||
searchParams: SearchParamsProps,
|
||||
keys: ReadonlyArray<PendingRowProviderFilterParam>,
|
||||
keys: ReadonlyArray<ScansFilterParam>,
|
||||
): string | string[] | undefined => {
|
||||
for (const key of keys) {
|
||||
const value = searchParams[key];
|
||||
@@ -98,11 +96,18 @@ const filterProvidersForPendingRows = (
|
||||
const types = parseCsvParam(
|
||||
getFirstSearchParam(searchParams, PROVIDER_TYPE_FILTER_KEYS),
|
||||
);
|
||||
const groups = parseCsvParam(
|
||||
getFirstSearchParam(searchParams, PROVIDER_GROUP_FILTER_KEYS),
|
||||
);
|
||||
|
||||
return providers.filter(
|
||||
(provider) =>
|
||||
(uids.length === 0 || uids.includes(provider.attributes.uid)) &&
|
||||
(types.length === 0 || types.includes(provider.attributes.provider)),
|
||||
(types.length === 0 || types.includes(provider.attributes.provider)) &&
|
||||
(groups.length === 0 ||
|
||||
(provider.relationships?.provider_groups?.data ?? []).some((group) =>
|
||||
groups.includes(group.id),
|
||||
)),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -168,8 +173,18 @@ export default async function Scans({
|
||||
const session = await auth();
|
||||
const resolvedSearchParams = await searchParams;
|
||||
|
||||
const providersData = await getAllProviders();
|
||||
const providers = providersData?.data ?? [];
|
||||
const [providersResult, providerGroupsResult] = await Promise.allSettled([
|
||||
getAllProviders(),
|
||||
getAllProviderGroups(),
|
||||
]);
|
||||
const providers =
|
||||
providersResult.status === "fulfilled"
|
||||
? (providersResult.value?.data ?? [])
|
||||
: [];
|
||||
const providerGroups =
|
||||
providerGroupsResult.status === "fulfilled"
|
||||
? (providerGroupsResult.value?.data ?? [])
|
||||
: [];
|
||||
|
||||
const connectedProviders = providers.filter(
|
||||
(provider: ProviderProps) =>
|
||||
@@ -220,6 +235,7 @@ export default async function Scans({
|
||||
) : (
|
||||
<ScansPageShell
|
||||
providers={providers}
|
||||
providerGroups={providerGroups}
|
||||
hasManageScansPermission={hasManageScansPermission}
|
||||
activeScanCount={activeScanCount}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Requirement } from "@/types/compliance";
|
||||
|
||||
import { ClientAccordionContent } from "./client-accordion-content";
|
||||
|
||||
const { getFindingsMock, getLatestFindingsMock } = vi.hoisted(() => ({
|
||||
getFindingsMock: vi.fn(),
|
||||
getLatestFindingsMock: vi.fn(),
|
||||
}));
|
||||
|
||||
let currentSearchParams = new URLSearchParams();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => currentSearchParams,
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/findings/findings", () => ({
|
||||
getFindings: getFindingsMock,
|
||||
getLatestFindings: getLatestFindingsMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/table", () => ({
|
||||
getStandaloneFindingColumns: () => [],
|
||||
SkeletonTableFindings: () => <div data-testid="skeleton" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/accordion/Accordion", () => ({
|
||||
Accordion: () => <div data-testid="accordion" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
DataTable: () => <div data-testid="data-table" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/compliance/compliance-mapper", () => ({
|
||||
getComplianceMapper: () => ({ getDetailsComponent: () => null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
createDict: () => ({}),
|
||||
FINDINGS_DEFAULT_SORT: "severity",
|
||||
MUTED_FILTER: { EXCLUDE: "false" },
|
||||
}));
|
||||
|
||||
const requirement = {
|
||||
check_ids: ["check-1"],
|
||||
status: "FAIL",
|
||||
} as unknown as Requirement;
|
||||
|
||||
describe("ClientAccordionContent findings drill-down", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getFindingsMock.mockResolvedValue({ data: [], meta: {} });
|
||||
getLatestFindingsMock.mockResolvedValue({ data: [], meta: {} });
|
||||
});
|
||||
|
||||
describe("when provider filters drive aggregated mode", () => {
|
||||
it("loads findings from the latest endpoint, not the scan-scoped one", async () => {
|
||||
// Given - the URL carries a provider-scope filter and no scanId
|
||||
currentSearchParams = new URLSearchParams({
|
||||
complianceId: "cis_2.0_aws",
|
||||
"filter[provider_type__in]": "aws",
|
||||
});
|
||||
|
||||
// When
|
||||
render(
|
||||
<ClientAccordionContent
|
||||
requirement={requirement}
|
||||
framework="cis_aws"
|
||||
scanId=""
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then - /findings 400s without a scan or date filter, so aggregated mode
|
||||
// must use /findings/latest, forwarding the provider filters and no scan
|
||||
await waitFor(() =>
|
||||
expect(getLatestFindingsMock).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(getFindingsMock).not.toHaveBeenCalled();
|
||||
const { filters } = getLatestFindingsMock.mock.calls[0][0];
|
||||
expect(filters).toMatchObject({ "filter[provider_type__in]": "aws" });
|
||||
expect(filters).not.toHaveProperty("filter[scan]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when a single scan drives the scope", () => {
|
||||
it("loads findings from the scan-scoped endpoint", async () => {
|
||||
// Given - no provider filters, a concrete scanId
|
||||
currentSearchParams = new URLSearchParams({
|
||||
complianceId: "cis_2.0_aws",
|
||||
});
|
||||
|
||||
// When
|
||||
render(
|
||||
<ClientAccordionContent
|
||||
requirement={requirement}
|
||||
framework="cis_aws"
|
||||
scanId="scan-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(getFindingsMock).toHaveBeenCalledTimes(1));
|
||||
expect(getLatestFindingsMock).not.toHaveBeenCalled();
|
||||
const { filters } = getFindingsMock.mock.calls[0][0];
|
||||
expect(filters).toMatchObject({ "filter[scan]": "scan-1" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { getFindings } from "@/actions/findings/findings";
|
||||
import { getFindings, getLatestFindings } from "@/actions/findings/findings";
|
||||
import {
|
||||
getStandaloneFindingColumns,
|
||||
SkeletonTableFindings,
|
||||
@@ -12,6 +12,7 @@ import { Accordion } from "@/components/ui/accordion/Accordion";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { createDict, FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib";
|
||||
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
|
||||
import { extractComplianceProviderFilters } from "@/lib/compliance/compliance-provider-filters";
|
||||
import { Requirement } from "@/types/compliance";
|
||||
import { FindingProps, FindingsResponse } from "@/types/components";
|
||||
|
||||
@@ -46,6 +47,12 @@ export const ClientAccordionContent = ({
|
||||
// so the requirement view stays consistent with every other findings
|
||||
// surface in the app (findings page, resource drawer, overview widgets).
|
||||
const mutedFilter = searchParams.get("filter[muted]") || MUTED_FILTER.EXCLUDE;
|
||||
// Aggregated mode: the detail page carries provider filters instead of a scanId,
|
||||
// so scope this requirement's findings by those providers rather than one scan.
|
||||
// Stable string key keeps the effect deps free of a per-render object.
|
||||
const providerScopeKey = new URLSearchParams(
|
||||
extractComplianceProviderFilters(searchParams),
|
||||
).toString();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadFindings() {
|
||||
@@ -68,10 +75,18 @@ export const ClientAccordionContent = ({
|
||||
try {
|
||||
const checkIds = requirement.check_ids;
|
||||
const encodedSort = sort.replace(/^\+/, "");
|
||||
const findingsData = await getFindings({
|
||||
// Aggregated mode carries provider filters but no scan/date, which the
|
||||
// /findings endpoint rejects (400). Use /findings/latest there — it
|
||||
// needs neither and scopes to the latest scan per matching provider.
|
||||
const isAggregated = providerScopeKey.length > 0;
|
||||
const scopeFilters = isAggregated
|
||||
? Object.fromEntries(new URLSearchParams(providerScopeKey))
|
||||
: { "filter[scan]": scanId };
|
||||
const loadFindings = isAggregated ? getLatestFindings : getFindings;
|
||||
const findingsData = await loadFindings({
|
||||
filters: {
|
||||
"filter[check_id__in]": checkIds.join(","),
|
||||
"filter[scan]": scanId,
|
||||
...scopeFilters,
|
||||
"filter[muted]": mutedFilter,
|
||||
...(region && { "filter[region__in]": region }),
|
||||
},
|
||||
@@ -115,6 +130,7 @@ export const ClientAccordionContent = ({
|
||||
}, [
|
||||
requirement,
|
||||
scanId,
|
||||
providerScopeKey,
|
||||
pageNumber,
|
||||
pageSize,
|
||||
sort,
|
||||
|
||||
@@ -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,11 @@ 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(searchParams);
|
||||
const isAggregated = Object.keys(providerFilters).length > 0;
|
||||
|
||||
const formatTitle = (title: string) => {
|
||||
return title.split("-").join(" ");
|
||||
};
|
||||
@@ -75,8 +81,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 +95,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,148 @@
|
||||
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("@/components/filters/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");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
|
||||
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
|
||||
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
|
||||
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,10 @@ 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(searchParams);
|
||||
const isAggregated = Object.keys(providerFilters).length > 0;
|
||||
|
||||
const filteredFrameworks = frameworks.filter((compliance) =>
|
||||
compliance.attributes.framework
|
||||
.toLowerCase()
|
||||
@@ -62,8 +67,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,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CONNECTION_STATUS_MAPPING } from "@/lib/helper-filters";
|
||||
import { FilterOption, FilterType } from "@/types/filters";
|
||||
import { FILTER_FIELD, FilterOption } from "@/types/filters";
|
||||
import {
|
||||
PROVIDER_DISPLAY_NAMES,
|
||||
PROVIDER_TYPES,
|
||||
@@ -64,19 +64,19 @@ export const filterScans = [
|
||||
//Static filters for findings
|
||||
export const filterFindings = [
|
||||
{
|
||||
key: FilterType.SEVERITY,
|
||||
key: FILTER_FIELD.SEVERITY,
|
||||
labelCheckboxGroup: "Severity",
|
||||
values: ["critical", "high", "medium", "low", "informational"],
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
key: FilterType.STATUS,
|
||||
key: FILTER_FIELD.STATUS,
|
||||
labelCheckboxGroup: "Status",
|
||||
values: ["PASS", "FAIL", "MANUAL"],
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
key: FilterType.DELTA,
|
||||
key: FILTER_FIELD.DELTA,
|
||||
labelCheckboxGroup: "Delta",
|
||||
values: ["new", "changed"],
|
||||
index: 2,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ProviderGroup } from "@/types/components";
|
||||
|
||||
import { ProviderGroupSelector } from "./provider-group-selector";
|
||||
|
||||
const multiSelectContentSpy = vi.fn();
|
||||
|
||||
const { navigateWithParamsMock } = vi.hoisted(() => ({
|
||||
navigateWithParamsMock: vi.fn(),
|
||||
}));
|
||||
|
||||
let currentSearchParams = new URLSearchParams();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => currentSearchParams,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-url-filters", () => ({
|
||||
useUrlFilters: () => ({
|
||||
navigateWithParams: navigateWithParamsMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/select/multiselect", () => ({
|
||||
MultiSelect: ({
|
||||
children,
|
||||
onValuesChange,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onValuesChange: (values: string[]) => void;
|
||||
}) => (
|
||||
<div>
|
||||
<button
|
||||
data-testid="mock-select-group-2"
|
||||
onClick={() => onValuesChange(["group-2"])}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="trigger">{children}</div>
|
||||
),
|
||||
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
|
||||
<span>{placeholder}</span>
|
||||
),
|
||||
MultiSelectContent: ({
|
||||
children,
|
||||
search,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
search?: unknown;
|
||||
}) => {
|
||||
multiSelectContentSpy(search);
|
||||
return <div>{children}</div>;
|
||||
},
|
||||
MultiSelectItem: ({
|
||||
children,
|
||||
value,
|
||||
keywords,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
keywords?: string[];
|
||||
}) => (
|
||||
<div data-value={value} data-keywords={keywords?.join("|")}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const makeGroup = (id: string, name: string): ProviderGroup => ({
|
||||
type: "provider-groups",
|
||||
id,
|
||||
attributes: { name, inserted_at: "", updated_at: "" },
|
||||
relationships: {
|
||||
providers: { meta: { count: 0 }, data: [] },
|
||||
roles: { meta: { count: 0 }, data: [] },
|
||||
},
|
||||
links: { self: "" },
|
||||
});
|
||||
|
||||
const groups = [
|
||||
makeGroup("group-1", "Production"),
|
||||
makeGroup("group-2", "Dev"),
|
||||
];
|
||||
|
||||
describe("ProviderGroupSelector", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
currentSearchParams = new URLSearchParams();
|
||||
});
|
||||
|
||||
it("stays visible with the placeholder and empty message when there are no provider groups", () => {
|
||||
render(<ProviderGroupSelector groups={[]} />);
|
||||
|
||||
// Control is still rendered (visible even with zero groups)...
|
||||
expect(screen.getByText("All Provider Groups")).toBeInTheDocument();
|
||||
// ...and the single empty state is the MultiSelect's own emptyMessage,
|
||||
// not a duplicate custom message.
|
||||
expect(multiSelectContentSpy).toHaveBeenCalledWith({
|
||||
placeholder: "Search Provider Groups...",
|
||||
emptyMessage: "No Provider Groups found.",
|
||||
});
|
||||
expect(
|
||||
screen.queryByText("No Provider Groups available"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes searchable dropdown defaults to MultiSelectContent and lists groups", () => {
|
||||
render(<ProviderGroupSelector groups={groups} />);
|
||||
|
||||
expect(multiSelectContentSpy).toHaveBeenCalledWith({
|
||||
placeholder: "Search Provider Groups...",
|
||||
emptyMessage: "No Provider Groups found.",
|
||||
});
|
||||
expect(screen.getByText("Production")).toBeInTheDocument();
|
||||
expect(screen.getByText("Dev")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows disabling search explicitly", () => {
|
||||
render(<ProviderGroupSelector groups={groups} search={false} />);
|
||||
|
||||
expect(multiSelectContentSpy).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it("passes the group name as a search keyword", () => {
|
||||
render(<ProviderGroupSelector groups={groups} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("Production").closest("[data-value]"),
|
||||
).toHaveAttribute("data-keywords", expect.stringContaining("Production"));
|
||||
});
|
||||
|
||||
it("disables select all when nothing is selected", () => {
|
||||
render(<ProviderGroupSelector groups={groups} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("option", { name: /select all Provider Groups/i }),
|
||||
).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByText("All selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the selected count in the trigger when multiple groups are selected", () => {
|
||||
render(
|
||||
<ProviderGroupSelector
|
||||
groups={groups}
|
||||
onBatchChange={vi.fn()}
|
||||
selectedValues={["group-1", "group-2"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId("trigger");
|
||||
expect(
|
||||
within(trigger).getByText("2 Provider Groups selected"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the single group name in the trigger when one group is selected", () => {
|
||||
render(
|
||||
<ProviderGroupSelector
|
||||
groups={groups}
|
||||
onBatchChange={vi.fn()}
|
||||
selectedValues={["group-1"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId("trigger");
|
||||
expect(within(trigger).getByText("Production")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("instant mode: writes the selection to filter[provider_groups__in] in the URL", () => {
|
||||
render(<ProviderGroupSelector groups={groups} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("mock-select-group-2"));
|
||||
|
||||
expect(navigateWithParamsMock).toHaveBeenCalledTimes(1);
|
||||
const params = new URLSearchParams();
|
||||
navigateWithParamsMock.mock.calls[0][0](params);
|
||||
expect(params.get("filter[provider_groups__in]")).toBe("group-2");
|
||||
});
|
||||
|
||||
it("instant mode: clearing deletes the filter key and the extra paramsToDeleteOnChange keys", () => {
|
||||
currentSearchParams = new URLSearchParams(
|
||||
"filter[provider_groups__in]=group-1&page=3&scanId=abc",
|
||||
);
|
||||
render(
|
||||
<ProviderGroupSelector
|
||||
groups={groups}
|
||||
paramsToDeleteOnChange={["page", "scanId"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("option", { name: /select all Provider Groups/i }),
|
||||
);
|
||||
|
||||
expect(navigateWithParamsMock).toHaveBeenCalledTimes(1);
|
||||
const params = new URLSearchParams(
|
||||
"filter[provider_groups__in]=group-1&page=3&scanId=abc",
|
||||
);
|
||||
navigateWithParamsMock.mock.calls[0][0](params);
|
||||
expect(params.has("filter[provider_groups__in]")).toBe(false);
|
||||
expect(params.has("page")).toBe(false);
|
||||
expect(params.has("scanId")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not navigate on clear when nothing is selected", () => {
|
||||
render(<ProviderGroupSelector groups={groups} />);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("option", { name: /select all Provider Groups/i }),
|
||||
);
|
||||
|
||||
expect(navigateWithParamsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import {
|
||||
MultiSelect,
|
||||
MultiSelectContent,
|
||||
MultiSelectItem,
|
||||
type MultiSelectSearchProp,
|
||||
MultiSelectTrigger,
|
||||
MultiSelectValue,
|
||||
} from "@/components/shadcn/select/multiselect";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import type { ProviderGroup } from "@/types/components";
|
||||
|
||||
const PROVIDER_GROUP_FILTER_KEY = "provider_groups__in";
|
||||
const URL_FILTER_KEY = `filter[${PROVIDER_GROUP_FILTER_KEY}]`;
|
||||
|
||||
/** Common props shared by both batch and instant modes. */
|
||||
interface ProviderGroupSelectorBaseProps {
|
||||
groups: ProviderGroup[];
|
||||
search?: MultiSelectSearchProp;
|
||||
/**
|
||||
* Instant mode only: extra URL params to delete when the selection changes
|
||||
* (e.g. ["page", "scanId"]), mirroring ProviderAccountSelectors. Ignored in
|
||||
* batch mode, where the parent owns URL updates.
|
||||
*/
|
||||
paramsToDeleteOnChange?: string[];
|
||||
}
|
||||
|
||||
/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */
|
||||
interface ProviderGroupSelectorBatchProps
|
||||
extends ProviderGroupSelectorBaseProps {
|
||||
/**
|
||||
* Called instead of navigating immediately.
|
||||
* Use this on pages that batch filter changes (e.g. Findings).
|
||||
*
|
||||
* @param filterKey - The raw filter key without "filter[]" wrapper, e.g. "provider_groups__in"
|
||||
* @param values - The selected values array
|
||||
*/
|
||||
onBatchChange: (filterKey: string, values: string[]) => void;
|
||||
/**
|
||||
* Pending selected values controlled by the parent.
|
||||
* Reflects pending state before Apply is clicked.
|
||||
*/
|
||||
selectedValues: string[];
|
||||
}
|
||||
|
||||
/** Instant mode: URL-driven — neither callback nor controlled value. */
|
||||
interface ProviderGroupSelectorInstantProps
|
||||
extends ProviderGroupSelectorBaseProps {
|
||||
onBatchChange?: never;
|
||||
selectedValues?: never;
|
||||
}
|
||||
|
||||
type ProviderGroupSelectorProps =
|
||||
| ProviderGroupSelectorBatchProps
|
||||
| ProviderGroupSelectorInstantProps;
|
||||
|
||||
export function ProviderGroupSelector({
|
||||
groups,
|
||||
onBatchChange,
|
||||
selectedValues,
|
||||
search = {
|
||||
placeholder: "Search Provider Groups...",
|
||||
emptyMessage: "No Provider Groups found.",
|
||||
},
|
||||
paramsToDeleteOnChange = [],
|
||||
}: ProviderGroupSelectorProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const { navigateWithParams } = useUrlFilters();
|
||||
|
||||
const current = searchParams.get(URL_FILTER_KEY) || "";
|
||||
const urlSelectedIds = current ? current.split(",").filter(Boolean) : [];
|
||||
|
||||
// In batch mode, use the parent-controlled pending values; otherwise, use URL state.
|
||||
const selectedIds = onBatchChange ? selectedValues : urlSelectedIds;
|
||||
|
||||
const handleMultiValueChange = (ids: string[]) => {
|
||||
if (onBatchChange) {
|
||||
onBatchChange(PROVIDER_GROUP_FILTER_KEY, ids);
|
||||
return;
|
||||
}
|
||||
navigateWithParams((params) => {
|
||||
if (ids.length > 0) {
|
||||
params.set(URL_FILTER_KEY, ids.join(","));
|
||||
} else {
|
||||
params.delete(URL_FILTER_KEY);
|
||||
}
|
||||
paramsToDeleteOnChange.forEach((key) => params.delete(key));
|
||||
});
|
||||
};
|
||||
|
||||
const selectedLabel = () => {
|
||||
if (selectedIds.length === 0) return null;
|
||||
if (selectedIds.length === 1) {
|
||||
const group = groups.find((g) => g.id === selectedIds[0]);
|
||||
return (
|
||||
<span className="truncate">
|
||||
{group ? group.attributes.name : selectedIds[0]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="truncate">
|
||||
{selectedIds.length} Provider Groups selected
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<label
|
||||
htmlFor="provider-group-selector"
|
||||
className="sr-only"
|
||||
id="provider-group-label"
|
||||
>
|
||||
Filter by Provider Group. Select one or more Provider Groups to filter
|
||||
results.
|
||||
</label>
|
||||
<MultiSelect values={selectedIds} onValuesChange={handleMultiValueChange}>
|
||||
<MultiSelectTrigger
|
||||
id="provider-group-selector"
|
||||
aria-labelledby="provider-group-label"
|
||||
>
|
||||
{selectedLabel() || (
|
||||
<MultiSelectValue placeholder="All Provider Groups" />
|
||||
)}
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent search={search}>
|
||||
{/* No items when empty: the MultiSelect's own emptyMessage is the
|
||||
single empty state (avoids a duplicate "none" message). */}
|
||||
{groups.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
role="option"
|
||||
aria-selected={selectedIds.length === 0}
|
||||
aria-disabled={selectedIds.length === 0}
|
||||
aria-label="Select all Provider Groups (clears current selection to show all)"
|
||||
tabIndex={0}
|
||||
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:hover:bg-slate-700/50"
|
||||
onClick={() => {
|
||||
if (selectedIds.length === 0) return;
|
||||
handleMultiValueChange([]);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (selectedIds.length === 0) return;
|
||||
handleMultiValueChange([]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectedIds.length === 0 ? "All selected" : "Select All"}
|
||||
</div>
|
||||
{groups.map((group) => (
|
||||
<MultiSelectItem
|
||||
key={group.id}
|
||||
value={group.id}
|
||||
badgeLabel={group.attributes.name}
|
||||
keywords={[group.attributes.name]}
|
||||
aria-label={`${group.attributes.name} Provider Group`}
|
||||
>
|
||||
<span className="truncate">{group.attributes.name}</span>
|
||||
</MultiSelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</MultiSelectContent>
|
||||
</MultiSelect>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,12 +14,14 @@ import {
|
||||
FilterSummaryStrip,
|
||||
} from "@/components/filters/filter-summary-strip";
|
||||
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
|
||||
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { ExpandableSection } from "@/components/ui/expandable-section";
|
||||
import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom";
|
||||
import { useFilterBatch } from "@/hooks/use-filter-batch";
|
||||
import { getCategoryLabel, getGroupLabel } from "@/lib/categories";
|
||||
import { FilterType, ScanEntity } from "@/types";
|
||||
import { FILTER_FIELD, ScanEntity } from "@/types";
|
||||
import { ProviderGroup } from "@/types/components";
|
||||
import { DATA_TABLE_FILTER_MODE } from "@/types/filters";
|
||||
import { ProviderProps } from "@/types/providers";
|
||||
|
||||
@@ -31,6 +33,8 @@ import {
|
||||
interface FindingsFiltersProps {
|
||||
/** Provider data for provider/account filter controls. */
|
||||
providers: ProviderProps[];
|
||||
/** Provider groups for the provider group filter control. */
|
||||
providerGroups?: ProviderGroup[];
|
||||
completedScanIds: string[];
|
||||
scanDetails: { [key: string]: ScanEntity }[];
|
||||
uniqueRegions: string[];
|
||||
@@ -70,6 +74,10 @@ const FILTER_GRID_ITEM_CLASS = "min-w-0";
|
||||
|
||||
export const FindingsFilterBatchControls = ({
|
||||
providers,
|
||||
// Undefined = caller opted out (the alert editor shares this component but
|
||||
// loads no groups); an empty array still renders the control, so it stays
|
||||
// visible even when a tenant has no groups yet.
|
||||
providerGroups,
|
||||
completedScanIds,
|
||||
scanDetails,
|
||||
uniqueRegions,
|
||||
@@ -97,7 +105,7 @@ export const FindingsFilterBatchControls = ({
|
||||
|
||||
const customFilters = [
|
||||
...filterFindings
|
||||
.filter((filter) => !isAlertsEdit || filter.key !== FilterType.STATUS)
|
||||
.filter((filter) => !isAlertsEdit || filter.key !== FILTER_FIELD.STATUS)
|
||||
.map((filter) => ({
|
||||
...filter,
|
||||
labelFormatter: (value: string) =>
|
||||
@@ -107,32 +115,32 @@ export const FindingsFilterBatchControls = ({
|
||||
}),
|
||||
})),
|
||||
{
|
||||
key: FilterType.REGION,
|
||||
key: FILTER_FIELD.REGION,
|
||||
labelCheckboxGroup: "Regions",
|
||||
values: uniqueRegions,
|
||||
index: 3,
|
||||
},
|
||||
{
|
||||
key: FilterType.SERVICE,
|
||||
key: FILTER_FIELD.SERVICE,
|
||||
labelCheckboxGroup: "Services",
|
||||
values: uniqueServices,
|
||||
index: 4,
|
||||
},
|
||||
{
|
||||
key: FilterType.RESOURCE_TYPE,
|
||||
key: FILTER_FIELD.RESOURCE_TYPE,
|
||||
labelCheckboxGroup: "Resource Type",
|
||||
values: uniqueResourceTypes,
|
||||
index: 8,
|
||||
},
|
||||
{
|
||||
key: FilterType.CATEGORY,
|
||||
key: FILTER_FIELD.CATEGORY,
|
||||
labelCheckboxGroup: "Category",
|
||||
values: uniqueCategories,
|
||||
labelFormatter: getCategoryLabel,
|
||||
index: 5,
|
||||
},
|
||||
{
|
||||
key: FilterType.RESOURCE_GROUPS,
|
||||
key: FILTER_FIELD.RESOURCE_GROUPS,
|
||||
labelCheckboxGroup: "Resource Group",
|
||||
values: uniqueGroups,
|
||||
labelFormatter: getGroupLabel,
|
||||
@@ -142,14 +150,14 @@ export const FindingsFilterBatchControls = ({
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: FilterType.SCAN,
|
||||
key: FILTER_FIELD.SCAN,
|
||||
labelCheckboxGroup: "Scan ID",
|
||||
values: completedScanIds,
|
||||
width: "wide" as const,
|
||||
valueLabelMapping: scanDetails,
|
||||
labelFormatter: (value: string) =>
|
||||
getFindingsFilterDisplayValue(
|
||||
`filter[${FilterType.SCAN}]`,
|
||||
`filter[${FILTER_FIELD.SCAN}]`,
|
||||
value,
|
||||
{
|
||||
providers,
|
||||
@@ -167,6 +175,7 @@ export const FindingsFilterBatchControls = ({
|
||||
appliedFilters,
|
||||
{
|
||||
providers,
|
||||
providerGroups,
|
||||
scans: scanDetails,
|
||||
},
|
||||
);
|
||||
@@ -174,6 +183,7 @@ export const FindingsFilterBatchControls = ({
|
||||
changedFilters,
|
||||
{
|
||||
providers,
|
||||
providerGroups,
|
||||
scans: scanDetails,
|
||||
},
|
||||
);
|
||||
@@ -199,15 +209,26 @@ export const FindingsFilterBatchControls = ({
|
||||
: undefined;
|
||||
|
||||
const providerAccountControls = (className: string) => (
|
||||
<ProviderAccountSelectors
|
||||
providers={providers}
|
||||
mode="batch"
|
||||
selectedProviderTypes={getFilterValue("filter[provider_type__in]")}
|
||||
selectedAccounts={getFilterValue("filter[provider_id__in]")}
|
||||
onBatchChange={setPending}
|
||||
providerSelectorClassName={className}
|
||||
accountSelectorClassName={className}
|
||||
/>
|
||||
<>
|
||||
<ProviderAccountSelectors
|
||||
providers={providers}
|
||||
mode="batch"
|
||||
selectedProviderTypes={getFilterValue("filter[provider_type__in]")}
|
||||
selectedAccounts={getFilterValue("filter[provider_id__in]")}
|
||||
onBatchChange={setPending}
|
||||
providerSelectorClassName={className}
|
||||
accountSelectorClassName={className}
|
||||
/>
|
||||
{providerGroups !== undefined && (
|
||||
<div className={className}>
|
||||
<ProviderGroupSelector
|
||||
groups={providerGroups}
|
||||
selectedValues={getFilterValue("filter[provider_groups__in]")}
|
||||
onBatchChange={setPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const alertEditFilterGrid = hasCustomFilters ? (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ProviderGroup } from "@/types/components";
|
||||
import { ProviderProps } from "@/types/providers";
|
||||
import { ScanEntity } from "@/types/scans";
|
||||
|
||||
@@ -8,6 +9,19 @@ import {
|
||||
getFindingsFilterDisplayValue,
|
||||
} from "./findings-filters.utils";
|
||||
|
||||
const providerGroups: ProviderGroup[] = [
|
||||
{
|
||||
type: "provider-groups",
|
||||
id: "group-1",
|
||||
attributes: { name: "Production", inserted_at: "", updated_at: "" },
|
||||
relationships: {
|
||||
providers: { meta: { count: 0 }, data: [] },
|
||||
roles: { meta: { count: 0 }, data: [] },
|
||||
},
|
||||
links: { self: "" },
|
||||
},
|
||||
];
|
||||
|
||||
function makeProvider(
|
||||
overrides: Partial<ProviderProps> & { id: string },
|
||||
): ProviderProps {
|
||||
@@ -98,6 +112,24 @@ describe("getFindingsFilterDisplayValue", () => {
|
||||
).toBe("missing-provider");
|
||||
});
|
||||
|
||||
it("shows the provider group name for provider_groups filters instead of the raw group id", () => {
|
||||
expect(
|
||||
getFindingsFilterDisplayValue("filter[provider_groups__in]", "group-1", {
|
||||
providerGroups,
|
||||
}),
|
||||
).toBe("Production");
|
||||
});
|
||||
|
||||
it("keeps the raw value when the provider group cannot be resolved", () => {
|
||||
expect(
|
||||
getFindingsFilterDisplayValue(
|
||||
"filter[provider_groups__in]",
|
||||
"missing-group",
|
||||
{ providerGroups },
|
||||
),
|
||||
).toBe("missing-group");
|
||||
});
|
||||
|
||||
it("shows the resolved scan badge label for scan filters instead of formatting the raw scan id", () => {
|
||||
expect(
|
||||
getFindingsFilterDisplayValue("filter[scan__in]", "scan-1", { scans }),
|
||||
@@ -230,6 +262,22 @@ describe("buildFindingsFilterChips", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("labels provider group chips and resolves their names", () => {
|
||||
const chips = buildFindingsFilterChips(
|
||||
{ "filter[provider_groups__in]": ["group-1"] },
|
||||
{ providerGroups },
|
||||
);
|
||||
|
||||
expect(chips).toEqual([
|
||||
{
|
||||
key: "filter[provider_groups__in]",
|
||||
label: "Provider Group",
|
||||
value: "group-1",
|
||||
displayValue: "Production",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats filter[delta] and filter[delta__in] identically", () => {
|
||||
// Given
|
||||
const chipsSingular = buildFindingsFilterChips({
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { FindingsFilterParam } from "@/actions/findings/findings-filters";
|
||||
import type { FilterChip } from "@/components/filters/filter-summary-strip";
|
||||
import { formatLabel, getCategoryLabel, getGroupLabel } from "@/lib/categories";
|
||||
import { getScanEntityLabel } from "@/lib/helper-filters";
|
||||
import {
|
||||
getProviderGroupDisplayValue,
|
||||
getScanEntityLabel,
|
||||
} from "@/lib/helper-filters";
|
||||
import { FINDING_STATUS_DISPLAY_NAMES } from "@/types";
|
||||
import { FilterParam } from "@/types/filters";
|
||||
import { ProviderGroup } from "@/types/components";
|
||||
import { getProviderDisplayName, ProviderProps } from "@/types/providers";
|
||||
import { ScanEntity } from "@/types/scans";
|
||||
import { SEVERITY_DISPLAY_NAMES } from "@/types/severities";
|
||||
@@ -10,6 +14,7 @@ import { SEVERITY_DISPLAY_NAMES } from "@/types/severities";
|
||||
interface GetFindingsFilterDisplayValueOptions {
|
||||
providers?: ProviderProps[];
|
||||
scans?: Array<{ [scanId: string]: ScanEntity }>;
|
||||
providerGroups?: ProviderGroup[];
|
||||
}
|
||||
|
||||
const FINDING_DELTA_DISPLAY_NAMES: Record<string, string> = {
|
||||
@@ -42,7 +47,7 @@ function getScanDisplayValue(
|
||||
}
|
||||
|
||||
export function getFindingsFilterDisplayValue(
|
||||
filterKey: string,
|
||||
filterKey: FindingsFilterParam,
|
||||
value: string,
|
||||
options: GetFindingsFilterDisplayValueOptions = {},
|
||||
): string {
|
||||
@@ -53,6 +58,9 @@ export function getFindingsFilterDisplayValue(
|
||||
if (filterKey === "filter[provider_id__in]") {
|
||||
return getProviderAccountDisplayValue(value, options.providers || []);
|
||||
}
|
||||
if (filterKey === "filter[provider_groups__in]") {
|
||||
return getProviderGroupDisplayValue(value, options.providerGroups || []);
|
||||
}
|
||||
if (filterKey === "filter[scan__in]" || filterKey === "filter[scan]") {
|
||||
return getScanDisplayValue(value, options.scans || []);
|
||||
}
|
||||
@@ -95,12 +103,14 @@ export function getFindingsFilterDisplayValue(
|
||||
/**
|
||||
* Maps raw filter param keys (e.g. "filter[severity__in]") to human-readable labels.
|
||||
* Used to render chips in the FilterSummaryStrip.
|
||||
* Typed as Record<FilterParam, string> so TypeScript enforces exhaustiveness — any
|
||||
* addition to FilterParam will cause a compile error here if the label is missing.
|
||||
* Typed as Record<FindingsFilterParam, string> so TypeScript enforces exhaustiveness
|
||||
* — any addition to the findings filter set will cause a compile error here if the
|
||||
* label is missing.
|
||||
*/
|
||||
export const FILTER_KEY_LABELS: Record<FilterParam, string> = {
|
||||
export const FILTER_KEY_LABELS: Record<FindingsFilterParam, string> = {
|
||||
"filter[provider_type__in]": "Provider",
|
||||
"filter[provider_id__in]": "Account",
|
||||
"filter[provider_groups__in]": "Provider Group",
|
||||
"filter[severity__in]": "Severity",
|
||||
"filter[status__in]": "Status",
|
||||
"filter[delta__in]": "Delta",
|
||||
@@ -115,12 +125,15 @@ export const FILTER_KEY_LABELS: Record<FilterParam, string> = {
|
||||
"filter[scan_id]": "Scan",
|
||||
"filter[scan_id__in]": "Scan",
|
||||
"filter[inserted_at]": "Date",
|
||||
"filter[inserted_at__gte]": "Date",
|
||||
"filter[inserted_at__lte]": "Date",
|
||||
"filter[muted]": "Muted",
|
||||
};
|
||||
|
||||
interface BuildFindingsFilterChipsOptions {
|
||||
providers?: ProviderProps[];
|
||||
scans?: Array<{ [scanId: string]: ScanEntity }>;
|
||||
providerGroups?: ProviderGroup[];
|
||||
includeMuted?: boolean;
|
||||
}
|
||||
|
||||
@@ -142,13 +155,13 @@ export function buildFindingsFilterChips(
|
||||
Object.entries(pendingFilters).forEach(([key, values]) => {
|
||||
if (!values || values.length === 0) return;
|
||||
if (key === "filter[muted]" && !options.includeMuted) return;
|
||||
const label = FILTER_KEY_LABELS[key as FilterParam] ?? key;
|
||||
const label = FILTER_KEY_LABELS[key as FindingsFilterParam] ?? key;
|
||||
|
||||
const visibleValues = values;
|
||||
if (visibleValues.length === 0) return;
|
||||
|
||||
const displayValues = visibleValues.map((value) =>
|
||||
getFindingsFilterDisplayValue(key, value, options),
|
||||
getFindingsFilterDisplayValue(key as FindingsFilterParam, value, options),
|
||||
);
|
||||
|
||||
const chip: FilterChip = {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
getTourTargetSelector,
|
||||
} from "@/lib/tours/use-driver-tour";
|
||||
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
|
||||
import type { ProviderGroup } from "@/types/components";
|
||||
import type { ProvidersTableRow } from "@/types/providers-table";
|
||||
import type { ScanScheduleCapability } from "@/types/schedules";
|
||||
|
||||
@@ -51,6 +52,7 @@ interface ProvidersAccountsViewProps {
|
||||
filters: FilterOption[];
|
||||
metadata?: MetaDataProps;
|
||||
providers: ProviderProps[];
|
||||
providerGroups?: ProviderGroup[];
|
||||
rows: ProvidersTableRow[];
|
||||
/** Cloud overlay seam for provider-creation scan launch. */
|
||||
scanScheduleCapability?: ScanScheduleCapability;
|
||||
@@ -62,6 +64,7 @@ export function ProvidersAccountsView({
|
||||
filters,
|
||||
metadata,
|
||||
providers,
|
||||
providerGroups = [],
|
||||
rows,
|
||||
scanScheduleCapability,
|
||||
isScanLimitReached,
|
||||
@@ -141,6 +144,7 @@ export function ProvidersAccountsView({
|
||||
<ProvidersFilters
|
||||
filters={filters}
|
||||
providers={providers}
|
||||
providerGroups={providerGroups}
|
||||
actions={
|
||||
<>
|
||||
<MutedFindingsConfigButton />
|
||||
|
||||
@@ -16,6 +16,10 @@ vi.mock("@/app/(prowler)/_overview/_components/provider-type-selector", () => ({
|
||||
ProviderTypeSelector: () => <div>Provider type selector</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/filters/provider-group-selector", () => ({
|
||||
ProviderGroupSelector: () => <div>Provider group selector</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/filters/clear-filters-button", () => ({
|
||||
ClearFiltersButton: () => <button type="button">Clear</button>,
|
||||
}));
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ReactNode } from "react";
|
||||
|
||||
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
|
||||
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
|
||||
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
|
||||
import {
|
||||
MultiSelect,
|
||||
MultiSelectContent,
|
||||
@@ -18,6 +19,7 @@ import { EntityInfo } from "@/components/ui/entities/entity-info";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import { isConnectionStatus, isGroupFilterEntity } from "@/lib/helper-filters";
|
||||
import { FilterEntity, FilterOption, ProviderEntity } from "@/types";
|
||||
import { ProviderGroup } from "@/types/components";
|
||||
import {
|
||||
GroupFilterEntity,
|
||||
ProviderConnectionStatus,
|
||||
@@ -31,12 +33,14 @@ function isNonEmptyString(value: string | null | undefined): value is string {
|
||||
interface ProvidersFiltersProps {
|
||||
filters: FilterOption[];
|
||||
providers: ProviderProps[];
|
||||
providerGroups?: ProviderGroup[];
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export const ProvidersFilters = ({
|
||||
filters,
|
||||
providers,
|
||||
providerGroups = [],
|
||||
actions,
|
||||
}: ProvidersFiltersProps) => {
|
||||
const { updateFilter } = useUrlFilters();
|
||||
@@ -153,6 +157,9 @@ export const ProvidersFilters = ({
|
||||
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
|
||||
<ProviderTypeSelector providers={providers} />
|
||||
</div>
|
||||
<div className="max-w-[240px] min-w-[180px] flex-1">
|
||||
<ProviderGroupSelector groups={providerGroups} />
|
||||
</div>
|
||||
{sortedFilters.map((filter) => {
|
||||
const selectedValues = getSelectedValues(filter);
|
||||
return (
|
||||
|
||||
@@ -11,11 +11,13 @@ import {
|
||||
FilterSummaryStrip,
|
||||
} from "@/components/filters/filter-summary-strip";
|
||||
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
|
||||
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { ExpandableSection } from "@/components/ui/expandable-section";
|
||||
import { DataTableFilterCustom } from "@/components/ui/table";
|
||||
import { useFilterBatch } from "@/hooks/use-filter-batch";
|
||||
import { getGroupLabel } from "@/lib/categories";
|
||||
import { ProviderGroup } from "@/types/components";
|
||||
import { DATA_TABLE_FILTER_MODE } from "@/types/filters";
|
||||
import { ProviderProps } from "@/types/providers";
|
||||
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
|
||||
interface ResourcesFiltersProps {
|
||||
providers: ProviderProps[];
|
||||
providerGroups?: ProviderGroup[];
|
||||
uniqueRegions: string[];
|
||||
uniqueServices: string[];
|
||||
uniqueResourceTypes: string[];
|
||||
@@ -40,6 +43,7 @@ const FILTER_CONTROL_COLUMN_CLASS =
|
||||
|
||||
export const ResourcesFilters = ({
|
||||
providers,
|
||||
providerGroups = [],
|
||||
uniqueRegions,
|
||||
uniqueServices,
|
||||
uniqueResourceTypes,
|
||||
@@ -93,10 +97,12 @@ export const ResourcesFilters = ({
|
||||
const appliedFilterChips: FilterChip[] = buildResourcesFilterChips(
|
||||
appliedFilters,
|
||||
providers,
|
||||
providerGroups,
|
||||
);
|
||||
const pendingFilterChips: FilterChip[] = buildResourcesFilterChips(
|
||||
changedFilters,
|
||||
providers,
|
||||
providerGroups,
|
||||
);
|
||||
const appliedCount = countVisibleFilterKeys(appliedFilters);
|
||||
const showAppliedRow = appliedFilterChips.length > 0;
|
||||
@@ -178,6 +184,13 @@ export const ResourcesFilters = ({
|
||||
providerSelectorClassName={FILTER_CONTROL_COLUMN_CLASS}
|
||||
accountSelectorClassName={FILTER_CONTROL_COLUMN_CLASS}
|
||||
/>
|
||||
<div className={FILTER_CONTROL_COLUMN_CLASS}>
|
||||
<ProviderGroupSelector
|
||||
groups={providerGroups}
|
||||
selectedValues={getFilterValue("filter[provider_groups__in]")}
|
||||
onBatchChange={setPending}
|
||||
/>
|
||||
</div>
|
||||
{hasCustomFilters && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ProviderGroup } from "@/types/components";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
import {
|
||||
buildResourcesFilterChips,
|
||||
getResourcesFilterDisplayValue,
|
||||
} from "./resources-filters.utils";
|
||||
|
||||
const providerGroups: ProviderGroup[] = [
|
||||
{
|
||||
type: "provider-groups",
|
||||
id: "group-1",
|
||||
attributes: { name: "Production", inserted_at: "", updated_at: "" },
|
||||
relationships: {
|
||||
providers: { meta: { count: 0 }, data: [] },
|
||||
roles: { meta: { count: 0 }, data: [] },
|
||||
},
|
||||
links: { self: "" },
|
||||
},
|
||||
];
|
||||
|
||||
const providers: ProviderProps[] = [];
|
||||
|
||||
describe("getResourcesFilterDisplayValue", () => {
|
||||
it("shows the provider group name for provider_groups filters", () => {
|
||||
expect(
|
||||
getResourcesFilterDisplayValue(
|
||||
"filter[provider_groups__in]",
|
||||
"group-1",
|
||||
providers,
|
||||
providerGroups,
|
||||
),
|
||||
).toBe("Production");
|
||||
});
|
||||
|
||||
it("keeps the raw value when the provider group cannot be resolved", () => {
|
||||
expect(
|
||||
getResourcesFilterDisplayValue(
|
||||
"filter[provider_groups__in]",
|
||||
"missing-group",
|
||||
providers,
|
||||
providerGroups,
|
||||
),
|
||||
).toBe("missing-group");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildResourcesFilterChips", () => {
|
||||
it("labels provider group chips and resolves their names", () => {
|
||||
const chips = buildResourcesFilterChips(
|
||||
{ "filter[provider_groups__in]": ["group-1"] },
|
||||
providers,
|
||||
providerGroups,
|
||||
);
|
||||
|
||||
expect(chips).toEqual([
|
||||
{
|
||||
key: "filter[provider_groups__in]",
|
||||
label: "Provider Group",
|
||||
value: "group-1",
|
||||
displayValue: "Production",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { ResourcesFilterParam } from "@/actions/resources/resources-filters";
|
||||
import type { FilterChip } from "@/components/filters/filter-summary-strip";
|
||||
import { formatLabel, getGroupLabel } from "@/lib/categories";
|
||||
import { getProviderGroupDisplayValue } from "@/lib/helper-filters";
|
||||
import type { ProviderGroup } from "@/types/components";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
import { getProviderDisplayName } from "@/types/providers";
|
||||
|
||||
const RESOURCE_FILTER_KEY_LABELS: Record<string, string> = {
|
||||
const RESOURCE_FILTER_KEY_LABELS: Record<ResourcesFilterParam, string> = {
|
||||
"filter[provider_type__in]": "Provider",
|
||||
"filter[provider_id__in]": "Account",
|
||||
"filter[provider_groups__in]": "Provider Group",
|
||||
"filter[region__in]": "Region",
|
||||
"filter[service__in]": "Service",
|
||||
"filter[type__in]": "Type",
|
||||
@@ -28,6 +32,7 @@ export function getResourcesFilterDisplayValue(
|
||||
filterKey: string,
|
||||
value: string,
|
||||
providers: ProviderProps[],
|
||||
providerGroups: ProviderGroup[] = [],
|
||||
): string {
|
||||
if (!value) return value;
|
||||
|
||||
@@ -39,6 +44,10 @@ export function getResourcesFilterDisplayValue(
|
||||
return getProviderAccountDisplayValue(value, providers);
|
||||
}
|
||||
|
||||
if (filterKey === "filter[provider_groups__in]") {
|
||||
return getProviderGroupDisplayValue(value, providerGroups);
|
||||
}
|
||||
|
||||
if (filterKey === "filter[groups__in]") {
|
||||
return getGroupLabel(value);
|
||||
}
|
||||
@@ -53,15 +62,17 @@ export function getResourcesFilterDisplayValue(
|
||||
export function buildResourcesFilterChips(
|
||||
pendingFilters: Record<string, string[]>,
|
||||
providers: ProviderProps[],
|
||||
providerGroups: ProviderGroup[] = [],
|
||||
): FilterChip[] {
|
||||
const chips: FilterChip[] = [];
|
||||
|
||||
Object.entries(pendingFilters).forEach(([key, values]) => {
|
||||
if (!values || values.length === 0) return;
|
||||
|
||||
const label = RESOURCE_FILTER_KEY_LABELS[key] ?? key;
|
||||
const label =
|
||||
RESOURCE_FILTER_KEY_LABELS[key as ResourcesFilterParam] ?? key;
|
||||
const displayValues = values.map((value) =>
|
||||
getResourcesFilterDisplayValue(key, value, providers),
|
||||
getResourcesFilterDisplayValue(key, value, providers, providerGroups),
|
||||
);
|
||||
|
||||
const chip: FilterChip = {
|
||||
|
||||
@@ -9,6 +9,10 @@ vi.mock("@/components/filters/provider-account-selectors", () => ({
|
||||
ProviderAccountSelectors: () => <div>Provider account selectors</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/filters/provider-group-selector", () => ({
|
||||
ProviderGroupSelector: () => <div>Provider group selector</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Select: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
|
||||
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/shadcn";
|
||||
import { SCAN_JOBS_TAB, type ScanJobsTab } from "@/types";
|
||||
import type { ProviderGroup } from "@/types/components";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
import {
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
|
||||
interface ScansFilterBarProps {
|
||||
providers: ProviderProps[];
|
||||
providerGroups?: ProviderGroup[];
|
||||
activeTab: ScanJobsTab;
|
||||
scheduleType: string;
|
||||
scanStatus: string;
|
||||
@@ -30,6 +33,7 @@ const filterItemClass = "w-full md:w-[calc(50%-0.375rem)] xl:w-60";
|
||||
|
||||
export function ScansFilterBar({
|
||||
providers,
|
||||
providerGroups = [],
|
||||
activeTab,
|
||||
scheduleType,
|
||||
scanStatus,
|
||||
@@ -53,6 +57,13 @@ export function ScansFilterBar({
|
||||
accountSelectorClassName={filterItemClass}
|
||||
/>
|
||||
|
||||
<div className={filterItemClass}>
|
||||
<ProviderGroupSelector
|
||||
groups={providerGroups}
|
||||
paramsToDeleteOnChange={["page", "scanId"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showScheduleTypeFilter && (
|
||||
<Select value={scheduleType} onValueChange={onScheduleTypeChange}>
|
||||
<SelectTrigger aria-label="All Types" className={filterItemClass}>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { buildViewFirstScanTour } from "@/lib/tours/view-first-scan.tour";
|
||||
import { useScansStore } from "@/store";
|
||||
import { SCAN_JOBS_TAB, SCAN_TAB_LABELS, type ScanJobsTab } from "@/types";
|
||||
import type { ProviderGroup } from "@/types/components";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
import type { ScanScheduleCapability } from "@/types/schedules";
|
||||
|
||||
@@ -32,6 +33,7 @@ import { useScansFilters } from "./use-scans-filters";
|
||||
|
||||
interface ScansPageShellProps {
|
||||
providers: ProviderProps[];
|
||||
providerGroups?: ProviderGroup[];
|
||||
hasManageScansPermission: boolean;
|
||||
activeScanCount?: number;
|
||||
children: ReactNode;
|
||||
@@ -42,6 +44,7 @@ interface ScansPageShellProps {
|
||||
|
||||
export function ScansPageShell({
|
||||
providers,
|
||||
providerGroups = [],
|
||||
hasManageScansPermission,
|
||||
activeScanCount = 0,
|
||||
children,
|
||||
@@ -116,6 +119,7 @@ export function ScansPageShell({
|
||||
>
|
||||
<ScansFilterBar
|
||||
providers={providers}
|
||||
providerGroups={providerGroups}
|
||||
activeTab={filters.activeTab}
|
||||
scheduleType={filters.scheduleType}
|
||||
scanStatus={filters.scanStatus}
|
||||
|
||||
@@ -62,22 +62,22 @@ describe("useFilterBatch", () => {
|
||||
expect(result.current.hasChanges).toBe(false);
|
||||
});
|
||||
|
||||
it("should expose filter[delta]=new under the FilterType.DELTA key so the dropdown shows it selected", async () => {
|
||||
it("should expose filter[delta]=new under the FILTER_FIELD.DELTA key so the dropdown shows it selected", async () => {
|
||||
// Given — URL from LinkToFindings uses `filter[delta]` (singular), matching the API.
|
||||
setSearchParams({
|
||||
"filter[status__in]": "FAIL",
|
||||
"filter[delta]": "new",
|
||||
});
|
||||
|
||||
const { FilterType } = await import("@/types/filters");
|
||||
const { FILTER_FIELD } = await import("@/types/filters");
|
||||
|
||||
// When
|
||||
const { result } = renderHook(() => useFilterBatch());
|
||||
|
||||
// Then — the Delta dropdown reads via getFilterValue(`filter[${FilterType.DELTA}]`).
|
||||
// Then — the Delta dropdown reads via getFilterValue(`filter[${FILTER_FIELD.DELTA}]`).
|
||||
// For the checkbox of "new" to appear checked, that lookup must return ["new"].
|
||||
expect(
|
||||
result.current.getFilterValue(`filter[${FilterType.DELTA}]`),
|
||||
result.current.getFilterValue(`filter[${FILTER_FIELD.DELTA}]`),
|
||||
).toEqual(["new"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { isScanEntity } from "@/lib/helper-filters";
|
||||
import {
|
||||
FILTER_FIELD,
|
||||
FilterEntity,
|
||||
FilterType,
|
||||
FilterParam,
|
||||
ProviderEntity,
|
||||
ProviderType,
|
||||
ScanEntity,
|
||||
@@ -16,7 +17,9 @@ interface UseRelatedFiltersProps {
|
||||
completedScanIds?: string[];
|
||||
scanDetails?: { [key: string]: ScanEntity }[];
|
||||
enableScanRelation?: boolean;
|
||||
providerFilterType?: FilterType.PROVIDER | FilterType.PROVIDER_UID;
|
||||
providerFilterType?:
|
||||
| typeof FILTER_FIELD.PROVIDER
|
||||
| typeof FILTER_FIELD.PROVIDER_UID;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,15 +41,17 @@ export const useRelatedFilters = ({
|
||||
completedScanIds = [],
|
||||
scanDetails = [],
|
||||
enableScanRelation = false,
|
||||
providerFilterType = FilterType.PROVIDER,
|
||||
providerFilterType = FILTER_FIELD.PROVIDER,
|
||||
}: UseRelatedFiltersProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const providers = providerIds.length > 0 ? providerIds : providerUIDs;
|
||||
|
||||
const providerParam = searchParams.get(`filter[${providerFilterType}]`);
|
||||
const providerParam = searchParams.get(
|
||||
`filter[${providerFilterType}]` satisfies FilterParam,
|
||||
);
|
||||
const providerTypeParam = searchParams.get(
|
||||
`filter[${FilterType.PROVIDER_TYPE}]`,
|
||||
`filter[${FILTER_FIELD.PROVIDER_TYPE}]` satisfies FilterParam,
|
||||
);
|
||||
|
||||
const currentProviders = providerParam ? providerParam.split(",") : [];
|
||||
|
||||
@@ -35,4 +35,45 @@ describe("buildComplianceDetailPath", () => {
|
||||
expect(withRegion).toContain("filter%5Bregion__in%5D=eu-west-1");
|
||||
expect(withoutRegion).not.toContain("region__in");
|
||||
});
|
||||
|
||||
it("carries provider filters instead of scanId in aggregated mode", () => {
|
||||
const path = buildComplianceDetailPath({
|
||||
title: "ens",
|
||||
complianceId: "ens_rd2022_aws",
|
||||
version: "RD2022",
|
||||
providerFilters: {
|
||||
"filter[provider_type__in]": "aws",
|
||||
"filter[provider_groups__in]": "g1,g2",
|
||||
},
|
||||
});
|
||||
|
||||
expect(path).toContain("filter%5Bprovider_type__in%5D=aws");
|
||||
expect(path).toContain("filter%5Bprovider_groups__in%5D=g1%2Cg2");
|
||||
expect(path).not.toContain("scanId");
|
||||
});
|
||||
|
||||
it("omits scanId when provider filters are present (XOR)", () => {
|
||||
const path = buildComplianceDetailPath({
|
||||
title: "ens",
|
||||
complianceId: "ens_rd2022_aws",
|
||||
version: "RD2022",
|
||||
scanId: "scan-1",
|
||||
providerFilters: { "filter[provider_type__in]": "aws" },
|
||||
});
|
||||
|
||||
expect(path).not.toContain("scanId");
|
||||
expect(path).toContain("filter%5Bprovider_type__in%5D=aws");
|
||||
});
|
||||
|
||||
it("propagates region alongside provider filters", () => {
|
||||
const path = buildComplianceDetailPath({
|
||||
title: "ens",
|
||||
complianceId: "ens_rd2022_aws",
|
||||
version: "RD2022",
|
||||
providerFilters: { "filter[provider_type__in]": "aws" },
|
||||
regionFilter: "eu-west-1",
|
||||
});
|
||||
|
||||
expect(path).toContain("filter%5Bregion__in%5D=eu-west-1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import type { ComplianceProviderFilters } from "./compliance-provider-filters";
|
||||
|
||||
interface ComplianceDetailPathParams {
|
||||
/** Framework title as shown on the card (URL-encoded into the path). */
|
||||
title: string;
|
||||
complianceId: string;
|
||||
version: string;
|
||||
scanId: string;
|
||||
/** Single-scan scope. Omitted when provider filters drive aggregated mode. */
|
||||
scanId?: string | null;
|
||||
regionFilter?: string | null;
|
||||
/**
|
||||
* Aggregated-mode scope: provider-filter keys (e.g. filter[provider_type__in])
|
||||
* carried instead of scanId. XOR with scanId — when present, scanId is dropped.
|
||||
*/
|
||||
providerFilters?: ComplianceProviderFilters;
|
||||
}
|
||||
|
||||
/** Builds the `/compliance/[compliancetitle]` detail URL used by the overview cards. */
|
||||
@@ -14,11 +22,25 @@ export function buildComplianceDetailPath({
|
||||
version,
|
||||
scanId,
|
||||
regionFilter,
|
||||
providerFilters,
|
||||
}: ComplianceDetailPathParams): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set("complianceId", complianceId);
|
||||
params.set("version", version);
|
||||
params.set("scanId", scanId);
|
||||
|
||||
const providerEntries = providerFilters
|
||||
? Object.entries(providerFilters).filter(
|
||||
(entry): entry is [string, string] => Boolean(entry[1]),
|
||||
)
|
||||
: [];
|
||||
if (providerEntries.length > 0) {
|
||||
for (const [key, value] of providerEntries) {
|
||||
params.set(key, value);
|
||||
}
|
||||
} else if (scanId) {
|
||||
params.set("scanId", scanId);
|
||||
}
|
||||
|
||||
if (regionFilter) {
|
||||
params.set("filter[region__in]", regionFilter);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
COMPLIANCE_PROVIDER_FILTER_KEYS,
|
||||
extractComplianceProviderFilters,
|
||||
hasComplianceProviderFilters,
|
||||
} from "./compliance-provider-filters";
|
||||
|
||||
describe("COMPLIANCE_PROVIDER_FILTER_KEYS", () => {
|
||||
it("contains the three provider scope keys", () => {
|
||||
expect(COMPLIANCE_PROVIDER_FILTER_KEYS).toEqual([
|
||||
"filter[provider_type__in]",
|
||||
"filter[provider_id__in]",
|
||||
"filter[provider_groups__in]",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasComplianceProviderFilters", () => {
|
||||
for (const key of COMPLIANCE_PROVIDER_FILTER_KEYS) {
|
||||
it(`is true when ${key} is present (plain object)`, () => {
|
||||
expect(hasComplianceProviderFilters({ [key]: "abc" })).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
it("is true when reading from URLSearchParams", () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("filter[provider_groups__in]", "g1,g2");
|
||||
expect(hasComplianceProviderFilters(params)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for scanId / region / unrelated params", () => {
|
||||
expect(
|
||||
hasComplianceProviderFilters({
|
||||
scanId: "scan-1",
|
||||
"filter[region__in]": "eu-west-1",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("treats empty / whitespace values as absent", () => {
|
||||
expect(
|
||||
hasComplianceProviderFilters({
|
||||
"filter[provider_type__in]": "",
|
||||
"filter[provider_id__in]": " ",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for an empty object", () => {
|
||||
expect(hasComplianceProviderFilters({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractComplianceProviderFilters", () => {
|
||||
it("returns only the present, non-empty provider keys", () => {
|
||||
expect(
|
||||
extractComplianceProviderFilters({
|
||||
"filter[provider_type__in]": "aws,gcp",
|
||||
"filter[provider_id__in]": "",
|
||||
scanId: "scan-1",
|
||||
"filter[region__in]": "eu-west-1",
|
||||
}),
|
||||
).toEqual({ "filter[provider_type__in]": "aws,gcp" });
|
||||
});
|
||||
|
||||
it("joins array values with commas", () => {
|
||||
expect(
|
||||
extractComplianceProviderFilters({
|
||||
"filter[provider_groups__in]": ["g1", "g2"],
|
||||
}),
|
||||
).toEqual({ "filter[provider_groups__in]": "g1,g2" });
|
||||
});
|
||||
|
||||
it("reads from URLSearchParams", () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("filter[provider_id__in]", "p1,p2");
|
||||
expect(extractComplianceProviderFilters(params)).toEqual({
|
||||
"filter[provider_id__in]": "p1,p2",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an empty object when no provider filters are present", () => {
|
||||
expect(extractComplianceProviderFilters({ scanId: "scan-1" })).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ReadonlyURLSearchParams } from "next/navigation";
|
||||
|
||||
import type { SearchParamsProps } from "@/types/components";
|
||||
import { FILTER_FIELD, FilterParam } from "@/types/filters";
|
||||
|
||||
/**
|
||||
* Provider-scope filter fields the compliance UI sets — a subset of the shared
|
||||
* `FILTER_FIELD` source of truth, the same three the overview dashboard uses.
|
||||
*/
|
||||
const COMPLIANCE_PROVIDER_FILTER_FIELD = {
|
||||
PROVIDER_TYPE: FILTER_FIELD.PROVIDER_TYPE,
|
||||
PROVIDER_ID: FILTER_FIELD.PROVIDER_ID,
|
||||
PROVIDER_GROUPS: FILTER_FIELD.PROVIDER_GROUPS,
|
||||
} as const;
|
||||
|
||||
type ComplianceProviderFilterField =
|
||||
(typeof COMPLIANCE_PROVIDER_FILTER_FIELD)[keyof typeof COMPLIANCE_PROVIDER_FILTER_FIELD];
|
||||
|
||||
/**
|
||||
* Provider-scope filter param keys (e.g. `filter[provider_type__in]`). The
|
||||
* backend (`ComplianceOverviewViewSet`) treats these as an alternative to
|
||||
* `filter[scan_id]` (XOR) and aggregates compliance across the latest completed
|
||||
* scan of each matching provider.
|
||||
*/
|
||||
export type ComplianceProviderFilterParam =
|
||||
FilterParam<ComplianceProviderFilterField>;
|
||||
|
||||
/** Present, CSV-joined provider-scope filters (aggregated mode). */
|
||||
export type ComplianceProviderFilters = Partial<
|
||||
Record<ComplianceProviderFilterParam, string>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Filters the compliance server actions accept: the provider-scope keys above
|
||||
* (aggregated mode) or `filter[scan_id]` (single-scan mode) — XOR.
|
||||
*/
|
||||
export type ComplianceFilters = Partial<
|
||||
Record<ComplianceProviderFilterParam | FilterParam<"scan_id">, string>
|
||||
>;
|
||||
|
||||
export const COMPLIANCE_PROVIDER_FILTER_KEYS = [
|
||||
`filter[${COMPLIANCE_PROVIDER_FILTER_FIELD.PROVIDER_TYPE}]`,
|
||||
`filter[${COMPLIANCE_PROVIDER_FILTER_FIELD.PROVIDER_ID}]`,
|
||||
`filter[${COMPLIANCE_PROVIDER_FILTER_FIELD.PROVIDER_GROUPS}]`,
|
||||
] as const satisfies ReadonlyArray<ComplianceProviderFilterParam>;
|
||||
|
||||
/**
|
||||
* Accepts either an SSR plain search-params object or the client
|
||||
* `useSearchParams()` result (`ReadonlyURLSearchParams`), so callers don't need
|
||||
* to wrap the latter in a fresh `URLSearchParams`.
|
||||
*/
|
||||
type SearchParamsLike =
|
||||
| SearchParamsProps
|
||||
| URLSearchParams
|
||||
| ReadonlyURLSearchParams;
|
||||
|
||||
const readParam = (params: SearchParamsLike, key: string): string => {
|
||||
if (params instanceof URLSearchParams) {
|
||||
return params.get(key) ?? "";
|
||||
}
|
||||
const value = params[key];
|
||||
if (Array.isArray(value)) return value.join(",");
|
||||
return value ?? "";
|
||||
};
|
||||
|
||||
/** True when any compliance provider-scope filter is present and non-empty. */
|
||||
export const hasComplianceProviderFilters = (
|
||||
params: SearchParamsLike,
|
||||
): boolean =>
|
||||
COMPLIANCE_PROVIDER_FILTER_KEYS.some(
|
||||
(key) => readParam(params, key).trim().length > 0,
|
||||
);
|
||||
|
||||
/** Returns only the present, non-empty provider-scope filters (CSV-joined). */
|
||||
export const extractComplianceProviderFilters = (
|
||||
params: SearchParamsLike,
|
||||
): ComplianceProviderFilters => {
|
||||
const result: ComplianceProviderFilters = {};
|
||||
for (const key of COMPLIANCE_PROVIDER_FILTER_KEYS) {
|
||||
const value = readParam(params, key).trim();
|
||||
if (value.length > 0) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -1,14 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ProviderGroup } from "@/types/components";
|
||||
import type { ScanEntity } from "@/types/scans";
|
||||
|
||||
import {
|
||||
getProviderGroupDisplayValue,
|
||||
getScanEntityLabel,
|
||||
hasDateFilter,
|
||||
hasDateOrScanFilter,
|
||||
hasHistoricalFindingFilter,
|
||||
} from "./helper-filters";
|
||||
|
||||
const makeProviderGroup = (id: string, name: string): ProviderGroup =>
|
||||
({
|
||||
type: "provider-groups",
|
||||
id,
|
||||
attributes: { name, inserted_at: "", updated_at: "" },
|
||||
}) as ProviderGroup;
|
||||
|
||||
function makeScan(overrides: Partial<ScanEntity> = {}): ScanEntity {
|
||||
return {
|
||||
id: "scan-1",
|
||||
@@ -25,6 +34,27 @@ function makeScan(overrides: Partial<ScanEntity> = {}): ScanEntity {
|
||||
};
|
||||
}
|
||||
|
||||
describe("getProviderGroupDisplayValue", () => {
|
||||
const groups = [
|
||||
makeProviderGroup("g1", "Production"),
|
||||
makeProviderGroup("g2", "Staging"),
|
||||
];
|
||||
|
||||
it("resolves the group name when the id matches", () => {
|
||||
expect(getProviderGroupDisplayValue("g1", groups)).toBe("Production");
|
||||
});
|
||||
|
||||
it("falls back to the raw id when the group is not found", () => {
|
||||
expect(getProviderGroupDisplayValue("unknown", groups)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("falls back to the raw id when the group name is empty", () => {
|
||||
expect(
|
||||
getProviderGroupDisplayValue("g3", [makeProviderGroup("g3", "")]),
|
||||
).toBe("g3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasDateOrScanFilter", () => {
|
||||
it("returns true for scan filters", () => {
|
||||
expect(hasDateOrScanFilter({ "filter[scan__in]": "scan-1" })).toBe(true);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ProviderProps, ProvidersApiResponse, ScanProps } from "@/types";
|
||||
import { ProviderGroup } from "@/types/components";
|
||||
import { FilterEntity } from "@/types/filters";
|
||||
import {
|
||||
getProviderDisplayName,
|
||||
@@ -119,6 +120,19 @@ export function getScanEntityLabel(scan: ScanEntity): string {
|
||||
return providerLabel || scanName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the display name for a provider group filter value, falling back to
|
||||
* the raw id when the group can't be resolved. Shared by the findings and
|
||||
* resources filter utils so their chips stay in sync.
|
||||
*/
|
||||
export function getProviderGroupDisplayValue(
|
||||
groupId: string,
|
||||
groups: ProviderGroup[],
|
||||
): string {
|
||||
const group = groups.find((item) => item.id === groupId);
|
||||
return group?.attributes.name || groupId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a scan details mapping for filters from completed scans.
|
||||
* Used to provide detailed information for scan filters in the UI.
|
||||
|
||||
+32
-35
@@ -29,22 +29,35 @@ export interface CustomDropdownFilterProps {
|
||||
onFilterChange: (key: string, values: string[]) => void;
|
||||
}
|
||||
|
||||
export enum FilterType {
|
||||
SCAN = "scan__in",
|
||||
PROVIDER = "provider__in",
|
||||
PROVIDER_UID = "provider_uid__in",
|
||||
PROVIDER_TYPE = "provider_type__in",
|
||||
REGION = "region__in",
|
||||
SERVICE = "service__in",
|
||||
RESOURCE_TYPE = "resource_type__in",
|
||||
SEVERITY = "severity__in",
|
||||
STATUS = "status__in",
|
||||
/**
|
||||
* Filter field names — the inner part of a `filter[...]` URL param key, and the
|
||||
* `key` values used to build `FilterOption` dropdown configs. Single source of
|
||||
* truth for the `FilterParam` template; per-view modules compose their own field
|
||||
* set from these plus their own extras.
|
||||
*/
|
||||
export const FILTER_FIELD = {
|
||||
// core — provider scope + shared resource dimensions (used across views)
|
||||
PROVIDER_TYPE: "provider_type__in",
|
||||
PROVIDER_ID: "provider_id__in",
|
||||
PROVIDER_UID: "provider_uid__in",
|
||||
PROVIDER_GROUPS: "provider_groups__in",
|
||||
REGION: "region__in",
|
||||
SERVICE: "service__in",
|
||||
// view dimensions — dropdown configs (mostly findings; `provider__in` is the
|
||||
// providers-list type filter)
|
||||
PROVIDER: "provider__in",
|
||||
SCAN: "scan__in",
|
||||
RESOURCE_TYPE: "resource_type__in",
|
||||
SEVERITY: "severity__in",
|
||||
STATUS: "status__in",
|
||||
// The API only registers `delta` (exact, singular). `delta__in` is silently
|
||||
// dropped, so the dropdown, URL, and backend must all use `delta`.
|
||||
DELTA = "delta",
|
||||
CATEGORY = "category__in",
|
||||
RESOURCE_GROUPS = "resource_groups__in",
|
||||
}
|
||||
DELTA: "delta",
|
||||
CATEGORY: "category__in",
|
||||
RESOURCE_GROUPS: "resource_groups__in",
|
||||
} as const;
|
||||
|
||||
export type FilterField = (typeof FILTER_FIELD)[keyof typeof FILTER_FIELD];
|
||||
|
||||
/**
|
||||
* Controls the filter dispatch behavior of DataTableFilterCustom.
|
||||
@@ -60,25 +73,9 @@ export type DataTableFilterMode =
|
||||
(typeof DATA_TABLE_FILTER_MODE)[keyof typeof DATA_TABLE_FILTER_MODE];
|
||||
|
||||
/**
|
||||
* Exhaustive union of all URL filter param keys used in Findings filters.
|
||||
* Use this instead of `string` to ensure FILTER_KEY_LABELS and other
|
||||
* param-keyed records stay in sync with the actual filter surface.
|
||||
* URL filter param key template — wraps a field name in `filter[...]`.
|
||||
* Parameterize with a view's own field union (e.g. `FilterParam<FindingsFilterField>`)
|
||||
* so each view's param-keyed records stay in sync with the filters it supports.
|
||||
*/
|
||||
export type FilterParam =
|
||||
| "filter[provider_type__in]"
|
||||
| "filter[provider_id__in]"
|
||||
| "filter[severity__in]"
|
||||
| "filter[status__in]"
|
||||
| "filter[delta__in]"
|
||||
| "filter[delta]"
|
||||
| "filter[region__in]"
|
||||
| "filter[service__in]"
|
||||
| "filter[resource_type__in]"
|
||||
| "filter[category__in]"
|
||||
| "filter[resource_groups__in]"
|
||||
| "filter[scan]"
|
||||
| "filter[scan__in]"
|
||||
| "filter[scan_id]"
|
||||
| "filter[scan_id__in]"
|
||||
| "filter[inserted_at]"
|
||||
| "filter[muted]";
|
||||
export type FilterParam<Field extends string = FilterField> =
|
||||
`filter[${Field}]`;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MetaDataProps } from "./components";
|
||||
import { MetaDataProps, ProviderGroup } from "./components";
|
||||
import { FilterOption } from "./filters";
|
||||
import {
|
||||
OrganizationResource,
|
||||
@@ -83,6 +83,7 @@ export interface ProvidersAccountsViewData {
|
||||
filters: FilterOption[];
|
||||
metadata?: MetaDataProps;
|
||||
providers: ProviderProps[];
|
||||
providerGroups: ProviderGroup[];
|
||||
rows: ProvidersTableRow[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user