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