mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-06 08:47:18 +00:00
chore(ui): unify filter search and batch patterns (#10859)
This commit is contained in:
@@ -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...",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user