chore(ui): unify filter search and batch patterns (#10859)

This commit is contained in:
Alejandro Bailo
2026-04-23 16:03:33 +02:00
committed by GitHub
parent 2304bf0093
commit d877bea0e3
6 changed files with 230 additions and 5 deletions
+1
View File
@@ -12,6 +12,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- Redesign compliance page, client-side search for compliance frameworks, compact scan selector trigger, enhanced compliance cards [(#10767)](https://github.com/prowler-cloud/prowler/pull/10767)
- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787)
- Shared filter dropdowns now support local option search and auto-scroll to the first visible match across table and provider filters [(#10859)](https://github.com/prowler-cloud/prowler/pull/10859)
- Backward-compatibility middleware redirect from `/sign-up?invitation_token=…` to `/invitation/accept?invitation_token=…`; new invitation emails use `/invitation/accept` directly [(#10797)](https://github.com/prowler-cloud/prowler/pull/10797)
---
@@ -0,0 +1,92 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { FilterOption } from "@/types/filters";
import type { ProviderProps } from "@/types/providers";
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/hooks/use-url-filters", () => ({
useUrlFilters: () => ({ updateFilter: vi.fn() }),
}));
vi.mock("@/app/(prowler)/_overview/_components/provider-type-selector", () => ({
ProviderTypeSelector: () => <div>Provider type selector</div>,
}));
vi.mock("@/components/filters/clear-filters-button", () => ({
ClearFiltersButton: () => <button type="button">Clear</button>,
}));
vi.mock("@/components/ui/entities/entity-info", () => ({
EntityInfo: () => null,
}));
vi.mock("@/lib/helper-filters", () => ({
isConnectionStatus: () => false,
isGroupFilterEntity: () => false,
}));
vi.mock("@/components/shadcn/select/multiselect", () => ({
MultiSelect: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
<span>{placeholder}</span>
),
MultiSelectContent: ({
children,
search,
}: {
children: React.ReactNode;
search?: boolean | { placeholder?: string };
}) => (
<div
data-testid="multiselect-content"
data-search-placeholder={
typeof search === "object" ? search.placeholder : String(search)
}
>
{children}
</div>
),
MultiSelectSelectAll: ({ children }: { children: React.ReactNode }) => (
<button type="button">{children}</button>
),
MultiSelectSeparator: () => <hr />,
MultiSelectItem: ({
children,
value,
}: {
children: React.ReactNode;
value: string;
}) => <option value={value}>{children}</option>,
}));
import { ProvidersFilters } from "./providers-filters";
const filters: FilterOption[] = [
{
key: "filter[group__in]",
labelCheckboxGroup: "Groups",
values: ["engineering"],
},
];
const providers: ProviderProps[] = [];
describe("ProvidersFilters", () => {
it("enables searchable provider filter dropdowns", () => {
render(<ProvidersFilters filters={filters} providers={providers} />);
expect(screen.getByTestId("multiselect-content")).toHaveAttribute(
"data-search-placeholder",
"Search groups...",
);
});
});
+43 -1
View File
@@ -24,6 +24,10 @@ import {
ProviderProps,
} from "@/types/providers";
function isNonEmptyString(value: string | null | undefined): value is string {
return Boolean(value);
}
interface ProvidersFiltersProps {
filters: FilterOption[];
providers: ProviderProps[];
@@ -38,6 +42,14 @@ export const ProvidersFilters = ({
const { updateFilter } = useUrlFilters();
const searchParams = useSearchParams();
const buildSearchConfig = (filter: FilterOption) => {
const label = filter.labelCheckboxGroup.toLowerCase();
return {
placeholder: `Search ${label}...`,
emptyMessage: `No ${label} found.`,
};
};
const sortedFilters = [...filters].sort((a, b) => {
if (a.index !== undefined && b.index !== undefined)
return a.index - b.index;
@@ -107,6 +119,35 @@ export const ProvidersFilters = ({
);
};
const getSearchKeywords = (
entity: FilterEntity | undefined,
value: string,
displayLabel: string,
): string[] => {
if (!entity) {
return [displayLabel, value];
}
if (isConnectionStatus(entity)) {
return [displayLabel, value, (entity as ProviderConnectionStatus).label];
}
if (isGroupFilterEntity(entity)) {
return [displayLabel, value, (entity as GroupFilterEntity).name].filter(
isNonEmptyString,
);
}
const providerEntity = entity as ProviderEntity;
return [
displayLabel,
value,
providerEntity.alias,
providerEntity.uid,
providerEntity.provider,
].filter(isNonEmptyString);
};
return (
<div className="flex flex-wrap items-center gap-4">
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
@@ -126,7 +167,7 @@ export const ProvidersFilters = ({
/>
</MultiSelectTrigger>
<MultiSelectContent
search={false}
search={buildSearchConfig(filter)}
width={filter.width ?? "default"}
>
<MultiSelectSelectAll>Select All</MultiSelectSelectAll>
@@ -141,6 +182,7 @@ export const ProvidersFilters = ({
key={value}
value={value}
badgeLabel={getBadgeLabel(entity, displayLabel)}
keywords={getSearchKeywords(entity, value, displayLabel)}
>
{entity ? renderEntityContent(entity) : displayLabel}
</MultiSelectItem>
@@ -1,6 +1,6 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
MultiSelect,
@@ -10,6 +10,8 @@ import {
MultiSelectValue,
} from "./multiselect";
const scrollIntoViewMock = vi.fn();
class ResizeObserverMock {
observe() {}
unobserve() {}
@@ -25,10 +27,14 @@ Object.defineProperty(globalThis, "ResizeObserver", {
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
writable: true,
configurable: true,
value: () => {},
value: scrollIntoViewMock,
});
describe("MultiSelect", () => {
beforeEach(() => {
scrollIntoViewMock.mockClear();
});
it("shows preselected labels before the popover opens", () => {
// Given
render(
@@ -107,6 +113,32 @@ describe("MultiSelect", () => {
).not.toBeInTheDocument();
});
it("scrolls the first visible match into view when filtering", async () => {
const user = userEvent.setup();
render(
<MultiSelect values={[]} onValuesChange={() => {}}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent
search={{
placeholder: "Search accounts...",
emptyMessage: "No accounts found.",
}}
>
<MultiSelectItem value="azure-dev">Development Azure</MultiSelectItem>
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
</MultiSelectContent>
</MultiSelect>,
);
await user.click(screen.getByRole("combobox"));
await user.type(screen.getByPlaceholderText("Search accounts..."), "aws");
expect(scrollIntoViewMock).toHaveBeenCalled();
});
it("uses a normalized dropdown width instead of growing with the longest item", async () => {
const user = userEvent.setup();
@@ -65,11 +65,19 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
MultiSelectContent: ({
children,
width,
search,
}: {
children: React.ReactNode;
width?: string;
search?: boolean | { placeholder?: string; emptyMessage?: string };
}) => (
<div data-testid="multiselect-content" data-width={width ?? "default"}>
<div
data-testid="multiselect-content"
data-width={width ?? "default"}
data-search-placeholder={
typeof search === "object" ? search.placeholder : String(search)
}
>
{children}
</div>
),
@@ -300,5 +308,14 @@ describe("DataTableFilterCustom — batch vs instant mode", () => {
"wide",
);
});
it("should enable searchable filter dropdowns by default", () => {
render(<DataTableFilterCustom filters={[severityFilter]} />);
expect(screen.getByTestId("multiselect-content")).toHaveAttribute(
"data-search-placeholder",
"Search severity...",
);
});
});
});
@@ -30,6 +30,10 @@ import {
import { DATA_TABLE_FILTER_MODE, DataTableFilterMode } from "@/types/filters";
import { ProviderConnectionStatus } from "@/types/providers";
function isNonEmptyString(value: string | null | undefined): value is string {
return Boolean(value);
}
export interface DataTableFilterCustomProps {
filters: FilterOption[];
/** Optional element to render at the start of the filters grid */
@@ -70,6 +74,14 @@ export const DataTableFilterCustom = ({
const { updateFilter } = useUrlFilters();
const searchParams = useSearchParams();
const buildSearchConfig = (filter: FilterOption) => {
const label = filter.labelCheckboxGroup.toLowerCase();
return {
placeholder: `Search ${label}...`,
emptyMessage: `No ${label} found.`,
};
};
// Helper function to get entity from valueLabelMapping
const getEntityForValue = (
filter: FilterOption,
@@ -124,6 +136,34 @@ export const DataTableFilterCustom = ({
);
};
const getSearchKeywords = (
entity: FilterEntity | undefined,
value: string,
displayLabel: string,
): string[] => {
if (!entity) {
return [displayLabel, value];
}
if (isScanEntity(entity as ScanEntity)) {
const label = getScanEntityLabel(entity as ScanEntity);
return [displayLabel, value, label].filter(isNonEmptyString);
}
if (isConnectionStatus(entity)) {
return [displayLabel, value, (entity as ProviderConnectionStatus).label];
}
const providerEntity = entity as ProviderEntity;
return [
displayLabel,
value,
providerEntity.alias,
providerEntity.uid,
providerEntity.provider,
].filter(isNonEmptyString);
};
// Sort filters by index property, with fallback to original order for filters without index
const sortedFilters = () => {
return [...filters].sort((a, b) => {
@@ -204,7 +244,7 @@ export const DataTableFilterCustom = ({
/>
</MultiSelectTrigger>
<MultiSelectContent
search={false}
search={buildSearchConfig(filter)}
width={filter.width ?? "default"}
>
<MultiSelectSelectAll>Select All</MultiSelectSelectAll>
@@ -219,6 +259,7 @@ export const DataTableFilterCustom = ({
key={value}
value={value}
badgeLabel={getBadgeLabel(entity, displayLabel)}
keywords={getSearchKeywords(entity, value, displayLabel)}
>
{entity ? renderEntityContent(entity) : displayLabel}
</MultiSelectItem>