perf(ui): stream the findings page and load the Finding Group filter on open (#12891)

This commit is contained in:
Alejandro Bailo
2026-09-28 12:21:13 +02:00
committed by GitHub
parent 453c953f37
commit d5136f364c
18 changed files with 911 additions and 111 deletions
@@ -14,6 +14,7 @@ import {
includesMutedFindings,
splitCsvFilterValues,
} from "@/lib";
import { getFindingGroupFilterOptions } from "@/lib/finding-group-filter-options";
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
@@ -151,6 +152,25 @@ export const getLatestFindingGroups = async (
params: FetchFindingGroupsParams = {},
) => fetchFindingGroupsEndpoint("finding-groups/latest", params);
/**
* Options for the "Finding Group" filter. Walks every finding-group page on the
* server, so the browser issues a single request instead of one per page
* (client-side Server Action calls are dispatched sequentially).
*/
export const getFindingGroupCheckOptions = async ({
filters,
hasHistoricalData,
}: {
filters: Record<string, string>;
hasHistoricalData: boolean;
}) =>
getFindingGroupFilterOptions({
fetchFindingGroups: hasHistoricalData
? getFindingGroups
: getLatestFindingGroups,
filters,
});
interface FetchFindingGroupResourcesParams {
checkId: string;
page?: number;
@@ -48,6 +48,11 @@ vi.mock(
vi.mock("@/app/(prowler)/alerts/_actions", () => alertsActionMocks);
// The findings filters lazily load check options through this Server Action.
vi.mock("@/actions/finding-groups", () => ({
getFindingGroupCheckOptions: vi.fn().mockResolvedValue([]),
}));
vi.mock(
"@/components/compliance/compliance-header/compliance-scan-info",
() => ({
@@ -0,0 +1,103 @@
import {
getFindingGroups,
getLatestFindingGroups,
} from "@/actions/finding-groups";
import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getAllProviders } from "@/actions/providers";
import { SeedFromFindingsButton } from "@/app/(prowler)/alerts/_components";
import { FindingsFilters } from "@/components/findings/findings-filters";
import { createScanDetailsMapping, splitCsvFilterValues } from "@/lib";
import { getSelectedFindingCheckOptions } from "@/lib/finding-group-filter-options";
import { isCloud } from "@/lib/shared/env";
import { ScanEntity, ScanProps } from "@/types";
interface FindingsFiltersSectionProps {
filters: Record<string, string>;
resolvedFilters: Record<string, string>;
hasHistoricalData: boolean;
query: string;
encodedSort?: string;
completedScans: ScanProps[];
}
/**
* Streams behind its own Suspense boundary: everything the filter controls
* need is fetched here, in parallel, so the table never waits for it.
*/
export async function FindingsFiltersSection({
filters,
resolvedFilters,
hasHistoricalData,
query,
encodedSort,
completedScans,
}: FindingsFiltersSectionProps) {
const selectedCheckIds = [
...splitCsvFilterValues(resolvedFilters["filter[check_id]"]),
...splitCsvFilterValues(resolvedFilters["filter[check_id__in]"]),
];
const [providersData, providerGroupsData, metadataInfoData, selectedChecks] =
await Promise.all([
getAllProviders(),
getAllProviderGroups(),
(hasHistoricalData ? getMetadataInfo : getLatestMetadataInfo)({
query,
sort: encodedSort,
filters: resolvedFilters,
}),
getSelectedFindingCheckOptions({
fetchFindingGroups: hasHistoricalData
? getFindingGroups
: getLatestFindingGroups,
filters: resolvedFilters,
selectedCheckIds,
}),
]);
const attributes = metadataInfoData?.data?.attributes;
const uniqueRegions = attributes?.regions || [];
const uniqueServices = attributes?.services || [];
const uniqueResourceTypes = attributes?.resource_types || [];
const uniqueCategories = attributes?.categories || [];
const uniqueGroups = attributes?.groups || [];
const providers = providersData?.data || [];
const scanDetails = createScanDetailsMapping(
completedScans,
providersData,
) as { [uid: string]: ScanEntity }[];
return (
<FindingsFilters
providers={providers}
providerGroups={providerGroupsData?.data || []}
completedScanIds={completedScans.map((scan) => scan.id)}
scanDetails={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
checkOptionsSource={{
filters: resolvedFilters,
hasHistoricalData,
initialOptions: selectedChecks,
}}
trailingControls={
<SeedFromFindingsButton
filterBag={filters}
providers={providers}
scans={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
isCloudEnabled={isCloud()}
/>
}
/>
);
}
@@ -0,0 +1,17 @@
import { FILTER_CONTROL_COLUMN_CLASS } from "@/components/findings/findings-filters.utils";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
const FILTER_CONTROL_PLACEHOLDERS = 5;
export const FindingsFiltersSkeleton = () => {
return (
<div className="flex flex-wrap items-center gap-3">
{Array.from({ length: FILTER_CONTROL_PLACEHOLDERS }, (_, index) => (
<Skeleton
key={index}
className={`h-[52px] rounded-lg ${FILTER_CONTROL_COLUMN_CLASS}`}
/>
))}
</div>
);
};
+19
View File
@@ -0,0 +1,19 @@
import { SkeletonTableFindings } from "@/components/findings/table";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { FindingsFiltersSkeleton } from "./_components/findings-filters-skeleton";
export default function FindingsLoading() {
return (
<ContentLayout
title="Findings"
icon="lucide:tag"
onboardingAction={{ flowId: "explore-findings" }}
>
<div className="mb-6">
<FindingsFiltersSkeleton />
</div>
<SkeletonTableFindings />
</ContentLayout>
);
}
+31 -74
View File
@@ -5,12 +5,7 @@ import {
getFindingGroups,
getLatestFindingGroups,
} from "@/actions/finding-groups";
import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getAllProviders } from "@/actions/providers";
import { getScan, getScans } from "@/actions/scans";
import { SeedFromFindingsButton } from "@/app/(prowler)/alerts/_components";
import { FindingsFilters } from "@/components/findings/findings-filters";
import {
FindingsGroupTable,
SkeletonTableFindings,
@@ -19,17 +14,17 @@ import { ContentLayout } from "@/components/shadcn/content-layout";
import { FilterTransitionWrapper } from "@/contexts";
import {
applyDefaultMutedFilter,
createScanDetailsMapping,
extractFiltersAndQuery,
extractSortAndKey,
hasDateOrScanFilter,
} from "@/lib";
import { getFindingGroupFilterOptions } from "@/lib/finding-group-filter-options";
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
import { isCloud } from "@/lib/shared/env";
import { ScanEntity, ScanProps } from "@/types";
import { ScanProps } from "@/types";
import { SearchParamsProps } from "@/types/components";
import { FindingsFiltersSection } from "./_components/findings-filters-section";
import { FindingsFiltersSkeleton } from "./_components/findings-filters-skeleton";
export default async function Findings({
searchParams,
}: {
@@ -39,54 +34,37 @@ export default async function Findings({
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
const [providersData, providerGroupsData, scansData] = await Promise.all([
getAllProviders(),
getAllProviderGroups(),
getScans({ pageSize: 50 }),
]);
const filtersWithScanDates = await resolveFindingScanDateFilters({
// The page shell awaits only what both the filters and the table depend on:
// the completed scans (onboarding + scan filter) and the scan date range.
const [scansData, filtersWithScanDates] = await Promise.all([
getScans({
pageSize: 50,
filters: { "filter[state]": "completed" },
fields: {
scans: "name,state,unique_resource_count,completed_at,provider",
},
}),
resolveFindingScanDateFilters({
filters,
scans: scansData?.data || [],
scans: [],
loadScan: async (scanId: string) => {
const response = await getScan(scanId);
return response?.data;
},
});
}),
]);
const resolvedFilters = applyDefaultMutedFilter(filtersWithScanDates);
const hasHistoricalData = hasDateOrScanFilter(filtersWithScanDates);
const metadataInfoData = await (
hasHistoricalData ? getMetadataInfo : getLatestMetadataInfo
)({
query,
sort: encodedSort,
filters: resolvedFilters,
});
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
const uniqueServices = metadataInfoData?.data?.attributes?.services || [];
const uniqueResourceTypes =
metadataInfoData?.data?.attributes?.resource_types || [];
const uniqueCategories = metadataInfoData?.data?.attributes?.categories || [];
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
const fetchFindingGroupFilterOptions = hasHistoricalData
? getFindingGroups
: getLatestFindingGroups;
const checkOptions = await getFindingGroupFilterOptions({
fetchFindingGroups: fetchFindingGroupFilterOptions,
filters: resolvedFilters,
});
const completedScans = scansData?.data?.filter(
const completedScans: ScanProps[] =
scansData?.data?.filter(
(scan: ScanProps) =>
scan.attributes.state === "completed" &&
scan.attributes.unique_resource_count > 1,
);
) || [];
const completedScanIds =
completedScans?.map((scan: ScanProps) => scan.id) || [];
const onboardingAction =
completedScanIds.length > 0
completedScans.length > 0
? { flowId: "explore-findings" }
: {
flowId: "explore-findings",
@@ -94,12 +72,6 @@ export default async function Findings({
useFallback: true,
};
const scanDetails = createScanDetailsMapping(
completedScans || [],
providersData,
) as { [uid: string]: ScanEntity }[];
const alertsEnabled = isCloud();
return (
<ContentLayout
title="Findings"
@@ -108,31 +80,16 @@ export default async function Findings({
>
<FilterTransitionWrapper>
<div className="mb-6">
<FindingsFilters
providers={providersData?.data || []}
providerGroups={providerGroupsData?.data || []}
completedScanIds={completedScanIds}
scanDetails={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
checkOptions={checkOptions}
trailingControls={
<SeedFromFindingsButton
filterBag={filters}
providers={providersData?.data || []}
scans={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
isCloudEnabled={alertsEnabled}
/>
}
<Suspense fallback={<FindingsFiltersSkeleton />}>
<FindingsFiltersSection
filters={filters}
resolvedFilters={resolvedFilters}
hasHistoricalData={hasHistoricalData}
query={query}
encodedSort={encodedSort}
completedScans={completedScans}
/>
</Suspense>
</div>
<Suspense fallback={<SkeletonTableFindings />}>
<SSRDataTable
@@ -0,0 +1 @@
Findings page paints a skeleton at once and streams the table before the filters; the "Finding Group" options load in a single request when the dropdown opens
+16 -5
View File
@@ -28,9 +28,13 @@ import { ProviderProps } from "@/types/providers";
import {
buildFindingGroupFilterOption,
buildFindingsFilterChips,
type FindingCheckFilterOption,
FILTER_CONTROL_COLUMN_CLASS,
getFindingsFilterDisplayValue,
} from "./findings-filters.utils";
import {
type FindingCheckOptionsSource,
useFindingCheckOptions,
} from "./use-finding-check-options";
interface FindingsFiltersProps {
/** Provider data for provider/account filter controls. */
@@ -44,7 +48,8 @@ interface FindingsFiltersProps {
uniqueResourceTypes: string[];
uniqueCategories: string[];
uniqueGroups: string[];
checkOptions?: FindingCheckFilterOption[];
/** Enables the lazily loaded Finding Group filter. */
checkOptionsSource?: FindingCheckOptionsSource;
trailingControls?: ReactNode;
variant?: "default" | "alerts-edit";
}
@@ -71,8 +76,6 @@ const countVisibleFilterKeys = (filters: Record<string, string[]>): number =>
return true;
}).length;
const FILTER_CONTROL_COLUMN_CLASS =
"min-w-0 flex-none basis-full sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)] xl:basis-[calc((100%_-_2.25rem)/4)] 2xl:basis-[calc((100%_-_3rem)/5)]";
const FILTER_GRID_ITEM_CLASS = "min-w-0";
const FINDING_GROUP_FILTER_KEYS = ["filter[check_id]", "filter[check_id__in]"];
@@ -89,7 +92,7 @@ export const FindingsFilterBatchControls = ({
uniqueResourceTypes,
uniqueCategories,
uniqueGroups,
checkOptions = [],
checkOptionsSource,
trailingControls,
appliedFilters,
pendingFilters,
@@ -107,6 +110,11 @@ export const FindingsFilterBatchControls = ({
}: FindingsFilterBatchControlsProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const isAlertsEdit = variant === "alerts-edit";
const {
options: checkOptions,
isLoading: isLoadingCheckOptions,
loadAll: loadCheckOptions,
} = useFindingCheckOptions({ source: checkOptionsSource });
const checkTitles = Object.fromEntries(
checkOptions.map(({ checkId, checkTitle }) => [
checkId,
@@ -118,6 +126,9 @@ export const FindingsFilterBatchControls = ({
selectedCheckIds: getFilterValue("filter[check_id]"),
selectedCheckIdsIn: getFilterValue("filter[check_id__in]"),
checkTitles,
lazy: checkOptionsSource
? { onOpen: loadCheckOptions, isLoading: isLoadingCheckOptions }
: undefined,
});
const customFilters = [
@@ -431,4 +431,26 @@ describe("buildFindingGroupFilterOption", () => {
}),
).toBeNull();
});
it("keeps the Finding Group filter visible with lazy loading hooks when nothing is loaded yet", () => {
// Given
const onOpen = () => undefined;
// When
const filter = buildFindingGroupFilterOption({
checkOptions: [],
selectedCheckIds: [],
selectedCheckIdsIn: [],
checkTitles: {},
lazy: { onOpen, isLoading: true },
});
// Then
expect(filter).toMatchObject({
key: "check_id__in",
values: [],
onOpen,
isLoading: true,
});
});
});
@@ -17,6 +17,14 @@ export interface FindingCheckFilterOption {
checkTitle?: string;
}
export interface FindingGroupLazyOptions {
onOpen: () => void;
isLoading: boolean;
}
export const FILTER_CONTROL_COLUMN_CLASS =
"min-w-0 flex-none basis-full sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)] xl:basis-[calc((100%_-_2.25rem)/4)] 2xl:basis-[calc((100%_-_3rem)/5)]";
interface GetFindingsFilterDisplayValueOptions {
providers?: ProviderProps[];
scans?: Array<{ [scanId: string]: ScanEntity }>;
@@ -122,11 +130,14 @@ export function buildFindingGroupFilterOption({
selectedCheckIds,
selectedCheckIdsIn,
checkTitles,
lazy,
}: {
checkOptions: FindingCheckFilterOption[];
selectedCheckIds: string[];
selectedCheckIdsIn: string[];
checkTitles: Record<string, string>;
/** Keeps the dropdown visible with no values so they can load on open. */
lazy?: FindingGroupLazyOptions;
}): FilterOption | null {
const values = uniqueNonEmptyValues([
...checkOptions.map((option) => option.checkId),
@@ -134,7 +145,7 @@ export function buildFindingGroupFilterOption({
...selectedCheckIdsIn,
]);
if (values.length === 0) {
if (values.length === 0 && !lazy) {
return null;
}
@@ -147,6 +158,7 @@ export function buildFindingGroupFilterOption({
checkTitles,
}),
index: 3,
...(lazy && { onOpen: lazy.onOpen, isLoading: lazy.isLoading }),
};
}
@@ -0,0 +1,170 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getFindingGroupCheckOptions: vi.fn(),
}));
vi.mock("@/actions/finding-groups", () => ({
getFindingGroupCheckOptions: mocks.getFindingGroupCheckOptions,
}));
import { useFindingCheckOptions } from "./use-finding-check-options";
const filters: Record<string, string> = {
"filter[severity__in]": "high",
"filter[check_id__in]": "check-a",
};
const selected = [{ checkId: "check-a", checkTitle: "Check A" }];
const source = { filters, hasHistoricalData: false, initialOptions: selected };
describe("useFindingCheckOptions", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getFindingGroupCheckOptions.mockResolvedValue([]);
});
it("should do nothing without a source", () => {
// When
const { result } = renderHook(() =>
useFindingCheckOptions({ source: undefined }),
);
act(() => result.current.loadAll());
// Then
expect(mocks.getFindingGroupCheckOptions).not.toHaveBeenCalled();
expect(result.current.options).toEqual([]);
});
it("should expose the selected titles without fetching anything", () => {
// When
const { result } = renderHook(() => useFindingCheckOptions({ source }));
// Then
expect(result.current.options).toEqual(selected);
expect(mocks.getFindingGroupCheckOptions).not.toHaveBeenCalled();
});
it("should load every option in one request on first open and keep the selected titles", async () => {
// Given
mocks.getFindingGroupCheckOptions.mockResolvedValue([
{ checkId: "check-b", checkTitle: "Check B" },
]);
const { result } = renderHook(() => useFindingCheckOptions({ source }));
// When
act(() => result.current.loadAll());
expect(result.current.isLoading).toBe(true);
act(() => result.current.loadAll());
// Then
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(mocks.getFindingGroupCheckOptions).toHaveBeenCalledTimes(1);
expect(mocks.getFindingGroupCheckOptions).toHaveBeenCalledWith({
filters,
hasHistoricalData: false,
});
expect(result.current.options).toEqual([
...selected,
{ checkId: "check-b", checkTitle: "Check B" },
]);
});
it("should not reload when only the check selection changes", async () => {
// Given
const { result, rerender } = renderHook(
({ filters }) =>
useFindingCheckOptions({
source: { filters, hasHistoricalData: false, initialOptions: [] },
}),
{ initialProps: { filters } },
);
act(() => result.current.loadAll());
await waitFor(() => expect(result.current.isLoading).toBe(false));
// When
rerender({ filters: { ...filters, "filter[check_id__in]": "check-b" } });
act(() => result.current.loadAll());
// Then
expect(mocks.getFindingGroupCheckOptions).toHaveBeenCalledTimes(1);
});
it("should forget loaded options when the surrounding filters change", async () => {
// Given
mocks.getFindingGroupCheckOptions.mockResolvedValue([
{ checkId: "check-b", checkTitle: "Check B" },
]);
const { result, rerender } = renderHook(
({ filters }) =>
useFindingCheckOptions({
source: { filters, hasHistoricalData: false, initialOptions: [] },
}),
{ initialProps: { filters } },
);
act(() => result.current.loadAll());
await waitFor(() => expect(result.current.options).toHaveLength(1));
// When
rerender({ filters: { "filter[severity__in]": "low" } });
// Then
expect(result.current.options).toEqual([]);
act(() => result.current.loadAll());
await waitFor(() =>
expect(mocks.getFindingGroupCheckOptions).toHaveBeenCalledTimes(2),
);
});
it("should drop a load that finishes after the filters changed", async () => {
// Given
let resolveStale: (options: unknown) => void = () => undefined;
mocks.getFindingGroupCheckOptions.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveStale = resolve;
}),
);
const { result, rerender } = renderHook(
({ filters }) =>
useFindingCheckOptions({
source: { filters, hasHistoricalData: false, initialOptions: [] },
}),
{ initialProps: { filters } },
);
act(() => result.current.loadAll());
rerender({ filters: { "filter[severity__in]": "low" } });
// When
await act(async () => {
resolveStale([{ checkId: "stale", checkTitle: "Stale" }]);
});
// Then
expect(result.current.options).toEqual([]);
expect(result.current.isLoading).toBe(false);
act(() => result.current.loadAll());
await waitFor(() =>
expect(mocks.getFindingGroupCheckOptions).toHaveBeenCalledTimes(2),
);
});
it("should allow retrying after a failed load", async () => {
// Given
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
mocks.getFindingGroupCheckOptions.mockRejectedValueOnce(new Error("boom"));
const { result } = renderHook(() => useFindingCheckOptions({ source }));
// When
act(() => result.current.loadAll());
await waitFor(() => expect(result.current.isLoading).toBe(false));
act(() => result.current.loadAll());
// Then
expect(consoleError).toHaveBeenCalledTimes(1);
expect(mocks.getFindingGroupCheckOptions).toHaveBeenCalledTimes(2);
consoleError.mockRestore();
});
});
@@ -0,0 +1,95 @@
"use client";
import { useState } from "react";
import { getFindingGroupCheckOptions } from "@/actions/finding-groups";
import { excludeFindingGroupOwnFilters } from "@/lib/finding-group-filter-options";
import type { FindingCheckFilterOption } from "./findings-filters.utils";
const LOAD_STATUS = {
IDLE: "idle",
LOADING: "loading",
LOADED: "loaded",
} as const;
type LoadStatus = (typeof LOAD_STATUS)[keyof typeof LOAD_STATUS];
export interface FindingCheckOptionsSource {
/** Applied filters; the check filter itself is ignored when loading options. */
filters: Record<string, string>;
hasHistoricalData: boolean;
/** Titles of the checks already selected, so their chips read well before the list loads. */
initialOptions: FindingCheckFilterOption[];
}
interface LoadState {
/** The filters the options were loaded for; a mismatch means they are stale. */
key: string;
status: LoadStatus;
options: FindingCheckFilterOption[];
}
interface UseFindingCheckOptionsParams {
/** Undefined disables lazy loading. */
source?: FindingCheckOptionsSource;
}
const idleState = (key: string): LoadState => ({
key,
status: LOAD_STATUS.IDLE,
options: [],
});
const toFiltersKey = (filters: Record<string, string>) =>
JSON.stringify(Object.entries(excludeFindingGroupOwnFilters(filters)).sort());
function mergeOptions(
current: FindingCheckFilterOption[],
incoming: FindingCheckFilterOption[],
): FindingCheckFilterOption[] {
const byId = new Map(current.map((option) => [option.checkId, option]));
for (const option of incoming) byId.set(option.checkId, option);
return Array.from(byId.values());
}
/** Loads the check filter options the first time the dropdown opens. */
export function useFindingCheckOptions({
source,
}: UseFindingCheckOptionsParams) {
const filtersKey = source ? toFiltersKey(source.filters) : "";
// Local state needed: options load on demand, after the first open.
const [load, setLoad] = useState<LoadState>(() => idleState(filtersKey));
// Derived: a load for other filters counts as nothing loaded.
const current = load.key === filtersKey ? load : idleState(filtersKey);
const loadAll = () => {
if (!source || current.status !== LOAD_STATUS.IDLE) return;
const requestKey = filtersKey;
setLoad({ key: requestKey, status: LOAD_STATUS.LOADING, options: [] });
getFindingGroupCheckOptions({
filters: source.filters,
hasHistoricalData: source.hasHistoricalData,
})
.then((options) => {
setLoad((previous) =>
previous.key === requestKey
? { key: requestKey, status: LOAD_STATUS.LOADED, options }
: previous,
);
})
.catch((error) => {
console.error("Error fetching finding group filter options:", error);
setLoad((previous) =>
previous.key === requestKey ? idleState(requestKey) : previous,
);
});
};
return {
options: mergeOptions(source?.initialOptions ?? [], current.options),
isLoading: current.status === LOAD_STATUS.LOADING,
loadAll,
};
}
@@ -384,6 +384,22 @@ export function MultiSelectContent({
);
}
/** Status row shown under the items while more values are still loading. */
export function MultiSelectLoading({
children,
}: {
children: React.ReactNode;
}) {
return (
<div
role="status"
className="text-bg-button-secondary py-2 text-center text-sm"
>
{children}
</div>
);
}
export function MultiSelectItem({
value,
children,
@@ -31,12 +31,23 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
children,
values,
onValuesChange,
open,
onOpenChange,
}: {
children: React.ReactNode;
values?: string[];
onValuesChange?: (values: string[]) => void;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) => (
<div data-testid="multiselect" data-values={JSON.stringify(values ?? [])}>
<div
data-testid="multiselect"
data-values={JSON.stringify(values ?? [])}
data-open={String(Boolean(open))}
>
<button type="button" onClick={() => onOpenChange?.(!open)}>
toggle
</button>
{children}
{/* expose a select to drive value changes in tests */}
<select
@@ -77,10 +88,16 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
data-search-placeholder={
typeof search === "object" ? search.placeholder : String(search)
}
data-empty-message={
typeof search === "object" ? search.emptyMessage : undefined
}
>
{children}
</div>
),
MultiSelectLoading: ({ children }: { children: React.ReactNode }) => (
<div role="status">{children}</div>
),
MultiSelectSelectAll: ({ children }: { children: React.ReactNode }) => (
<button type="button">{children}</button>
),
@@ -376,4 +393,77 @@ describe("DataTableFilterCustom — batch vs instant mode", () => {
);
});
});
// ── Lazily loaded options ────────────────────────────────────────────────
describe("lazy options", () => {
const lazyFilter = (overrides: Partial<FilterOption>): FilterOption => ({
key: "check_id__in",
labelCheckboxGroup: "Finding Group",
values: [],
...overrides,
});
it("should call onOpen when the dropdown opens, never on close", async () => {
// Given
const user = userEvent.setup();
const onOpen = vi.fn();
render(<DataTableFilterCustom filters={[lazyFilter({ onOpen })]} />);
// When
await user.click(screen.getByRole("button", { name: "toggle" }));
await user.click(screen.getByRole("button", { name: "toggle" }));
// Then
expect(onOpen).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("multiselect")).toHaveAttribute(
"data-open",
"false",
);
});
it("should show a loading message while the list is still empty", () => {
// When
render(
<DataTableFilterCustom filters={[lazyFilter({ isLoading: true })]} />,
);
// Then
expect(screen.getByTestId("multiselect-content")).toHaveAttribute(
"data-empty-message",
"Loading finding group...",
);
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
it("should show a loading row under the values already available", () => {
// When
render(
<DataTableFilterCustom
filters={[lazyFilter({ isLoading: true, values: ["check-a"] })]}
/>,
);
// Then
expect(screen.getByRole("status")).toHaveTextContent(
"Loading finding group...",
);
expect(screen.getByTestId("multiselect-content")).toHaveAttribute(
"data-empty-message",
"No finding group found.",
);
});
it("should not render a loading row once the values are loaded", () => {
// When
render(
<DataTableFilterCustom
filters={[lazyFilter({ isLoading: false, values: ["check-a"] })]}
/>,
);
// Then
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
});
});
@@ -10,6 +10,7 @@ import {
MultiSelect,
MultiSelectContent,
MultiSelectItem,
MultiSelectLoading,
MultiSelectSelectAll,
MultiSelectSeparator,
MultiSelectTrigger,
@@ -89,12 +90,20 @@ export const DataTableFilterCustom = ({
const buildSearchConfig = (filter: FilterOption) => {
const label = filter.labelCheckboxGroup.toLowerCase();
const isLoadingEmptyList = filter.isLoading && filter.values.length === 0;
return {
placeholder: `Search ${label}...`,
emptyMessage: `No ${label} found.`,
emptyMessage: isLoadingEmptyList
? `Loading ${label}...`
: `No ${label} found.`,
};
};
const handleOpenChange = (filter: FilterOption, open: boolean) => {
setOpenFilterKey(open ? filter.key : null);
if (open) filter.onOpen?.();
};
// Helper function to get entity from valueLabelMapping
const getEntityForValue = (
filter: FilterOption,
@@ -286,7 +295,7 @@ export const DataTableFilterCustom = ({
<MultiSelect
key={filter.key}
open={openFilterKey === filter.key}
onOpenChange={(open) => setOpenFilterKey(open ? filter.key : null)}
onOpenChange={(open) => handleOpenChange(filter, open)}
values={selectedValues}
onValuesChange={(values) => pushDropdownFilter(filter, values)}
>
@@ -317,6 +326,11 @@ export const DataTableFilterCustom = ({
</MultiSelectItem>
);
})}
{filter.isLoading && filter.values.length > 0 && (
<MultiSelectLoading>
Loading {filter.labelCheckboxGroup.toLowerCase()}...
</MultiSelectLoading>
)}
</MultiSelectContent>
</MultiSelect>
);
+175 -1
View File
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { getFindingGroupFilterOptions } from "./finding-group-filter-options";
import {
getFindingGroupFilterOptions,
getSelectedFindingCheckOptions,
} from "./finding-group-filter-options";
function makeResponse(
pageCount: number,
@@ -62,15 +65,186 @@ describe("getFindingGroupFilterOptions", () => {
filters: { "filter[severity__in]": "high" },
page: 1,
pageSize: 100,
sort: "check_id",
});
expect(fetchFindingGroups).toHaveBeenNthCalledWith(2, {
filters: { "filter[severity__in]": "high" },
page: 2,
pageSize: 100,
sort: "check_id",
});
expect(options).toEqual([
{ checkId: "check-a", checkTitle: "Check A updated" },
{ checkId: "check-b", checkTitle: "Check B" },
]);
});
it("requests the remaining pages concurrently once the page count is known", async () => {
// Given
const pending: Array<(value: unknown) => void> = [];
const fetchFindingGroups = vi.fn(
({ page }: { page: number }) =>
new Promise((resolve) => {
if (page === 1) {
resolve(makeResponse(3, [{ id: "check-a", title: "Check A" }]));
return;
}
pending.push(resolve);
}),
);
// When
const optionsPromise = getFindingGroupFilterOptions({
fetchFindingGroups,
filters: {},
});
await vi.waitFor(() => expect(fetchFindingGroups).toHaveBeenCalledTimes(3));
pending[0](makeResponse(3, [{ id: "check-b", title: "Check B" }]));
pending[1](makeResponse(3, [{ id: "check-c", title: "Check C" }]));
const options = await optionsPromise;
// Then
expect(
fetchFindingGroups.mock.calls.map(([params]) => params.page),
).toEqual([1, 2, 3]);
expect(options.map((option) => option.checkId)).toEqual([
"check-a",
"check-b",
"check-c",
]);
});
it("caps how many pages are in flight at the same time", async () => {
// Given
const resolvers: Array<() => void> = [];
const fetchFindingGroups = vi.fn(({ page }: { page: number }) => {
const response = makeResponse(12, [
{ id: `check-${page}`, title: `Check ${page}` },
]);
if (page === 1) return Promise.resolve(response);
return new Promise((resolve) => {
resolvers.push(() => resolve(response));
});
});
// When
const optionsPromise = getFindingGroupFilterOptions({
fetchFindingGroups,
filters: {},
});
await vi.waitFor(() => expect(fetchFindingGroups).toHaveBeenCalledTimes(5));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(fetchFindingGroups).toHaveBeenCalledTimes(5);
while (fetchFindingGroups.mock.calls.length < 12 || resolvers.length > 0) {
await vi.waitFor(() => expect(resolvers.length).toBeGreaterThan(0));
resolvers.pop()?.();
}
const options = await optionsPromise;
// Then
expect(fetchFindingGroups).toHaveBeenCalledTimes(12);
expect(options.map((option) => option.checkId)).toEqual(
Array.from({ length: 12 }, (_, index) => `check-${index + 1}`),
);
});
it("stops dequeuing pages once one of them rejects", async () => {
// Given
let rejectPage: (error: Error) => void = () => undefined;
const heldPages: Array<() => void> = [];
const fetchFindingGroups = vi.fn(({ page }: { page: number }) => {
const response = makeResponse(12, [
{ id: `check-${page}`, title: `Check ${page}` },
]);
if (page === 1) return Promise.resolve(response);
if (page === 2) {
return new Promise((_, reject) => {
rejectPage = reject;
});
}
return new Promise((resolve) => {
heldPages.push(() => resolve(response));
});
});
// When
const optionsPromise = getFindingGroupFilterOptions({
fetchFindingGroups,
filters: {},
});
await vi.waitFor(() => expect(fetchFindingGroups).toHaveBeenCalledTimes(5));
rejectPage(new Error("auth failed"));
await expect(optionsPromise).rejects.toThrow("auth failed");
// The other workers finish their current page after the failure.
heldPages.forEach((release) => release());
await new Promise((resolve) => setTimeout(resolve, 0));
// Then
expect(fetchFindingGroups).toHaveBeenCalledTimes(5);
});
it("stops after the first page when the response has no pagination", async () => {
// Given
const fetchFindingGroups = vi.fn().mockResolvedValue(undefined);
// When
const options = await getFindingGroupFilterOptions({
fetchFindingGroups,
filters: {},
});
// Then
expect(fetchFindingGroups).toHaveBeenCalledTimes(1);
expect(options).toEqual([]);
});
});
describe("getSelectedFindingCheckOptions", () => {
it("resolves the selected titles in one request, replacing the filter's own selection", async () => {
// Given
const fetchFindingGroups = vi
.fn()
.mockResolvedValue(
makeResponse(1, [{ id: "check-a", title: "Check A" }]),
);
// When
const options = await getSelectedFindingCheckOptions({
fetchFindingGroups,
filters: {
"filter[check_id__in]": "check-a",
"filter[severity__in]": "high",
},
selectedCheckIds: ["check-a", "check-b", "check-a"],
});
// Then
expect(fetchFindingGroups).toHaveBeenCalledTimes(1);
expect(fetchFindingGroups).toHaveBeenCalledWith({
filters: {
"filter[severity__in]": "high",
"filter[check_id__in]": "check-a,check-b",
},
page: 1,
pageSize: 100,
sort: "check_id",
});
expect(options).toEqual([{ checkId: "check-a", checkTitle: "Check A" }]);
});
it("requests nothing when no check is selected", async () => {
// Given
const fetchFindingGroups = vi.fn();
// When
const options = await getSelectedFindingCheckOptions({
fetchFindingGroups,
filters: {},
selectedCheckIds: [],
});
// Then
expect(fetchFindingGroups).not.toHaveBeenCalled();
expect(options).toEqual([]);
});
});
+90 -19
View File
@@ -1,24 +1,35 @@
import { adaptFindingGroupsResponse } from "@/actions/finding-groups/finding-groups.adapter";
const FINDING_GROUP_FILTER_OPTION_PAGE_SIZE = 100;
// Options only need a stable page order; the table's composite sort costs an
// extra aggregation per page on the API.
const FINDING_GROUP_FILTER_OPTION_SORT = "check_id";
// Each page can hit the raw findings aggregation, so bound the DB fan-out.
const FINDING_GROUP_FILTER_OPTION_CONCURRENCY = 4;
const FINDING_GROUP_OWN_FILTER_KEYS = new Set([
"filter[check_id]",
"filter[check_id__in]",
]);
type FindingGroupFilters = Record<string, string | string[] | undefined>;
interface FindingGroupFilterFetcherParams {
page: number;
pageSize: number;
filters: Record<string, string | string[] | undefined>;
sort: string;
filters: FindingGroupFilters;
}
type FindingGroupFilterFetcher = (
export type FindingGroupFilterFetcher = (
params: FindingGroupFilterFetcherParams,
) => Promise<unknown>;
function excludeFindingGroupOwnFilters(
filters: Record<string, string | string[] | undefined>,
) {
export interface FindingGroupCheckOption {
checkId: string;
checkTitle: string;
}
export function excludeFindingGroupOwnFilters(filters: FindingGroupFilters) {
return Object.fromEntries(
Object.entries(filters).filter(
([key]) => !FINDING_GROUP_OWN_FILTER_KEYS.has(key),
@@ -48,33 +59,93 @@ function getTotalPages(response: unknown, currentPage: number): number {
return typeof pagination.pages === "number" ? pagination.pages : currentPage;
}
function toCheckOptions(response: unknown): FindingGroupCheckOption[] {
return adaptFindingGroupsResponse(response).map((group) => ({
checkId: group.checkId,
checkTitle: group.checkTitle,
}));
}
/** Titles for the checks already selected in the URL: one request, none without a selection. */
export async function getSelectedFindingCheckOptions({
fetchFindingGroups,
filters,
selectedCheckIds,
}: {
fetchFindingGroups: FindingGroupFilterFetcher;
filters: FindingGroupFilters;
selectedCheckIds: string[];
}): Promise<FindingGroupCheckOption[]> {
const uniqueIds = Array.from(new Set(selectedCheckIds.filter(Boolean)));
if (uniqueIds.length === 0) return [];
const response = await fetchFindingGroups({
filters: {
...excludeFindingGroupOwnFilters(filters),
"filter[check_id__in]": uniqueIds.join(","),
},
page: 1,
pageSize: FINDING_GROUP_FILTER_OPTION_PAGE_SIZE,
sort: FINDING_GROUP_FILTER_OPTION_SORT,
});
return toCheckOptions(response);
}
/** Every check for the given filters, walking the pages a few at a time. */
export async function getFindingGroupFilterOptions({
fetchFindingGroups,
filters,
}: {
fetchFindingGroups: FindingGroupFilterFetcher;
filters: Record<string, string | string[] | undefined>;
}) {
filters: FindingGroupFilters;
}): Promise<FindingGroupCheckOption[]> {
const optionFilters = excludeFindingGroupOwnFilters(filters);
const options = new Map<string, { checkId: string; checkTitle: string }>();
let page = 1;
while (true) {
const response = await fetchFindingGroups({
const fetchPage = (page: number) =>
fetchFindingGroups({
filters: optionFilters,
page,
pageSize: FINDING_GROUP_FILTER_OPTION_PAGE_SIZE,
sort: FINDING_GROUP_FILTER_OPTION_SORT,
});
for (const group of adaptFindingGroupsResponse(response)) {
options.set(group.checkId, {
checkId: group.checkId,
checkTitle: group.checkTitle,
});
const firstPage = await fetchPage(1);
const totalPages = getTotalPages(firstPage, 1);
const pendingPages = Array.from(
{ length: Math.max(totalPages - 1, 0) },
(_, index) => index + 2,
);
const remainingPages: unknown[] = [];
// One rejection fails the whole walk, so the other workers stop dequeuing.
let failed = false;
const drainPendingPages = async () => {
while (!failed && pendingPages.length > 0) {
const page = pendingPages.shift() as number;
try {
remainingPages[page - 2] = await fetchPage(page);
} catch (error) {
failed = true;
throw error;
}
}
};
await Promise.all(
Array.from(
{
length: Math.min(
FINDING_GROUP_FILTER_OPTION_CONCURRENCY,
pendingPages.length,
),
},
drainPendingPages,
),
);
if (page >= getTotalPages(response, page)) break;
page += 1;
const options = new Map<string, FindingGroupCheckOption>();
for (const response of [firstPage, ...remainingPages]) {
for (const option of toCheckOptions(response)) {
options.set(option.checkId, option);
}
}
return Array.from(options.values());
+3
View File
@@ -31,6 +31,9 @@ export interface FilterOption {
showSelectAll?: boolean;
defaultToSelectAll?: boolean;
defaultValues?: string[];
/** Called each time the dropdown opens, so values can load lazily. */
onOpen?: () => void;
isLoading?: boolean;
}
export interface CustomDropdownFilterProps {