mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 12:31:54 +00:00
feat(ui): add provider group action, selector, and filter helpers
- Add getAllProviderGroups to fetch every group for filter dropdowns - Add reusable ProviderGroupSelector with batch and instant modes - Add provider group chip/label resolution and the provider_groups filter param Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,72 @@ 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 headers = await getAuthHeaders({ contentType: false });
|
||||
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 {
|
||||
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
|
||||
| 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 (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,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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FilterChip } from "@/components/filters/filter-summary-strip";
|
||||
import { formatLabel, getCategoryLabel, getGroupLabel } from "@/lib/categories";
|
||||
import { getScanEntityLabel } from "@/lib/helper-filters";
|
||||
import { FINDING_STATUS_DISPLAY_NAMES } from "@/types";
|
||||
import { ProviderGroup } from "@/types/components";
|
||||
import { FilterParam } from "@/types/filters";
|
||||
import { getProviderDisplayName, ProviderProps } from "@/types/providers";
|
||||
import { ScanEntity } from "@/types/scans";
|
||||
@@ -10,6 +11,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> = {
|
||||
@@ -29,6 +31,14 @@ function getProviderAccountDisplayValue(
|
||||
return provider.attributes.alias || provider.attributes.uid || providerId;
|
||||
}
|
||||
|
||||
function getProviderGroupDisplayValue(
|
||||
groupId: string,
|
||||
groups: ProviderGroup[],
|
||||
): string {
|
||||
const group = groups.find((item) => item.id === groupId);
|
||||
return group?.attributes.name || groupId;
|
||||
}
|
||||
|
||||
function getScanDisplayValue(
|
||||
scanId: string,
|
||||
scans: Array<{ [scanId: string]: ScanEntity }>,
|
||||
@@ -53,6 +63,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 || []);
|
||||
}
|
||||
@@ -101,6 +114,7 @@ export function getFindingsFilterDisplayValue(
|
||||
export const FILTER_KEY_LABELS: Record<FilterParam, 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",
|
||||
@@ -121,6 +135,7 @@ export const FILTER_KEY_LABELS: Record<FilterParam, string> = {
|
||||
interface BuildFindingsFilterChipsOptions {
|
||||
providers?: ProviderProps[];
|
||||
scans?: Array<{ [scanId: string]: ScanEntity }>;
|
||||
providerGroups?: ProviderGroup[];
|
||||
includeMuted?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,13 @@
|
||||
import type { FilterChip } from "@/components/filters/filter-summary-strip";
|
||||
import { formatLabel, getGroupLabel } from "@/lib/categories";
|
||||
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> = {
|
||||
"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",
|
||||
@@ -24,10 +26,19 @@ function getProviderAccountDisplayValue(
|
||||
return provider.attributes.alias || provider.attributes.uid || providerId;
|
||||
}
|
||||
|
||||
function getProviderGroupDisplayValue(
|
||||
groupId: string,
|
||||
groups: ProviderGroup[],
|
||||
): string {
|
||||
const group = groups.find((item) => item.id === groupId);
|
||||
return group?.attributes.name || groupId;
|
||||
}
|
||||
|
||||
export function getResourcesFilterDisplayValue(
|
||||
filterKey: string,
|
||||
value: string,
|
||||
providers: ProviderProps[],
|
||||
providerGroups: ProviderGroup[] = [],
|
||||
): string {
|
||||
if (!value) return value;
|
||||
|
||||
@@ -39,6 +50,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,6 +68,7 @@ export function getResourcesFilterDisplayValue(
|
||||
export function buildResourcesFilterChips(
|
||||
pendingFilters: Record<string, string[]>,
|
||||
providers: ProviderProps[],
|
||||
providerGroups: ProviderGroup[] = [],
|
||||
): FilterChip[] {
|
||||
const chips: FilterChip[] = [];
|
||||
|
||||
@@ -61,7 +77,7 @@ export function buildResourcesFilterChips(
|
||||
|
||||
const label = RESOURCE_FILTER_KEY_LABELS[key] ?? key;
|
||||
const displayValues = values.map((value) =>
|
||||
getResourcesFilterDisplayValue(key, value, providers),
|
||||
getResourcesFilterDisplayValue(key, value, providers, providerGroups),
|
||||
);
|
||||
|
||||
const chip: FilterChip = {
|
||||
|
||||
@@ -67,6 +67,7 @@ export type DataTableFilterMode =
|
||||
export type FilterParam =
|
||||
| "filter[provider_type__in]"
|
||||
| "filter[provider_id__in]"
|
||||
| "filter[provider_groups__in]"
|
||||
| "filter[severity__in]"
|
||||
| "filter[status__in]"
|
||||
| "filter[delta__in]"
|
||||
|
||||
Reference in New Issue
Block a user