perf(ui): stream findings page and lazy-load check filter

This commit is contained in:
pedrooot
2026-09-25 13:40:07 +02:00
parent 26d9d24e5d
commit e75b06c96e
22 changed files with 1045 additions and 109 deletions
@@ -48,6 +48,11 @@ vi.mock(
vi.mock("@/app/(prowler)/alerts/_actions", () => alertsActionMocks); vi.mock("@/app/(prowler)/alerts/_actions", () => alertsActionMocks);
vi.mock("@/actions/finding-groups", () => ({
getFindingGroups: vi.fn(),
getLatestFindingGroups: vi.fn(),
}));
vi.mock( vi.mock(
"@/components/compliance/compliance-header/compliance-scan-info", "@/components/compliance/compliance-header/compliance-scan-info",
() => ({ () => ({
@@ -1,12 +1,12 @@
"use client"; "use client";
import { BellPlusIcon } from "lucide-react"; import { BellPlusIcon } from "lucide-react";
import dynamic from "next/dynamic";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { createAlert, seedAlertRule } from "@/app/(prowler)/alerts/_actions"; import { createAlert, seedAlertRule } from "@/app/(prowler)/alerts/_actions";
import { AlertFormModal } from "@/app/(prowler)/alerts/_components/alert-form-modal";
import { import {
getFindingsFiltersFromAlertCondition, getFindingsFiltersFromAlertCondition,
toAlertPayload, toAlertPayload,
@@ -36,6 +36,14 @@ import type { ScanEntity } from "@/types";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import type { ProviderProps } from "@/types/providers"; import type { ProviderProps } from "@/types/providers";
const AlertFormModal = dynamic(
() =>
import("@/app/(prowler)/alerts/_components/alert-form-modal").then(
(module) => module.AlertFormModal,
),
{ loading: () => null },
);
const DISABLED_FILTER_TOOLTIP = const DISABLED_FILTER_TOOLTIP =
"Apply at least one Findings filter to create an alert from filters."; "Apply at least one Findings filter to create an alert from filters.";
const ALERT_SEED_ERROR = "Apply at least one alert-compatible Findings filter."; const ALERT_SEED_ERROR = "Apply at least one alert-compatible Findings filter.";
@@ -0,0 +1,188 @@
import type { ReactElement } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const calls: string[] = [];
// Each fetch records when it starts and resolves on the next tick, so the
// order proves whether the section awaits them one by one or all at once.
const gate = (name: string, value: unknown) =>
vi.fn(() => {
calls.push(`start:${name}`);
return new Promise((resolve) => {
setTimeout(() => {
calls.push(`end:${name}`);
resolve(value);
}, 0);
});
});
const mocks = vi.hoisted(() => ({
getFindingGroups: vi.fn(),
getLatestFindingGroups: vi.fn(),
getSelectedFindingCheckOptions: vi.fn(),
getAllProviders: vi.fn(),
getAllProviderGroups: vi.fn(),
getLatestMetadataInfo: vi.fn(),
getMetadataInfo: vi.fn(),
}));
vi.mock("@/actions/finding-groups", () => ({
getFindingGroups: mocks.getFindingGroups,
getLatestFindingGroups: mocks.getLatestFindingGroups,
}));
vi.mock("@/lib/finding-group-filter-options", () => ({
getSelectedFindingCheckOptions: mocks.getSelectedFindingCheckOptions,
}));
vi.mock("@/actions/providers", () => ({
getAllProviders: mocks.getAllProviders,
}));
vi.mock("@/actions/manage-groups/manage-groups", () => ({
getAllProviderGroups: mocks.getAllProviderGroups,
}));
vi.mock("@/actions/findings", () => ({
getLatestMetadataInfo: mocks.getLatestMetadataInfo,
getMetadataInfo: mocks.getMetadataInfo,
}));
vi.mock("@/app/(prowler)/alerts/_components", () => ({
SeedFromFindingsButton: () => null,
}));
vi.mock("@/components/findings/findings-filters", () => ({
FindingsFilters: () => null,
}));
vi.mock("@/lib", () => ({
createScanDetailsMapping: (scans: Array<{ id: string }>) =>
scans.map((scan) => ({ [scan.id]: { id: scan.id } })),
splitCsvFilterValues: (value?: string) =>
value ? value.split(",").filter(Boolean) : [],
}));
vi.mock("@/lib/shared/env", () => ({
isCloud: () => false,
}));
import type { ScanProps } from "@/types";
import { FindingsFiltersSection } from "./findings-filters-section";
const providers = { data: [{ id: "provider-1" }] };
const providerGroups = { data: [{ id: "group-1" }] };
const metadata = {
data: {
attributes: {
regions: ["eu-west-1"],
services: ["s3"],
resource_types: ["bucket"],
categories: ["storage"],
groups: ["prod"],
},
},
};
const baseProps = {
filters: { "filter[severity__in]": "high" },
resolvedFilters: { "filter[severity__in]": "high", "filter[muted]": "false" },
hasHistoricalData: false,
query: "",
completedScans: [{ id: "scan-1" }] as unknown as ScanProps[],
};
describe("FindingsFiltersSection", () => {
beforeEach(() => {
calls.length = 0;
vi.clearAllMocks();
mocks.getAllProviders.mockImplementation(gate("providers", providers));
mocks.getAllProviderGroups.mockImplementation(
gate("groups", providerGroups),
);
mocks.getLatestMetadataInfo.mockImplementation(gate("metadata", metadata));
mocks.getMetadataInfo.mockImplementation(gate("metadata", metadata));
mocks.getSelectedFindingCheckOptions.mockImplementation(
gate("selected", []),
);
});
it("starts every filter source before any of them resolves", async () => {
// When
await FindingsFiltersSection(baseProps);
// Then
expect(calls.slice(0, 4)).toEqual([
"start:providers",
"start:groups",
"start:metadata",
"start:selected",
]);
});
it("uses the latest metadata and hands the check options over to the client", async () => {
// When
const element = (await FindingsFiltersSection(baseProps)) as ReactElement<
Record<string, unknown>
>;
// Then
expect(mocks.getLatestMetadataInfo).toHaveBeenCalledWith({
query: "",
sort: undefined,
filters: baseProps.resolvedFilters,
});
expect(mocks.getMetadataInfo).not.toHaveBeenCalled();
expect(element.props).toMatchObject({
providers: providers.data,
providerGroups: providerGroups.data,
completedScanIds: ["scan-1"],
uniqueRegions: ["eu-west-1"],
uniqueServices: ["s3"],
checkOptionsSource: {
filters: baseProps.resolvedFilters,
hasHistoricalData: false,
initialOptions: [],
},
});
expect(mocks.getSelectedFindingCheckOptions).toHaveBeenCalledWith({
fetchFindingGroups: mocks.getLatestFindingGroups,
filters: baseProps.resolvedFilters,
selectedCheckIds: [],
});
});
it("resolves the titles of the checks selected in the URL", async () => {
// Given
const selected = [{ checkId: "check-a", checkTitle: "Check A" }];
mocks.getSelectedFindingCheckOptions.mockResolvedValue(selected);
// When
const element = (await FindingsFiltersSection({
...baseProps,
resolvedFilters: {
...baseProps.resolvedFilters,
"filter[check_id__in]": "check-a,check-b",
},
})) as ReactElement<Record<string, unknown>>;
// Then
expect(mocks.getSelectedFindingCheckOptions).toHaveBeenCalledWith(
expect.objectContaining({ selectedCheckIds: ["check-a", "check-b"] }),
);
expect(element.props.checkOptionsSource).toMatchObject({
initialOptions: selected,
});
});
it("uses the historical endpoints when a date or scan filter is set", async () => {
// When
const element = (await FindingsFiltersSection({
...baseProps,
hasHistoricalData: true,
})) as ReactElement<Record<string, unknown>>;
// Then
expect(mocks.getMetadataInfo).toHaveBeenCalledTimes(1);
expect(mocks.getLatestMetadataInfo).not.toHaveBeenCalled();
expect(element.props.checkOptionsSource).toMatchObject({
hasHistoricalData: true,
});
expect(mocks.getSelectedFindingCheckOptions).toHaveBeenCalledWith(
expect.objectContaining({ fetchFindingGroups: mocks.getFindingGroups }),
);
});
});
@@ -0,0 +1,101 @@
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[];
}
export async function FindingsFiltersSection({
filters,
resolvedFilters,
hasHistoricalData,
query,
encodedSort,
completedScans,
}: FindingsFiltersSectionProps) {
const fetchFindingGroups = hasHistoricalData
? getFindingGroups
: getLatestFindingGroups;
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,
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 completedScanIds = completedScans.map((scan) => scan.id);
const scanDetails = createScanDetailsMapping(
completedScans,
providersData,
) as { [uid: string]: ScanEntity }[];
return (
<FindingsFilters
providers={providers}
providerGroups={providerGroupsData?.data || []}
completedScanIds={completedScanIds}
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,18 @@
import { FILTER_CONTROL_COLUMN_CLASS } from "@/components/findings/findings-filters.utils";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { cn } from "@/lib/utils";
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={cn("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 -1
View File
@@ -1,4 +1,4 @@
import { readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -16,6 +16,10 @@ describe("findings page", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url)); const currentDir = path.dirname(fileURLToPath(import.meta.url));
const pagePath = path.join(currentDir, "page.tsx"); const pagePath = path.join(currentDir, "page.tsx");
const source = readFileSync(pagePath, "utf8"); const source = readFileSync(pagePath, "utf8");
const filtersSectionSource = readFileSync(
path.join(currentDir, "_components", "findings-filters-section.tsx"),
"utf8",
);
it("only passes sort to fetchFindingGroups when the user has an explicit sort param", () => { it("only passes sort to fetchFindingGroups when the user has an explicit sort param", () => {
expect(source).toContain("...(encodedSort && { sort: encodedSort })"); expect(source).toContain("...(encodedSort && { sort: encodedSort })");
@@ -44,4 +48,30 @@ describe("findings page", () => {
it("applies the shared default muted filter so muted findings are hidden unless the caller opts in", () => { it("applies the shared default muted filter so muted findings are hidden unless the caller opts in", () => {
expect(source).toContain("applyDefaultMutedFilter"); expect(source).toContain("applyDefaultMutedFilter");
}); });
it("renders a route loading state so the sidebar click paints immediately", () => {
expect(existsSync(path.join(currentDir, "loading.tsx"))).toBe(true);
});
it("streams the filters behind their own Suspense boundary so the table does not wait for them", () => {
expect(source).toContain("FindingsFiltersSkeleton");
expect(source).toContain("FindingsFiltersSection");
expect(source).not.toContain("getAllProviders");
expect(source).not.toContain("getFindingGroupFilterOptions");
});
it("loads the scan date range per selected scan instead of waiting for the scans list", () => {
expect(source).toContain("loadScan");
expect(source).not.toContain("scans: scansData");
});
it("requests only completed scans with the fields the filters use", () => {
expect(source).toContain('"filter[state]": "completed"');
expect(source).toMatch(/fields:\s*\{[^}]*scans:/);
});
it("loads the check filter options lazily instead of walking finding groups before render", () => {
expect(filtersSectionSource).not.toContain("getFindingGroupFilterOptions");
expect(filtersSectionSource).toContain("checkOptionsSource");
});
}); });
+37 -82
View File
@@ -5,12 +5,7 @@ import {
getFindingGroups, getFindingGroups,
getLatestFindingGroups, getLatestFindingGroups,
} from "@/actions/finding-groups"; } 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 { getScan, getScans } from "@/actions/scans";
import { SeedFromFindingsButton } from "@/app/(prowler)/alerts/_components";
import { FindingsFilters } from "@/components/findings/findings-filters";
import { import {
FindingsGroupTable, FindingsGroupTable,
SkeletonTableFindings, SkeletonTableFindings,
@@ -19,17 +14,17 @@ import { ContentLayout } from "@/components/shadcn/content-layout";
import { FilterTransitionWrapper } from "@/contexts"; import { FilterTransitionWrapper } from "@/contexts";
import { import {
applyDefaultMutedFilter, applyDefaultMutedFilter,
createScanDetailsMapping,
extractFiltersAndQuery, extractFiltersAndQuery,
extractSortAndKey, extractSortAndKey,
hasDateOrScanFilter, hasDateOrScanFilter,
} from "@/lib"; } from "@/lib";
import { getFindingGroupFilterOptions } from "@/lib/finding-group-filter-options";
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters"; import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
import { isCloud } from "@/lib/shared/env"; import { ScanProps } from "@/types";
import { ScanEntity, ScanProps } from "@/types";
import { SearchParamsProps } from "@/types/components"; import { SearchParamsProps } from "@/types/components";
import { FindingsFiltersSection } from "./_components/findings-filters-section";
import { FindingsFiltersSkeleton } from "./_components/findings-filters-skeleton";
export default async function Findings({ export default async function Findings({
searchParams, searchParams,
}: { }: {
@@ -39,54 +34,35 @@ export default async function Findings({
const { encodedSort } = extractSortAndKey(resolvedSearchParams); const { encodedSort } = extractSortAndKey(resolvedSearchParams);
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams); const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
const [providersData, providerGroupsData, scansData] = await Promise.all([ const [scansData, filtersWithScanDates] = await Promise.all([
getAllProviders(), getScans({
getAllProviderGroups(), pageSize: 50,
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 resolvedFilters = applyDefaultMutedFilter(filtersWithScanDates);
const hasHistoricalData = hasDateOrScanFilter(filtersWithScanDates); const hasHistoricalData = hasDateOrScanFilter(filtersWithScanDates);
const metadataInfoData = await (
hasHistoricalData ? getMetadataInfo : getLatestMetadataInfo
)({
query,
sort: encodedSort,
filters: resolvedFilters,
});
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || []; const completedScans: ScanProps[] =
const uniqueServices = metadataInfoData?.data?.attributes?.services || []; scansData?.data?.filter(
const uniqueResourceTypes = (scan: ScanProps) =>
metadataInfoData?.data?.attributes?.resource_types || []; scan.attributes.state === "completed" &&
const uniqueCategories = metadataInfoData?.data?.attributes?.categories || []; scan.attributes.unique_resource_count > 1,
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || []; ) || [];
const fetchFindingGroupFilterOptions = hasHistoricalData
? getFindingGroups
: getLatestFindingGroups;
const checkOptions = await getFindingGroupFilterOptions({
fetchFindingGroups: fetchFindingGroupFilterOptions,
filters: resolvedFilters,
});
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 = const onboardingAction =
completedScanIds.length > 0 completedScans.length > 0
? { flowId: "explore-findings" } ? { flowId: "explore-findings" }
: { : {
flowId: "explore-findings", flowId: "explore-findings",
@@ -94,12 +70,6 @@ export default async function Findings({
useFallback: true, useFallback: true,
}; };
const scanDetails = createScanDetailsMapping(
completedScans || [],
providersData,
) as { [uid: string]: ScanEntity }[];
const alertsEnabled = isCloud();
return ( return (
<ContentLayout <ContentLayout
title="Findings" title="Findings"
@@ -108,31 +78,16 @@ export default async function Findings({
> >
<FilterTransitionWrapper> <FilterTransitionWrapper>
<div className="mb-6"> <div className="mb-6">
<FindingsFilters <Suspense fallback={<FindingsFiltersSkeleton />}>
providers={providersData?.data || []} <FindingsFiltersSection
providerGroups={providerGroupsData?.data || []} filters={filters}
completedScanIds={completedScanIds} resolvedFilters={resolvedFilters}
scanDetails={scanDetails} hasHistoricalData={hasHistoricalData}
uniqueRegions={uniqueRegions} query={query}
uniqueServices={uniqueServices} encodedSort={encodedSort}
uniqueResourceTypes={uniqueResourceTypes} completedScans={completedScans}
uniqueCategories={uniqueCategories} />
uniqueGroups={uniqueGroups} </Suspense>
checkOptions={checkOptions}
trailingControls={
<SeedFromFindingsButton
filterBag={filters}
providers={providersData?.data || []}
scans={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
uniqueCategories={uniqueCategories}
uniqueGroups={uniqueGroups}
isCloudEnabled={alertsEnabled}
/>
}
/>
</div> </div>
<Suspense fallback={<SkeletonTableFindings />}> <Suspense fallback={<SkeletonTableFindings />}>
<SSRDataTable <SSRDataTable
@@ -0,0 +1 @@
Findings page renders a loading skeleton immediately and streams the table before the filter options
+16 -5
View File
@@ -28,9 +28,13 @@ import { ProviderProps } from "@/types/providers";
import { import {
buildFindingGroupFilterOption, buildFindingGroupFilterOption,
buildFindingsFilterChips, buildFindingsFilterChips,
type FindingCheckFilterOption, FILTER_CONTROL_COLUMN_CLASS,
getFindingsFilterDisplayValue, getFindingsFilterDisplayValue,
} from "./findings-filters.utils"; } from "./findings-filters.utils";
import {
type FindingCheckOptionsSource,
useFindingCheckOptions,
} from "./use-finding-check-options";
interface FindingsFiltersProps { interface FindingsFiltersProps {
/** Provider data for provider/account filter controls. */ /** Provider data for provider/account filter controls. */
@@ -44,7 +48,8 @@ interface FindingsFiltersProps {
uniqueResourceTypes: string[]; uniqueResourceTypes: string[];
uniqueCategories: string[]; uniqueCategories: string[];
uniqueGroups: string[]; uniqueGroups: string[];
checkOptions?: FindingCheckFilterOption[]; /** Enables the lazily loaded Finding Group filter. */
checkOptionsSource?: FindingCheckOptionsSource;
trailingControls?: ReactNode; trailingControls?: ReactNode;
variant?: "default" | "alerts-edit"; variant?: "default" | "alerts-edit";
} }
@@ -71,8 +76,6 @@ const countVisibleFilterKeys = (filters: Record<string, string[]>): number =>
return true; return true;
}).length; }).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 FILTER_GRID_ITEM_CLASS = "min-w-0";
const FINDING_GROUP_FILTER_KEYS = ["filter[check_id]", "filter[check_id__in]"]; const FINDING_GROUP_FILTER_KEYS = ["filter[check_id]", "filter[check_id__in]"];
@@ -89,7 +92,7 @@ export const FindingsFilterBatchControls = ({
uniqueResourceTypes, uniqueResourceTypes,
uniqueCategories, uniqueCategories,
uniqueGroups, uniqueGroups,
checkOptions = [], checkOptionsSource,
trailingControls, trailingControls,
appliedFilters, appliedFilters,
pendingFilters, pendingFilters,
@@ -107,6 +110,11 @@ export const FindingsFilterBatchControls = ({
}: FindingsFilterBatchControlsProps) => { }: FindingsFilterBatchControlsProps) => {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const isAlertsEdit = variant === "alerts-edit"; const isAlertsEdit = variant === "alerts-edit";
const {
options: checkOptions,
isLoading: isLoadingCheckOptions,
loadAll: loadCheckOptions,
} = useFindingCheckOptions({ source: checkOptionsSource });
const checkTitles = Object.fromEntries( const checkTitles = Object.fromEntries(
checkOptions.map(({ checkId, checkTitle }) => [ checkOptions.map(({ checkId, checkTitle }) => [
checkId, checkId,
@@ -118,6 +126,9 @@ export const FindingsFilterBatchControls = ({
selectedCheckIds: getFilterValue("filter[check_id]"), selectedCheckIds: getFilterValue("filter[check_id]"),
selectedCheckIdsIn: getFilterValue("filter[check_id__in]"), selectedCheckIdsIn: getFilterValue("filter[check_id__in]"),
checkTitles, checkTitles,
lazy: checkOptionsSource
? { onOpen: loadCheckOptions, isLoading: isLoadingCheckOptions }
: undefined,
}); });
const customFilters = [ const customFilters = [
@@ -431,4 +431,26 @@ describe("buildFindingGroupFilterOption", () => {
}), }),
).toBeNull(); ).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; 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 { interface GetFindingsFilterDisplayValueOptions {
providers?: ProviderProps[]; providers?: ProviderProps[];
scans?: Array<{ [scanId: string]: ScanEntity }>; scans?: Array<{ [scanId: string]: ScanEntity }>;
@@ -122,11 +130,14 @@ export function buildFindingGroupFilterOption({
selectedCheckIds, selectedCheckIds,
selectedCheckIdsIn, selectedCheckIdsIn,
checkTitles, checkTitles,
lazy,
}: { }: {
checkOptions: FindingCheckFilterOption[]; checkOptions: FindingCheckFilterOption[];
selectedCheckIds: string[]; selectedCheckIds: string[];
selectedCheckIdsIn: string[]; selectedCheckIdsIn: string[];
checkTitles: Record<string, string>; checkTitles: Record<string, string>;
/** Keeps the dropdown visible with no values so they can load on open. */
lazy?: FindingGroupLazyOptions;
}): FilterOption | null { }): FilterOption | null {
const values = uniqueNonEmptyValues([ const values = uniqueNonEmptyValues([
...checkOptions.map((option) => option.checkId), ...checkOptions.map((option) => option.checkId),
@@ -134,7 +145,7 @@ export function buildFindingGroupFilterOption({
...selectedCheckIdsIn, ...selectedCheckIdsIn,
]); ]);
if (values.length === 0) { if (values.length === 0 && !lazy) {
return null; return null;
} }
@@ -147,6 +158,7 @@ export function buildFindingGroupFilterOption({
checkTitles, checkTitles,
}), }),
index: 3, index: 3,
...(lazy && { onOpen: lazy.onOpen, isLoading: lazy.isLoading }),
}; };
} }
@@ -7,6 +7,7 @@ import {
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { ChevronsDown } from "lucide-react"; import { ChevronsDown } from "lucide-react";
import dynamic from "next/dynamic";
import { useImperativeHandle, useRef, useState } from "react"; import { useImperativeHandle, useRef, useState } from "react";
import { import {
@@ -33,7 +34,16 @@ import {
getFindingGroupEmptyStateMessage, getFindingGroupEmptyStateMessage,
getFindingGroupSkeletonCount, getFindingGroupSkeletonCount,
} from "./inline-resource-container.utils"; } from "./inline-resource-container.utils";
import { ResourceDetailDrawer } from "./resource-detail-drawer";
// Loaded on demand so the drawer's markdown and timeline code stays out of the
// findings page bundle until a resource is opened.
const ResourceDetailDrawer = dynamic(
() =>
import("./resource-detail-drawer/resource-detail-drawer").then(
(module) => module.ResourceDetailDrawer,
),
{ loading: () => null },
);
export interface InlineResourceContainerHandle { export interface InlineResourceContainerHandle {
/** Soft-refresh resources (re-fetch page 1 without skeletons). */ /** Soft-refresh resources (re-fetch page 1 without skeletons). */
@@ -0,0 +1,180 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getFindingGroups: vi.fn(),
getLatestFindingGroups: vi.fn(),
getFindingGroupFilterOptions: vi.fn(),
}));
vi.mock("@/actions/finding-groups", () => ({
getFindingGroups: mocks.getFindingGroups,
getLatestFindingGroups: mocks.getLatestFindingGroups,
}));
vi.mock("@/lib/finding-group-filter-options", async (importOriginal) => ({
...(await importOriginal<
typeof import("@/lib/finding-group-filter-options")
>()),
getFindingGroupFilterOptions: mocks.getFindingGroupFilterOptions,
}));
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.getFindingGroupFilterOptions.mockResolvedValue([]);
});
it("should do nothing without a source", () => {
// When
const { result } = renderHook(() =>
useFindingCheckOptions({ source: undefined }),
);
act(() => result.current.loadAll());
// Then
expect(mocks.getFindingGroupFilterOptions).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.getFindingGroupFilterOptions).not.toHaveBeenCalled();
});
it("should load every option once on first open and keep the selected titles", async () => {
// Given
mocks.getFindingGroupFilterOptions.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.getFindingGroupFilterOptions).toHaveBeenCalledTimes(1);
expect(mocks.getFindingGroupFilterOptions).toHaveBeenCalledWith({
fetchFindingGroups: mocks.getLatestFindingGroups,
filters,
});
expect(result.current.options).toEqual([
...selected,
{ checkId: "check-b", checkTitle: "Check B" },
]);
});
it("should use the historical finding groups fetcher when a date or scan filter is set", async () => {
// Given
const { result } = renderHook(() =>
useFindingCheckOptions({
source: { ...source, hasHistoricalData: true },
}),
);
// When
act(() => result.current.loadAll());
// Then
await waitFor(() =>
expect(mocks.getFindingGroupFilterOptions).toHaveBeenCalledWith({
fetchFindingGroups: mocks.getFindingGroups,
filters,
}),
);
});
it("should forget loaded options when the surrounding filters change", async () => {
// Given
mocks.getFindingGroupFilterOptions.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.getFindingGroupFilterOptions).toHaveBeenCalledTimes(2),
);
});
it("should drop a load that finishes after the filters changed", async () => {
// Given
let resolveStale: (options: unknown) => void = () => undefined;
mocks.getFindingGroupFilterOptions.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.getFindingGroupFilterOptions).toHaveBeenCalledTimes(2),
);
});
it("should allow retrying after a failed load", async () => {
// Given
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
mocks.getFindingGroupFilterOptions.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.getFindingGroupFilterOptions).toHaveBeenCalledTimes(2);
consoleError.mockRestore();
});
});
@@ -0,0 +1,95 @@
"use client";
import { useRef, useState } from "react";
import {
getFindingGroups,
getLatestFindingGroups,
} from "@/actions/finding-groups";
import {
excludeFindingGroupOwnFilters,
getFindingGroupFilterOptions,
} 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 {
filters: Record<string, string | string[] | undefined>;
hasHistoricalData: boolean;
/** Titles of the checks already selected, so their chips read well before the list loads. */
initialOptions: FindingCheckFilterOption[];
}
interface UseFindingCheckOptionsParams {
/** Undefined disables lazy loading. */
source?: FindingCheckOptionsSource;
}
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
? JSON.stringify(excludeFindingGroupOwnFilters(source.filters))
: "";
const filtersKeyRef = useRef(filtersKey);
filtersKeyRef.current = filtersKey;
const [loadedOptions, setLoadedOptions] = useState<
FindingCheckFilterOption[]
>([]);
const [status, setStatus] = useState<LoadStatus>(LOAD_STATUS.IDLE);
const [loadedFor, setLoadedFor] = useState(filtersKey);
if (loadedFor !== filtersKey) {
setLoadedFor(filtersKey);
setLoadedOptions([]);
setStatus(LOAD_STATUS.IDLE);
}
const loadAll = () => {
if (!source || status !== LOAD_STATUS.IDLE) return;
const requestKey = filtersKey;
setStatus(LOAD_STATUS.LOADING);
getFindingGroupFilterOptions({
fetchFindingGroups: source.hasHistoricalData
? getFindingGroups
: getLatestFindingGroups,
filters: source.filters,
})
.then((options) => {
if (requestKey !== filtersKeyRef.current) return;
setLoadedOptions(options);
setStatus(LOAD_STATUS.LOADED);
})
.catch((error) => {
if (requestKey !== filtersKeyRef.current) return;
console.error("Error fetching finding group filter options:", error);
setStatus(LOAD_STATUS.IDLE);
});
};
return {
options: mergeOptions(source?.initialOptions ?? [], loadedOptions),
isLoading: status === LOAD_STATUS.LOADING,
loadAll,
};
}
@@ -31,12 +31,23 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
children, children,
values, values,
onValuesChange, onValuesChange,
open,
onOpenChange,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
values?: string[]; values?: string[];
onValuesChange?: (values: string[]) => void; 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} {children}
{/* expose a select to drive value changes in tests */} {/* expose a select to drive value changes in tests */}
<select <select
@@ -77,6 +88,9 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
data-search-placeholder={ data-search-placeholder={
typeof search === "object" ? search.placeholder : String(search) typeof search === "object" ? search.placeholder : String(search)
} }
data-empty-message={
typeof search === "object" ? search.emptyMessage : undefined
}
> >
{children} {children}
</div> </div>
@@ -376,4 +390,61 @@ 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 values are still empty", () => {
// When
render(
<DataTableFilterCustom filters={[lazyFilter({ isLoading: true })]} />,
);
// Then
expect(screen.getByTestId("multiselect-content")).toHaveAttribute(
"data-empty-message",
"Loading finding group...",
);
});
it("should keep the regular empty message once values are available", () => {
// When
render(
<DataTableFilterCustom
filters={[lazyFilter({ isLoading: true, values: ["check-a"] })]}
/>,
);
// Then
expect(screen.getByTestId("multiselect-content")).toHaveAttribute(
"data-empty-message",
"No finding group found.",
);
});
});
}); });
@@ -89,12 +89,20 @@ export const DataTableFilterCustom = ({
const buildSearchConfig = (filter: FilterOption) => { const buildSearchConfig = (filter: FilterOption) => {
const label = filter.labelCheckboxGroup.toLowerCase(); const label = filter.labelCheckboxGroup.toLowerCase();
const isLoadingValues = filter.isLoading && filter.values.length === 0;
return { return {
placeholder: `Search ${label}...`, placeholder: `Search ${label}...`,
emptyMessage: `No ${label} found.`, emptyMessage: isLoadingValues
? `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 // Helper function to get entity from valueLabelMapping
const getEntityForValue = ( const getEntityForValue = (
filter: FilterOption, filter: FilterOption,
@@ -286,7 +294,7 @@ export const DataTableFilterCustom = ({
<MultiSelect <MultiSelect
key={filter.key} key={filter.key}
open={openFilterKey === filter.key} open={openFilterKey === filter.key}
onOpenChange={(open) => setOpenFilterKey(open ? filter.key : null)} onOpenChange={(open) => handleOpenChange(filter, open)}
values={selectedValues} values={selectedValues}
onValuesChange={(values) => pushDropdownFilter(filter, values)} onValuesChange={(values) => pushDropdownFilter(filter, values)}
> >
@@ -20,9 +20,12 @@ vi.mock("@/lib", () => ({
}), }),
})); }));
vi.mock("@/components/findings/table/resource-detail-drawer", () => ({ vi.mock(
useResourceDetailDrawer: useResourceDetailDrawerMock, "@/components/findings/table/resource-detail-drawer/use-resource-detail-drawer",
})); () => ({
useResourceDetailDrawer: useResourceDetailDrawerMock,
}),
);
import { import {
type FindingGroupRow, type FindingGroupRow,
+1 -1
View File
@@ -4,7 +4,7 @@ import { OnChangeFn, Row, RowSelectionState } from "@tanstack/react-table";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { canMuteFindingResource } from "@/components/findings/table/finding-resource-selection"; import { canMuteFindingResource } from "@/components/findings/table/finding-resource-selection";
import { useResourceDetailDrawer } from "@/components/findings/table/resource-detail-drawer"; import { useResourceDetailDrawer } from "@/components/findings/table/resource-detail-drawer/use-resource-detail-drawer";
import { useFindingGroupResources } from "@/hooks/use-finding-group-resources"; import { useFindingGroupResources } from "@/hooks/use-finding-group-resources";
import { applyDefaultMutedFilter } from "@/lib"; import { applyDefaultMutedFilter } from "@/lib";
import { import {
+140 -1
View File
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { getFindingGroupFilterOptions } from "./finding-group-filter-options"; import {
getFindingGroupFilterOptions,
getSelectedFindingCheckOptions,
} from "./finding-group-filter-options";
function makeResponse( function makeResponse(
pageCount: number, pageCount: number,
@@ -62,15 +65,151 @@ describe("getFindingGroupFilterOptions", () => {
filters: { "filter[severity__in]": "high" }, filters: { "filter[severity__in]": "high" },
page: 1, page: 1,
pageSize: 100, pageSize: 100,
sort: "check_id",
}); });
expect(fetchFindingGroups).toHaveBeenNthCalledWith(2, { expect(fetchFindingGroups).toHaveBeenNthCalledWith(2, {
filters: { "filter[severity__in]": "high" }, filters: { "filter[severity__in]": "high" },
page: 2, page: 2,
pageSize: 100, pageSize: 100,
sort: "check_id",
}); });
expect(options).toEqual([ expect(options).toEqual([
{ checkId: "check-a", checkTitle: "Check A updated" }, { checkId: "check-a", checkTitle: "Check A updated" },
{ checkId: "check-b", checkTitle: "Check B" }, { 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("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([]);
});
it("caps how many option pages are requested 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}`),
);
});
});
describe("getSelectedFindingCheckOptions", () => {
it("resolves titles for the selected checks in one request without 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("does not request anything 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([]);
});
}); });
+67 -10
View File
@@ -1,6 +1,11 @@
import { adaptFindingGroupsResponse } from "@/actions/finding-groups/finding-groups.adapter"; import { adaptFindingGroupsResponse } from "@/actions/finding-groups/finding-groups.adapter";
const FINDING_GROUP_FILTER_OPTION_PAGE_SIZE = 100; 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([ const FINDING_GROUP_OWN_FILTER_KEYS = new Set([
"filter[check_id]", "filter[check_id]",
"filter[check_id__in]", "filter[check_id__in]",
@@ -9,14 +14,15 @@ const FINDING_GROUP_OWN_FILTER_KEYS = new Set([
interface FindingGroupFilterFetcherParams { interface FindingGroupFilterFetcherParams {
page: number; page: number;
pageSize: number; pageSize: number;
sort: string;
filters: Record<string, string | string[] | undefined>; filters: Record<string, string | string[] | undefined>;
} }
type FindingGroupFilterFetcher = ( export type FindingGroupFilterFetcher = (
params: FindingGroupFilterFetcherParams, params: FindingGroupFilterFetcherParams,
) => Promise<unknown>; ) => Promise<unknown>;
function excludeFindingGroupOwnFilters( export function excludeFindingGroupOwnFilters(
filters: Record<string, string | string[] | undefined>, filters: Record<string, string | string[] | undefined>,
) { ) {
return Object.fromEntries( return Object.fromEntries(
@@ -48,6 +54,35 @@ function getTotalPages(response: unknown, currentPage: number): number {
return typeof pagination.pages === "number" ? pagination.pages : currentPage; return typeof pagination.pages === "number" ? pagination.pages : currentPage;
} }
/** Titles for already-selected checks; one small request, none without a selection. */
export async function getSelectedFindingCheckOptions({
fetchFindingGroups,
filters,
selectedCheckIds,
}: {
fetchFindingGroups: FindingGroupFilterFetcher;
filters: Record<string, string | string[] | undefined>;
selectedCheckIds: string[];
}) {
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 adaptFindingGroupsResponse(response).map((group) => ({
checkId: group.checkId,
checkTitle: group.checkTitle,
}));
}
export async function getFindingGroupFilterOptions({ export async function getFindingGroupFilterOptions({
fetchFindingGroups, fetchFindingGroups,
filters, filters,
@@ -56,25 +91,47 @@ export async function getFindingGroupFilterOptions({
filters: Record<string, string | string[] | undefined>; filters: Record<string, string | string[] | undefined>;
}) { }) {
const optionFilters = excludeFindingGroupOwnFilters(filters); const optionFilters = excludeFindingGroupOwnFilters(filters);
const options = new Map<string, { checkId: string; checkTitle: string }>(); const fetchPage = (page: number) =>
let page = 1; fetchFindingGroups({
while (true) {
const response = await fetchFindingGroups({
filters: optionFilters, filters: optionFilters,
page, page,
pageSize: FINDING_GROUP_FILTER_OPTION_PAGE_SIZE, pageSize: FINDING_GROUP_FILTER_OPTION_PAGE_SIZE,
sort: FINDING_GROUP_FILTER_OPTION_SORT,
}); });
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[] = [];
const fetchNextPage = async () => {
while (pendingPages.length > 0) {
const page = pendingPages.shift() as number;
remainingPages[page - 2] = await fetchPage(page);
}
};
await Promise.all(
Array.from(
{
length: Math.min(
FINDING_GROUP_FILTER_OPTION_CONCURRENCY,
pendingPages.length,
),
},
fetchNextPage,
),
);
const options = new Map<string, { checkId: string; checkTitle: string }>();
for (const response of [firstPage, ...remainingPages]) {
for (const group of adaptFindingGroupsResponse(response)) { for (const group of adaptFindingGroupsResponse(response)) {
options.set(group.checkId, { options.set(group.checkId, {
checkId: group.checkId, checkId: group.checkId,
checkTitle: group.checkTitle, checkTitle: group.checkTitle,
}); });
} }
if (page >= getTotalPages(response, page)) break;
page += 1;
} }
return Array.from(options.values()); return Array.from(options.values());
+3
View File
@@ -31,6 +31,9 @@ export interface FilterOption {
showSelectAll?: boolean; showSelectAll?: boolean;
defaultToSelectAll?: boolean; defaultToSelectAll?: boolean;
defaultValues?: string[]; defaultValues?: string[];
/** Called whenever the dropdown opens, so callers can load values lazily. */
onOpen?: () => void;
isLoading?: boolean;
} }
export interface CustomDropdownFilterProps { export interface CustomDropdownFilterProps {