From d5136f364c01e928339660ba7aca320df1d42dea Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:21:13 +0200 Subject: [PATCH] perf(ui): stream the findings page and load the Finding Group filter on open (#12891) --- ui/actions/finding-groups/finding-groups.ts | 20 ++ .../__tests__/alert-form-modal.test.tsx | 5 + .../_components/findings-filters-section.tsx | 103 ++++++++++ .../_components/findings-filters-skeleton.tsx | 17 ++ ui/app/(prowler)/findings/loading.tsx | 19 ++ ui/app/(prowler)/findings/page.tsx | 121 ++++-------- .../findings-page-streaming.changed.md | 1 + ui/components/findings/findings-filters.tsx | 21 ++- .../findings/findings-filters.utils.test.ts | 22 +++ .../findings/findings-filters.utils.ts | 14 +- .../use-finding-check-options.test.ts | 170 +++++++++++++++++ .../findings/use-finding-check-options.ts | 95 ++++++++++ ui/components/shadcn/select/multiselect.tsx | 16 ++ .../data-table-filter-custom-batch.test.tsx | 92 ++++++++- .../shadcn/table/data-table-filter-custom.tsx | 18 +- ui/lib/finding-group-filter-options.test.ts | 176 +++++++++++++++++- ui/lib/finding-group-filter-options.ts | 109 +++++++++-- ui/types/filters.ts | 3 + 18 files changed, 911 insertions(+), 111 deletions(-) create mode 100644 ui/app/(prowler)/findings/_components/findings-filters-section.tsx create mode 100644 ui/app/(prowler)/findings/_components/findings-filters-skeleton.tsx create mode 100644 ui/app/(prowler)/findings/loading.tsx create mode 100644 ui/changelog.d/findings-page-streaming.changed.md create mode 100644 ui/components/findings/use-finding-check-options.test.ts create mode 100644 ui/components/findings/use-finding-check-options.ts diff --git a/ui/actions/finding-groups/finding-groups.ts b/ui/actions/finding-groups/finding-groups.ts index faa697cf32..26182e4443 100644 --- a/ui/actions/finding-groups/finding-groups.ts +++ b/ui/actions/finding-groups/finding-groups.ts @@ -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; + hasHistoricalData: boolean; +}) => + getFindingGroupFilterOptions({ + fetchFindingGroups: hasHistoricalData + ? getFindingGroups + : getLatestFindingGroups, + filters, + }); + interface FetchFindingGroupResourcesParams { checkId: string; page?: number; diff --git a/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx b/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx index 4fa46494ae..991ddf7879 100644 --- a/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx +++ b/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx @@ -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", () => ({ diff --git a/ui/app/(prowler)/findings/_components/findings-filters-section.tsx b/ui/app/(prowler)/findings/_components/findings-filters-section.tsx new file mode 100644 index 0000000000..b1efa7535c --- /dev/null +++ b/ui/app/(prowler)/findings/_components/findings-filters-section.tsx @@ -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; + resolvedFilters: Record; + 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 ( + scan.id)} + scanDetails={scanDetails} + uniqueRegions={uniqueRegions} + uniqueServices={uniqueServices} + uniqueResourceTypes={uniqueResourceTypes} + uniqueCategories={uniqueCategories} + uniqueGroups={uniqueGroups} + checkOptionsSource={{ + filters: resolvedFilters, + hasHistoricalData, + initialOptions: selectedChecks, + }} + trailingControls={ + + } + /> + ); +} diff --git a/ui/app/(prowler)/findings/_components/findings-filters-skeleton.tsx b/ui/app/(prowler)/findings/_components/findings-filters-skeleton.tsx new file mode 100644 index 0000000000..50e1cd63c3 --- /dev/null +++ b/ui/app/(prowler)/findings/_components/findings-filters-skeleton.tsx @@ -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 ( +
+ {Array.from({ length: FILTER_CONTROL_PLACEHOLDERS }, (_, index) => ( + + ))} +
+ ); +}; diff --git a/ui/app/(prowler)/findings/loading.tsx b/ui/app/(prowler)/findings/loading.tsx new file mode 100644 index 0000000000..383bdbd297 --- /dev/null +++ b/ui/app/(prowler)/findings/loading.tsx @@ -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 ( + +
+ +
+ +
+ ); +} diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index 4a7f7d2f80..9891929f9d 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -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 (
- - } - /> + }> + +
}> ): 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 = [ diff --git a/ui/components/findings/findings-filters.utils.test.ts b/ui/components/findings/findings-filters.utils.test.ts index 5b0fe99cb2..00d6b1d361 100644 --- a/ui/components/findings/findings-filters.utils.test.ts +++ b/ui/components/findings/findings-filters.utils.test.ts @@ -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, + }); + }); }); diff --git a/ui/components/findings/findings-filters.utils.ts b/ui/components/findings/findings-filters.utils.ts index 09188e65e6..830a68426f 100644 --- a/ui/components/findings/findings-filters.utils.ts +++ b/ui/components/findings/findings-filters.utils.ts @@ -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; + /** 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 }), }; } diff --git a/ui/components/findings/use-finding-check-options.test.ts b/ui/components/findings/use-finding-check-options.test.ts new file mode 100644 index 0000000000..082624bc5c --- /dev/null +++ b/ui/components/findings/use-finding-check-options.test.ts @@ -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 = { + "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(); + }); +}); diff --git a/ui/components/findings/use-finding-check-options.ts b/ui/components/findings/use-finding-check-options.ts new file mode 100644 index 0000000000..0a779eae30 --- /dev/null +++ b/ui/components/findings/use-finding-check-options.ts @@ -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; + 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) => + 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(() => 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, + }; +} diff --git a/ui/components/shadcn/select/multiselect.tsx b/ui/components/shadcn/select/multiselect.tsx index 45c91fa6ef..2ddbaadaee 100644 --- a/ui/components/shadcn/select/multiselect.tsx +++ b/ui/components/shadcn/select/multiselect.tsx @@ -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 ( +
+ {children} +
+ ); +} + export function MultiSelectItem({ value, children, diff --git a/ui/components/shadcn/table/data-table-filter-custom-batch.test.tsx b/ui/components/shadcn/table/data-table-filter-custom-batch.test.tsx index 21e8f384a5..e1465be8bb 100644 --- a/ui/components/shadcn/table/data-table-filter-custom-batch.test.tsx +++ b/ui/components/shadcn/table/data-table-filter-custom-batch.test.tsx @@ -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; }) => ( -
+
+ {children} {/* expose a select to drive value changes in tests */}