From d877bea0e3910ba9beb820f357e630180e354d0c Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:03:33 +0200 Subject: [PATCH] chore(ui): unify filter search and batch patterns (#10859) --- ui/CHANGELOG.md | 1 + .../providers/providers-filters.test.tsx | 92 +++++++++++++++++++ ui/components/providers/providers-filters.tsx | 44 ++++++++- .../shadcn/select/multiselect.test.tsx | 36 +++++++- .../data-table-filter-custom-batch.test.tsx | 19 +++- .../ui/table/data-table-filter-custom.tsx | 43 ++++++++- 6 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 ui/components/providers/providers-filters.test.tsx diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index d3bcf9f4a2..92dbb7ecc1 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -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) --- diff --git a/ui/components/providers/providers-filters.test.tsx b/ui/components/providers/providers-filters.test.tsx new file mode 100644 index 0000000000..b6e6655247 --- /dev/null +++ b/ui/components/providers/providers-filters.test.tsx @@ -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: () =>
Provider type selector
, +})); + +vi.mock("@/components/filters/clear-filters-button", () => ({ + ClearFiltersButton: () => , +})); + +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 }) => ( +
{children}
+ ), + MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + MultiSelectValue: ({ placeholder }: { placeholder: string }) => ( + {placeholder} + ), + MultiSelectContent: ({ + children, + search, + }: { + children: React.ReactNode; + search?: boolean | { placeholder?: string }; + }) => ( +
+ {children} +
+ ), + MultiSelectSelectAll: ({ children }: { children: React.ReactNode }) => ( + + ), + MultiSelectSeparator: () =>
, + MultiSelectItem: ({ + children, + value, + }: { + children: React.ReactNode; + value: string; + }) => , +})); + +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(); + + expect(screen.getByTestId("multiselect-content")).toHaveAttribute( + "data-search-placeholder", + "Search groups...", + ); + }); +}); diff --git a/ui/components/providers/providers-filters.tsx b/ui/components/providers/providers-filters.tsx index d50c90f291..285bae4c1e 100644 --- a/ui/components/providers/providers-filters.tsx +++ b/ui/components/providers/providers-filters.tsx @@ -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 (
@@ -126,7 +167,7 @@ export const ProvidersFilters = ({ /> Select All @@ -141,6 +182,7 @@ export const ProvidersFilters = ({ key={value} value={value} badgeLabel={getBadgeLabel(entity, displayLabel)} + keywords={getSearchKeywords(entity, value, displayLabel)} > {entity ? renderEntityContent(entity) : displayLabel} diff --git a/ui/components/shadcn/select/multiselect.test.tsx b/ui/components/shadcn/select/multiselect.test.tsx index d5e497e8ce..15ed80b73c 100644 --- a/ui/components/shadcn/select/multiselect.test.tsx +++ b/ui/components/shadcn/select/multiselect.test.tsx @@ -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( + {}}> + + + + + Development Azure + Production AWS + + , + ); + + 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(); diff --git a/ui/components/ui/table/data-table-filter-custom-batch.test.tsx b/ui/components/ui/table/data-table-filter-custom-batch.test.tsx index a8a629d24c..34f47ab455 100644 --- a/ui/components/ui/table/data-table-filter-custom-batch.test.tsx +++ b/ui/components/ui/table/data-table-filter-custom-batch.test.tsx @@ -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 }; }) => ( -
+
{children}
), @@ -300,5 +308,14 @@ describe("DataTableFilterCustom — batch vs instant mode", () => { "wide", ); }); + + it("should enable searchable filter dropdowns by default", () => { + render(); + + expect(screen.getByTestId("multiselect-content")).toHaveAttribute( + "data-search-placeholder", + "Search severity...", + ); + }); }); }); diff --git a/ui/components/ui/table/data-table-filter-custom.tsx b/ui/components/ui/table/data-table-filter-custom.tsx index 28988b34d8..6d0e0df33d 100644 --- a/ui/components/ui/table/data-table-filter-custom.tsx +++ b/ui/components/ui/table/data-table-filter-custom.tsx @@ -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 = ({ /> Select All @@ -219,6 +259,7 @@ export const DataTableFilterCustom = ({ key={value} value={value} badgeLabel={getBadgeLabel(entity, displayLabel)} + keywords={getSearchKeywords(entity, value, displayLabel)} > {entity ? renderEntityContent(entity) : displayLabel}