From aa5b48e03d8b26bc216c35ed2ce1fbd38c7545f1 Mon Sep 17 00:00:00 2001 From: alejandrobailo Date: Wed, 22 Apr 2026 17:43:51 +0200 Subject: [PATCH] chore(ui): unify filter search patterns - enable searchable shared filter dropdowns - scroll to the first visible match while filtering - cover provider and table filters with tests --- ui/CHANGELOG.md | 1 + .../providers/providers-filters.test.tsx | 92 +++++++++++++++++++ ui/components/providers/providers-filters.tsx | 44 ++++++++- ui/components/shadcn/command.tsx | 32 ++++--- .../shadcn/select/multiselect.test.tsx | 36 +++++++- ui/components/shadcn/select/multiselect.tsx | 27 +++++- .../data-table-filter-custom-batch.test.tsx | 19 +++- .../ui/table/data-table-filter-custom.tsx | 43 ++++++++- 8 files changed, 273 insertions(+), 21 deletions(-) create mode 100644 ui/components/providers/providers-filters.test.tsx diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index bedc6d819e..286549f1fa 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🔄 Changed - 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 ### ❌ Removed 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/command.tsx b/ui/components/shadcn/command.tsx index 4a3f672dc4..3c08c772ec 100644 --- a/ui/components/shadcn/command.tsx +++ b/ui/components/shadcn/command.tsx @@ -1,5 +1,6 @@ "use client"; +import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react"; import { Command as CommandPrimitive } from "cmdk"; import { SearchIcon } from "lucide-react"; @@ -81,21 +82,22 @@ function CommandInput({ ); } -function CommandList({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} +const CommandList = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; function CommandEmpty({ ...props 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/shadcn/select/multiselect.tsx b/ui/components/shadcn/select/multiselect.tsx index e7dca260db..d9d36a7087 100644 --- a/ui/components/shadcn/select/multiselect.tsx +++ b/ui/components/shadcn/select/multiselect.tsx @@ -283,13 +283,33 @@ export function MultiSelectContent({ children: ReactNode; width?: "default" | "wide"; } & Omit, "children">) { + const { open } = useMultiSelectContext(); const canSearch = typeof search === "object" ? true : search; + const [searchValue, setSearchValue] = useState(""); + const listRef = useRef(null); const widthClasses = width === "wide" ? "w-[min(max(var(--radix-popover-trigger-width),24rem),calc(100vw-2rem))] max-w-[32rem]" : "w-[min(var(--radix-popover-trigger-width),calc(100vw-2rem))] max-w-[24rem]"; + useEffect(() => { + if (open) return; + setSearchValue(""); + }, [open]); + + useEffect(() => { + if (!canSearch || !searchValue.trim()) return; + + const firstVisibleItem = listRef.current?.querySelector( + '[data-slot="multiselect-item"]:not([hidden])', + ); + + firstVisibleItem?.scrollIntoView({ + block: "nearest", + }); + }, [canSearch, searchValue, children]); + return ( <>