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:
Pablo F.G
2026-06-19 11:34:19 +02:00
parent a7917f779a
commit c5f86da879
9 changed files with 718 additions and 1 deletions
@@ -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();
});
});
+66
View File
@@ -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 = {
+1
View File
@@ -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]"