From e75b06c96e823aab06ecd62fdc2b5e92dbc66531 Mon Sep 17 00:00:00 2001 From: pedrooot Date: Fri, 25 Sep 2026 13:40:07 +0200 Subject: [PATCH] perf(ui): stream findings page and lazy-load check filter --- .../__tests__/alert-form-modal.test.tsx | 5 + .../_components/seed-from-findings-button.tsx | 10 +- .../findings-filters-section.test.tsx | 188 ++++++++++++++++++ .../_components/findings-filters-section.tsx | 101 ++++++++++ .../_components/findings-filters-skeleton.tsx | 18 ++ ui/app/(prowler)/findings/loading.tsx | 19 ++ ui/app/(prowler)/findings/page.test.ts | 32 ++- ui/app/(prowler)/findings/page.tsx | 119 ++++------- ui/changelog.d/findings-page-load.changed.md | 1 + ui/components/findings/findings-filters.tsx | 21 +- .../findings/findings-filters.utils.test.ts | 22 ++ .../findings/findings-filters.utils.ts | 14 +- .../table/inline-resource-container.tsx | 12 +- .../use-finding-check-options.test.ts | 180 +++++++++++++++++ .../findings/use-finding-check-options.ts | 95 +++++++++ .../data-table-filter-custom-batch.test.tsx | 73 ++++++- .../shadcn/table/data-table-filter-custom.tsx | 12 +- .../use-finding-group-resource-state.test.ts | 9 +- ui/hooks/use-finding-group-resource-state.ts | 2 +- ui/lib/finding-group-filter-options.test.ts | 141 ++++++++++++- ui/lib/finding-group-filter-options.ts | 77 ++++++- ui/types/filters.ts | 3 + 22 files changed, 1045 insertions(+), 109 deletions(-) create mode 100644 ui/app/(prowler)/findings/_components/findings-filters-section.test.tsx 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-load.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/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx b/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx index 4fa46494ae..dfbc85b6d1 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); +vi.mock("@/actions/finding-groups", () => ({ + getFindingGroups: vi.fn(), + getLatestFindingGroups: vi.fn(), +})); + vi.mock( "@/components/compliance/compliance-header/compliance-scan-info", () => ({ diff --git a/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx b/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx index 988f7e70f8..c6efed2448 100644 --- a/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx +++ b/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx @@ -1,12 +1,12 @@ "use client"; import { BellPlusIcon } from "lucide-react"; +import dynamic from "next/dynamic"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { createAlert, seedAlertRule } from "@/app/(prowler)/alerts/_actions"; -import { AlertFormModal } from "@/app/(prowler)/alerts/_components/alert-form-modal"; import { getFindingsFiltersFromAlertCondition, toAlertPayload, @@ -36,6 +36,14 @@ import type { ScanEntity } from "@/types"; import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; 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 = "Apply at least one Findings filter to create an alert from filters."; const ALERT_SEED_ERROR = "Apply at least one alert-compatible Findings filter."; diff --git a/ui/app/(prowler)/findings/_components/findings-filters-section.test.tsx b/ui/app/(prowler)/findings/_components/findings-filters-section.test.tsx new file mode 100644 index 0000000000..6075bb70df --- /dev/null +++ b/ui/app/(prowler)/findings/_components/findings-filters-section.test.tsx @@ -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 + >; + + // 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>; + + // 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>; + + // 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 }), + ); + }); +}); 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..c4cd9e96a1 --- /dev/null +++ b/ui/app/(prowler)/findings/_components/findings-filters-section.tsx @@ -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; + resolvedFilters: Record; + 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 ( + + } + /> + ); +} 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..e8a46078e2 --- /dev/null +++ b/ui/app/(prowler)/findings/_components/findings-filters-skeleton.tsx @@ -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 ( +
+ {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.test.ts b/ui/app/(prowler)/findings/page.test.ts index 419bcc80fb..2c270fbee3 100644 --- a/ui/app/(prowler)/findings/page.test.ts +++ b/ui/app/(prowler)/findings/page.test.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -16,6 +16,10 @@ describe("findings page", () => { const currentDir = path.dirname(fileURLToPath(import.meta.url)); const pagePath = path.join(currentDir, "page.tsx"); 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", () => { 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", () => { 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"); + }); }); diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index 4a7f7d2f80..f0e718f626 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,35 @@ export default async function Findings({ const { encodedSort } = extractSortAndKey(resolvedSearchParams); const { filters, query } = extractFiltersAndQuery(resolvedSearchParams); - const [providersData, providerGroupsData, scansData] = await Promise.all([ - getAllProviders(), - getAllProviderGroups(), - getScans({ pageSize: 50 }), + const [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 +70,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/table/inline-resource-container.tsx b/ui/components/findings/table/inline-resource-container.tsx index 9a34ba8c1f..f9fe2ac7b5 100644 --- a/ui/components/findings/table/inline-resource-container.tsx +++ b/ui/components/findings/table/inline-resource-container.tsx @@ -7,6 +7,7 @@ import { } from "@tanstack/react-table"; import { AnimatePresence, motion } from "framer-motion"; import { ChevronsDown } from "lucide-react"; +import dynamic from "next/dynamic"; import { useImperativeHandle, useRef, useState } from "react"; import { @@ -33,7 +34,16 @@ import { getFindingGroupEmptyStateMessage, getFindingGroupSkeletonCount, } 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 { /** Soft-refresh resources (re-fetch page 1 without skeletons). */ 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..7815461526 --- /dev/null +++ b/ui/components/findings/use-finding-check-options.test.ts @@ -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 = { + "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(); + }); +}); 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..c72c9df5f9 --- /dev/null +++ b/ui/components/findings/use-finding-check-options.ts @@ -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; + 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(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, + }; +} 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..365a254071 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 */}