mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-03 17:54:05 +00:00
perf(ui): stream findings page and lazy-load check filter
This commit is contained in:
@@ -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",
|
||||
() => ({
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<ContentLayout
|
||||
title="Findings"
|
||||
@@ -108,31 +78,16 @@ export default async function Findings({
|
||||
>
|
||||
<FilterTransitionWrapper>
|
||||
<div className="mb-6">
|
||||
<FindingsFilters
|
||||
providers={providersData?.data || []}
|
||||
providerGroups={providerGroupsData?.data || []}
|
||||
completedScanIds={completedScanIds}
|
||||
scanDetails={scanDetails}
|
||||
uniqueRegions={uniqueRegions}
|
||||
uniqueServices={uniqueServices}
|
||||
uniqueResourceTypes={uniqueResourceTypes}
|
||||
uniqueCategories={uniqueCategories}
|
||||
uniqueGroups={uniqueGroups}
|
||||
checkOptions={checkOptions}
|
||||
trailingControls={
|
||||
<SeedFromFindingsButton
|
||||
filterBag={filters}
|
||||
providers={providersData?.data || []}
|
||||
scans={scanDetails}
|
||||
uniqueRegions={uniqueRegions}
|
||||
uniqueServices={uniqueServices}
|
||||
uniqueResourceTypes={uniqueResourceTypes}
|
||||
uniqueCategories={uniqueCategories}
|
||||
uniqueGroups={uniqueGroups}
|
||||
isCloudEnabled={alertsEnabled}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Suspense fallback={<FindingsFiltersSkeleton />}>
|
||||
<FindingsFiltersSection
|
||||
filters={filters}
|
||||
resolvedFilters={resolvedFilters}
|
||||
hasHistoricalData={hasHistoricalData}
|
||||
query={query}
|
||||
encodedSort={encodedSort}
|
||||
completedScans={completedScans}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={<SkeletonTableFindings />}>
|
||||
<SSRDataTable
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Findings page renders a loading skeleton immediately and streams the table before the filter options
|
||||
@@ -28,9 +28,13 @@ import { ProviderProps } from "@/types/providers";
|
||||
import {
|
||||
buildFindingGroupFilterOption,
|
||||
buildFindingsFilterChips,
|
||||
type FindingCheckFilterOption,
|
||||
FILTER_CONTROL_COLUMN_CLASS,
|
||||
getFindingsFilterDisplayValue,
|
||||
} from "./findings-filters.utils";
|
||||
import {
|
||||
type FindingCheckOptionsSource,
|
||||
useFindingCheckOptions,
|
||||
} from "./use-finding-check-options";
|
||||
|
||||
interface FindingsFiltersProps {
|
||||
/** Provider data for provider/account filter controls. */
|
||||
@@ -44,7 +48,8 @@ interface FindingsFiltersProps {
|
||||
uniqueResourceTypes: string[];
|
||||
uniqueCategories: string[];
|
||||
uniqueGroups: string[];
|
||||
checkOptions?: FindingCheckFilterOption[];
|
||||
/** Enables the lazily loaded Finding Group filter. */
|
||||
checkOptionsSource?: FindingCheckOptionsSource;
|
||||
trailingControls?: ReactNode;
|
||||
variant?: "default" | "alerts-edit";
|
||||
}
|
||||
@@ -71,8 +76,6 @@ const countVisibleFilterKeys = (filters: Record<string, string[]>): number =>
|
||||
return true;
|
||||
}).length;
|
||||
|
||||
const FILTER_CONTROL_COLUMN_CLASS =
|
||||
"min-w-0 flex-none basis-full sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)] xl:basis-[calc((100%_-_2.25rem)/4)] 2xl:basis-[calc((100%_-_3rem)/5)]";
|
||||
const FILTER_GRID_ITEM_CLASS = "min-w-0";
|
||||
const FINDING_GROUP_FILTER_KEYS = ["filter[check_id]", "filter[check_id__in]"];
|
||||
|
||||
@@ -89,7 +92,7 @@ export const FindingsFilterBatchControls = ({
|
||||
uniqueResourceTypes,
|
||||
uniqueCategories,
|
||||
uniqueGroups,
|
||||
checkOptions = [],
|
||||
checkOptionsSource,
|
||||
trailingControls,
|
||||
appliedFilters,
|
||||
pendingFilters,
|
||||
@@ -107,6 +110,11 @@ export const FindingsFilterBatchControls = ({
|
||||
}: FindingsFilterBatchControlsProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const isAlertsEdit = variant === "alerts-edit";
|
||||
const {
|
||||
options: checkOptions,
|
||||
isLoading: isLoadingCheckOptions,
|
||||
loadAll: loadCheckOptions,
|
||||
} = useFindingCheckOptions({ source: checkOptionsSource });
|
||||
const checkTitles = Object.fromEntries(
|
||||
checkOptions.map(({ checkId, checkTitle }) => [
|
||||
checkId,
|
||||
@@ -118,6 +126,9 @@ export const FindingsFilterBatchControls = ({
|
||||
selectedCheckIds: getFilterValue("filter[check_id]"),
|
||||
selectedCheckIdsIn: getFilterValue("filter[check_id__in]"),
|
||||
checkTitles,
|
||||
lazy: checkOptionsSource
|
||||
? { onOpen: loadCheckOptions, isLoading: isLoadingCheckOptions }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const customFilters = [
|
||||
|
||||
@@ -431,4 +431,26 @@ describe("buildFindingGroupFilterOption", () => {
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the Finding Group filter visible with lazy loading hooks when nothing is loaded yet", () => {
|
||||
// Given
|
||||
const onOpen = () => undefined;
|
||||
|
||||
// When
|
||||
const filter = buildFindingGroupFilterOption({
|
||||
checkOptions: [],
|
||||
selectedCheckIds: [],
|
||||
selectedCheckIdsIn: [],
|
||||
checkTitles: {},
|
||||
lazy: { onOpen, isLoading: true },
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(filter).toMatchObject({
|
||||
key: "check_id__in",
|
||||
values: [],
|
||||
onOpen,
|
||||
isLoading: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,14 @@ export interface FindingCheckFilterOption {
|
||||
checkTitle?: string;
|
||||
}
|
||||
|
||||
export interface FindingGroupLazyOptions {
|
||||
onOpen: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const FILTER_CONTROL_COLUMN_CLASS =
|
||||
"min-w-0 flex-none basis-full sm:basis-[calc((100%_-_0.75rem)/2)] lg:basis-[calc((100%_-_1.5rem)/3)] xl:basis-[calc((100%_-_2.25rem)/4)] 2xl:basis-[calc((100%_-_3rem)/5)]";
|
||||
|
||||
interface GetFindingsFilterDisplayValueOptions {
|
||||
providers?: ProviderProps[];
|
||||
scans?: Array<{ [scanId: string]: ScanEntity }>;
|
||||
@@ -122,11 +130,14 @@ export function buildFindingGroupFilterOption({
|
||||
selectedCheckIds,
|
||||
selectedCheckIdsIn,
|
||||
checkTitles,
|
||||
lazy,
|
||||
}: {
|
||||
checkOptions: FindingCheckFilterOption[];
|
||||
selectedCheckIds: string[];
|
||||
selectedCheckIdsIn: string[];
|
||||
checkTitles: Record<string, string>;
|
||||
/** Keeps the dropdown visible with no values so they can load on open. */
|
||||
lazy?: FindingGroupLazyOptions;
|
||||
}): FilterOption | null {
|
||||
const values = uniqueNonEmptyValues([
|
||||
...checkOptions.map((option) => option.checkId),
|
||||
@@ -134,7 +145,7 @@ export function buildFindingGroupFilterOption({
|
||||
...selectedCheckIdsIn,
|
||||
]);
|
||||
|
||||
if (values.length === 0) {
|
||||
if (values.length === 0 && !lazy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -147,6 +158,7 @@ export function buildFindingGroupFilterOption({
|
||||
checkTitles,
|
||||
}),
|
||||
index: 3,
|
||||
...(lazy && { onOpen: lazy.onOpen, isLoading: lazy.isLoading }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
values,
|
||||
onValuesChange,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
values?: string[];
|
||||
onValuesChange?: (values: string[]) => void;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) => (
|
||||
<div data-testid="multiselect" data-values={JSON.stringify(values ?? [])}>
|
||||
<div
|
||||
data-testid="multiselect"
|
||||
data-values={JSON.stringify(values ?? [])}
|
||||
data-open={String(Boolean(open))}
|
||||
>
|
||||
<button type="button" onClick={() => onOpenChange?.(!open)}>
|
||||
toggle
|
||||
</button>
|
||||
{children}
|
||||
{/* expose a select to drive value changes in tests */}
|
||||
<select
|
||||
@@ -77,6 +88,9 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
|
||||
data-search-placeholder={
|
||||
typeof search === "object" ? search.placeholder : String(search)
|
||||
}
|
||||
data-empty-message={
|
||||
typeof search === "object" ? search.emptyMessage : undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -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 label = filter.labelCheckboxGroup.toLowerCase();
|
||||
const isLoadingValues = filter.isLoading && filter.values.length === 0;
|
||||
return {
|
||||
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
|
||||
const getEntityForValue = (
|
||||
filter: FilterOption,
|
||||
@@ -286,7 +294,7 @@ export const DataTableFilterCustom = ({
|
||||
<MultiSelect
|
||||
key={filter.key}
|
||||
open={openFilterKey === filter.key}
|
||||
onOpenChange={(open) => setOpenFilterKey(open ? filter.key : null)}
|
||||
onOpenChange={(open) => handleOpenChange(filter, open)}
|
||||
values={selectedValues}
|
||||
onValuesChange={(values) => pushDropdownFilter(filter, values)}
|
||||
>
|
||||
|
||||
@@ -20,9 +20,12 @@ vi.mock("@/lib", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/table/resource-detail-drawer", () => ({
|
||||
useResourceDetailDrawer: useResourceDetailDrawerMock,
|
||||
}));
|
||||
vi.mock(
|
||||
"@/components/findings/table/resource-detail-drawer/use-resource-detail-drawer",
|
||||
() => ({
|
||||
useResourceDetailDrawer: useResourceDetailDrawerMock,
|
||||
}),
|
||||
);
|
||||
|
||||
import {
|
||||
type FindingGroupRow,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { OnChangeFn, Row, RowSelectionState } from "@tanstack/react-table";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
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 { applyDefaultMutedFilter } from "@/lib";
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getFindingGroupFilterOptions } from "./finding-group-filter-options";
|
||||
import {
|
||||
getFindingGroupFilterOptions,
|
||||
getSelectedFindingCheckOptions,
|
||||
} from "./finding-group-filter-options";
|
||||
|
||||
function makeResponse(
|
||||
pageCount: number,
|
||||
@@ -62,15 +65,151 @@ describe("getFindingGroupFilterOptions", () => {
|
||||
filters: { "filter[severity__in]": "high" },
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
sort: "check_id",
|
||||
});
|
||||
expect(fetchFindingGroups).toHaveBeenNthCalledWith(2, {
|
||||
filters: { "filter[severity__in]": "high" },
|
||||
page: 2,
|
||||
pageSize: 100,
|
||||
sort: "check_id",
|
||||
});
|
||||
expect(options).toEqual([
|
||||
{ checkId: "check-a", checkTitle: "Check A updated" },
|
||||
{ checkId: "check-b", checkTitle: "Check B" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("requests the remaining pages concurrently once the page count is known", async () => {
|
||||
// Given
|
||||
const pending: Array<(value: unknown) => void> = [];
|
||||
const fetchFindingGroups = vi.fn(
|
||||
({ page }: { page: number }) =>
|
||||
new Promise((resolve) => {
|
||||
if (page === 1) {
|
||||
resolve(makeResponse(3, [{ id: "check-a", title: "Check A" }]));
|
||||
return;
|
||||
}
|
||||
pending.push(resolve);
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
const optionsPromise = getFindingGroupFilterOptions({
|
||||
fetchFindingGroups,
|
||||
filters: {},
|
||||
});
|
||||
await vi.waitFor(() => expect(fetchFindingGroups).toHaveBeenCalledTimes(3));
|
||||
pending[0](makeResponse(3, [{ id: "check-b", title: "Check B" }]));
|
||||
pending[1](makeResponse(3, [{ id: "check-c", title: "Check C" }]));
|
||||
const options = await optionsPromise;
|
||||
|
||||
// Then
|
||||
expect(
|
||||
fetchFindingGroups.mock.calls.map(([params]) => params.page),
|
||||
).toEqual([1, 2, 3]);
|
||||
expect(options.map((option) => option.checkId)).toEqual([
|
||||
"check-a",
|
||||
"check-b",
|
||||
"check-c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { adaptFindingGroupsResponse } from "@/actions/finding-groups/finding-groups.adapter";
|
||||
|
||||
const FINDING_GROUP_FILTER_OPTION_PAGE_SIZE = 100;
|
||||
// Options only need a stable page order; the table's composite sort costs an
|
||||
// extra aggregation per page on the API.
|
||||
const FINDING_GROUP_FILTER_OPTION_SORT = "check_id";
|
||||
// Each page can hit the raw findings aggregation, so bound the DB fan-out.
|
||||
const FINDING_GROUP_FILTER_OPTION_CONCURRENCY = 4;
|
||||
const FINDING_GROUP_OWN_FILTER_KEYS = new Set([
|
||||
"filter[check_id]",
|
||||
"filter[check_id__in]",
|
||||
@@ -9,14 +14,15 @@ const FINDING_GROUP_OWN_FILTER_KEYS = new Set([
|
||||
interface FindingGroupFilterFetcherParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
sort: string;
|
||||
filters: Record<string, string | string[] | undefined>;
|
||||
}
|
||||
|
||||
type FindingGroupFilterFetcher = (
|
||||
export type FindingGroupFilterFetcher = (
|
||||
params: FindingGroupFilterFetcherParams,
|
||||
) => Promise<unknown>;
|
||||
|
||||
function excludeFindingGroupOwnFilters(
|
||||
export function excludeFindingGroupOwnFilters(
|
||||
filters: Record<string, string | string[] | undefined>,
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
@@ -48,6 +54,35 @@ function getTotalPages(response: unknown, currentPage: number): number {
|
||||
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({
|
||||
fetchFindingGroups,
|
||||
filters,
|
||||
@@ -56,25 +91,47 @@ export async function getFindingGroupFilterOptions({
|
||||
filters: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
const optionFilters = excludeFindingGroupOwnFilters(filters);
|
||||
const options = new Map<string, { checkId: string; checkTitle: string }>();
|
||||
let page = 1;
|
||||
|
||||
while (true) {
|
||||
const response = await fetchFindingGroups({
|
||||
const fetchPage = (page: number) =>
|
||||
fetchFindingGroups({
|
||||
filters: optionFilters,
|
||||
page,
|
||||
pageSize: FINDING_GROUP_FILTER_OPTION_PAGE_SIZE,
|
||||
sort: FINDING_GROUP_FILTER_OPTION_SORT,
|
||||
});
|
||||
|
||||
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)) {
|
||||
options.set(group.checkId, {
|
||||
checkId: group.checkId,
|
||||
checkTitle: group.checkTitle,
|
||||
});
|
||||
}
|
||||
|
||||
if (page >= getTotalPages(response, page)) break;
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return Array.from(options.values());
|
||||
|
||||
@@ -31,6 +31,9 @@ export interface FilterOption {
|
||||
showSelectAll?: boolean;
|
||||
defaultToSelectAll?: boolean;
|
||||
defaultValues?: string[];
|
||||
/** Called whenever the dropdown opens, so callers can load values lazily. */
|
||||
onOpen?: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface CustomDropdownFilterProps {
|
||||
|
||||
Reference in New Issue
Block a user