mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
perf(ui): stream the findings page and load the Finding Group filter on open (#12891)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
// 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: [],
|
||||
loadScan: async (scanId: string) => {
|
||||
const response = await getScan(scanId);
|
||||
return response?.data;
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const filtersWithScanDates = await resolveFindingScanDateFilters({
|
||||
filters,
|
||||
scans: scansData?.data || [],
|
||||
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: ScanProps[] =
|
||||
scansData?.data?.filter(
|
||||
(scan: ScanProps) =>
|
||||
scan.attributes.state === "completed" &&
|
||||
scan.attributes.unique_resource_count > 1,
|
||||
) || [];
|
||||
|
||||
const completedScans = 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
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user