mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 20:42:02 +00:00
fix(ui): enable triage editing in compliance findings table
Wire the triage status control and note loading into the compliance requirement findings table so triage state can be edited there, matching the findings page and resource drawer. Mutelist-shortcut statuses now refetch so the muted filter is honored. Surface the finding triage docs from an info link in the finding note modal, independent of switching to Remediating. Extract the optimistic finding-row triage patch into a shared helper in lib/finding-triage and reuse it across compliance and the resource drawer. Replace the ref-guarded data-fetching useEffect in the compliance accordion with a co-located useRequirementFindings hook that keys on primitive deps and cancels stale responses. Move createDict to lib/utils so the hook avoids helper.ts's heavy imports.
This commit is contained in:
@@ -7,7 +7,7 @@ import { ColumnLatestFindings } from "@/components/overview/new-findings-table/t
|
||||
import { CardTitle } from "@/components/shadcn";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { FINDINGS_FILTERED_SORT, MUTED_FILTER } from "@/lib";
|
||||
import { createDict } from "@/lib/helper";
|
||||
import { createDict } from "@/lib/utils";
|
||||
import { FindingProps, SearchParamsProps } from "@/types";
|
||||
|
||||
import { pickFilterParams } from "../../_lib/filter-params";
|
||||
|
||||
@@ -13,4 +13,22 @@ describe("client accordion content", () => {
|
||||
expect(source).toContain("getStandaloneFindingColumns");
|
||||
expect(source).not.toContain("getColumnFindings");
|
||||
});
|
||||
|
||||
it("wires triage update and note loading actions into compliance findings", () => {
|
||||
expect(source).toContain("updateFindingTriage");
|
||||
expect(source).toContain("loadLatestFindingTriageNote");
|
||||
expect(source).toContain("onTriageUpdateAction");
|
||||
expect(source).toContain("onTriageNoteLoadAction");
|
||||
});
|
||||
|
||||
it("refetches findings after mutelist-shortcut triage updates like the resource drawer", () => {
|
||||
expect(source).toContain("shouldRefreshAfterTriageUpdate");
|
||||
expect(source).toContain("reload()");
|
||||
});
|
||||
|
||||
it("delegates data fetching to the hook instead of effect/ref choreography", () => {
|
||||
expect(source).toContain("useRequirementFindings");
|
||||
expect(source).not.toContain("useEffect");
|
||||
expect(source).not.toContain("useRef");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { getFindings } from "@/actions/findings/findings";
|
||||
import {
|
||||
loadLatestFindingTriageNote,
|
||||
updateFindingTriage,
|
||||
} from "@/actions/findings";
|
||||
import {
|
||||
getStandaloneFindingColumns,
|
||||
SkeletonTableFindings,
|
||||
@@ -12,11 +14,14 @@ import {
|
||||
import { Alert, AlertDescription } from "@/components/shadcn";
|
||||
import { Accordion } from "@/components/ui/accordion/Accordion";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { createDict, FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib";
|
||||
import { FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib";
|
||||
import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons";
|
||||
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
|
||||
import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage";
|
||||
import { Requirement } from "@/types/compliance";
|
||||
import { FindingProps, FindingsResponse } from "@/types/components";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
|
||||
import { useRequirementFindings } from "./use-requirement-findings";
|
||||
|
||||
interface ClientAccordionContentProps {
|
||||
requirement: Requirement;
|
||||
@@ -31,100 +36,47 @@ export const ClientAccordionContent = ({
|
||||
scanId,
|
||||
disableFindings = false,
|
||||
}: ClientAccordionContentProps) => {
|
||||
const [findings, setFindings] = useState<FindingsResponse | null>(null);
|
||||
const [expandedFindings, setExpandedFindings] = useState<FindingProps[]>([]);
|
||||
const searchParams = useSearchParams();
|
||||
const pageNumber = searchParams.get("page") || "1";
|
||||
const pageSize = searchParams.get("pageSize") || "10";
|
||||
const complianceId = searchParams.get("complianceId");
|
||||
const openFindingId = searchParams.get("id");
|
||||
const sort = searchParams.get("sort") || FINDINGS_DEFAULT_SORT;
|
||||
const loadedPageRef = useRef<string | null>(null);
|
||||
const loadedPageSizeRef = useRef<string | null>(null);
|
||||
const loadedSortRef = useRef<string | null>(null);
|
||||
const loadedMutedRef = useRef<string | null>(null);
|
||||
const isExpandedRef = useRef(false);
|
||||
const region = searchParams.get("filter[region__in]") || "";
|
||||
// Respect the user's muted preference from the URL; default to EXCLUDE
|
||||
// so the requirement view stays consistent with every other findings
|
||||
// surface in the app (findings page, resource drawer, overview widgets).
|
||||
const mutedFilter = searchParams.get("filter[muted]") || MUTED_FILTER.EXCLUDE;
|
||||
|
||||
useEffect(() => {
|
||||
async function loadFindings() {
|
||||
if (
|
||||
const checks = requirement.check_ids || [];
|
||||
|
||||
const { findings, expandedFindings, patchTriageUpdate, reload } =
|
||||
useRequirementFindings({
|
||||
enabled:
|
||||
!disableFindings &&
|
||||
requirement.check_ids?.length > 0 &&
|
||||
requirement.status !== "No findings" &&
|
||||
(loadedPageRef.current !== pageNumber ||
|
||||
loadedPageSizeRef.current !== pageSize ||
|
||||
loadedSortRef.current !== sort ||
|
||||
loadedMutedRef.current !== mutedFilter ||
|
||||
!isExpandedRef.current)
|
||||
) {
|
||||
loadedPageRef.current = pageNumber;
|
||||
loadedPageSizeRef.current = pageSize;
|
||||
loadedSortRef.current = sort;
|
||||
loadedMutedRef.current = mutedFilter;
|
||||
isExpandedRef.current = true;
|
||||
checks.length > 0 &&
|
||||
requirement.status !== "No findings",
|
||||
checkIds: checks,
|
||||
scanId,
|
||||
pageNumber,
|
||||
pageSize,
|
||||
sort,
|
||||
region,
|
||||
mutedFilter,
|
||||
});
|
||||
|
||||
try {
|
||||
const checkIds = requirement.check_ids;
|
||||
const encodedSort = sort.replace(/^\+/, "");
|
||||
const findingsData = await getFindings({
|
||||
filters: {
|
||||
"filter[check_id__in]": checkIds.join(","),
|
||||
"filter[scan]": scanId,
|
||||
"filter[muted]": mutedFilter,
|
||||
...(region && { "filter[region__in]": region }),
|
||||
},
|
||||
page: parseInt(pageNumber, 10),
|
||||
pageSize: parseInt(pageSize, 10),
|
||||
sort: encodedSort,
|
||||
});
|
||||
const handleTriageUpdate = async (input: UpdateFindingTriageInput) => {
|
||||
await updateFindingTriage(input);
|
||||
|
||||
setFindings(findingsData);
|
||||
|
||||
if (findingsData?.data) {
|
||||
// Create dictionaries for resources, scans, and providers
|
||||
const resourceDict = createDict("resources", findingsData);
|
||||
const scanDict = createDict("scans", findingsData);
|
||||
const providerDict = createDict("providers", findingsData);
|
||||
|
||||
// Expand each finding with its corresponding resource, scan, and provider
|
||||
const expandedData = findingsData.data.map(
|
||||
(finding: FindingProps) => {
|
||||
const scan = scanDict[finding.relationships?.scan?.data?.id];
|
||||
const resource =
|
||||
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
|
||||
const provider =
|
||||
providerDict[scan?.relationships?.provider?.data?.id];
|
||||
|
||||
return {
|
||||
...finding,
|
||||
relationships: { scan, resource, provider },
|
||||
};
|
||||
},
|
||||
);
|
||||
setExpandedFindings(expandedData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading findings:", error);
|
||||
}
|
||||
}
|
||||
// Mutelist-shortcut statuses mute the finding server-side; refetch so the
|
||||
// list honors the muted filter, matching the resource drawer behavior.
|
||||
if (shouldRefreshAfterTriageUpdate(input)) {
|
||||
reload();
|
||||
return;
|
||||
}
|
||||
|
||||
loadFindings();
|
||||
}, [
|
||||
requirement,
|
||||
scanId,
|
||||
pageNumber,
|
||||
pageSize,
|
||||
sort,
|
||||
region,
|
||||
mutedFilter,
|
||||
disableFindings,
|
||||
]);
|
||||
patchTriageUpdate(input);
|
||||
};
|
||||
|
||||
const renderDetails = () => {
|
||||
if (!complianceId) {
|
||||
@@ -148,7 +100,6 @@ export const ClientAccordionContent = ({
|
||||
);
|
||||
}
|
||||
|
||||
const checks = requirement.check_ids || [];
|
||||
const checksList = (
|
||||
<div className="flex items-center px-2 text-sm">
|
||||
<div className="w-full flex-col">
|
||||
@@ -184,8 +135,12 @@ export const ClientAccordionContent = ({
|
||||
<h4 className="mb-2 text-sm font-medium">Findings</h4>
|
||||
|
||||
<DataTable
|
||||
columns={getStandaloneFindingColumns({ openFindingId })}
|
||||
data={expandedFindings || []}
|
||||
columns={getStandaloneFindingColumns({
|
||||
openFindingId,
|
||||
onTriageUpdateAction: handleTriageUpdate,
|
||||
onTriageNoteLoadAction: loadLatestFindingTriageNote,
|
||||
})}
|
||||
data={expandedFindings}
|
||||
metadata={findings?.meta}
|
||||
disableScroll={true}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { FINDING_TRIAGE_STATUS } from "@/types/findings-triage";
|
||||
|
||||
import { useRequirementFindings } from "./use-requirement-findings";
|
||||
|
||||
const findingsActionsMock = vi.hoisted(() => ({
|
||||
getFindings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/findings", () => findingsActionsMock);
|
||||
|
||||
function makeFindingsResponse() {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
id: "finding-1",
|
||||
attributes: { muted: false, status: "FAIL" },
|
||||
triage: {
|
||||
findingId: "finding-1",
|
||||
findingUid: "uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
hasVisibleNote: false,
|
||||
isMuted: false,
|
||||
canEdit: true,
|
||||
billingHref: "https://prowler.com/pricing",
|
||||
},
|
||||
relationships: {
|
||||
scan: { data: { id: "scan-1" } },
|
||||
resources: { data: [{ id: "resource-1" }] },
|
||||
},
|
||||
},
|
||||
],
|
||||
included: [
|
||||
{
|
||||
type: "scans",
|
||||
id: "scan-1",
|
||||
relationships: { provider: { data: { id: "provider-1" } } },
|
||||
},
|
||||
{ type: "resources", id: "resource-1" },
|
||||
{ type: "providers", id: "provider-1" },
|
||||
],
|
||||
meta: { pagination: { count: 1, pages: 1 } },
|
||||
};
|
||||
}
|
||||
|
||||
function defaultOptions(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
enabled: true,
|
||||
checkIds: ["check_1", "check_2"],
|
||||
scanId: "scan-1",
|
||||
pageNumber: "1",
|
||||
pageSize: "10",
|
||||
sort: "+severity",
|
||||
region: "",
|
||||
mutedFilter: "false",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function flushAsync() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe("useRequirementFindings", () => {
|
||||
beforeEach(() => {
|
||||
findingsActionsMock.getFindings.mockReset();
|
||||
findingsActionsMock.getFindings.mockResolvedValue(makeFindingsResponse());
|
||||
});
|
||||
|
||||
it("should fetch findings with the requirement filters and strip the sort plus sign", async () => {
|
||||
// Given / When
|
||||
renderHook(() => useRequirementFindings(defaultOptions()));
|
||||
await flushAsync();
|
||||
|
||||
// Then
|
||||
expect(findingsActionsMock.getFindings).toHaveBeenCalledTimes(1);
|
||||
expect(findingsActionsMock.getFindings).toHaveBeenCalledWith({
|
||||
filters: {
|
||||
"filter[check_id__in]": "check_1,check_2",
|
||||
"filter[scan]": "scan-1",
|
||||
"filter[muted]": "false",
|
||||
},
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
sort: "severity",
|
||||
});
|
||||
});
|
||||
|
||||
it("should expand findings with their included scan, resource, and provider", async () => {
|
||||
// Given / When
|
||||
const { result } = renderHook(() =>
|
||||
useRequirementFindings(defaultOptions()),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
// Then
|
||||
const [expanded] = result.current.expandedFindings;
|
||||
expect(expanded.relationships).toEqual({
|
||||
scan: expect.objectContaining({ id: "scan-1" }),
|
||||
resource: expect.objectContaining({ id: "resource-1" }),
|
||||
provider: expect.objectContaining({ id: "provider-1" }),
|
||||
});
|
||||
expect(result.current.findings?.meta?.pagination?.count).toBe(1);
|
||||
});
|
||||
|
||||
it("should not fetch when disabled or without check ids", async () => {
|
||||
// Given / When
|
||||
renderHook(() =>
|
||||
useRequirementFindings(defaultOptions({ enabled: false })),
|
||||
);
|
||||
renderHook(() => useRequirementFindings(defaultOptions({ checkIds: [] })));
|
||||
await flushAsync();
|
||||
|
||||
// Then
|
||||
expect(findingsActionsMock.getFindings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not refetch when only the checkIds array identity changes", async () => {
|
||||
// Given
|
||||
const { rerender } = renderHook((props) => useRequirementFindings(props), {
|
||||
initialProps: defaultOptions(),
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
// When: same values, fresh array identity (parent re-render)
|
||||
rerender(defaultOptions());
|
||||
await flushAsync();
|
||||
|
||||
// Then
|
||||
expect(findingsActionsMock.getFindings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should refetch when a query parameter changes", async () => {
|
||||
// Given
|
||||
const { rerender } = renderHook((props) => useRequirementFindings(props), {
|
||||
initialProps: defaultOptions(),
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
// When
|
||||
rerender(defaultOptions({ pageNumber: "2" }));
|
||||
await flushAsync();
|
||||
|
||||
// Then
|
||||
expect(findingsActionsMock.getFindings).toHaveBeenCalledTimes(2);
|
||||
expect(findingsActionsMock.getFindings).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ page: 2 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should refetch on reload keeping previous data visible meanwhile", async () => {
|
||||
// Given
|
||||
const { result } = renderHook(() =>
|
||||
useRequirementFindings(defaultOptions()),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.reload();
|
||||
});
|
||||
|
||||
// Then: previous data is not cleared while the refetch is in flight
|
||||
expect(result.current.findings).not.toBeNull();
|
||||
await flushAsync();
|
||||
expect(findingsActionsMock.getFindings).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should patch the matching row triage optimistically", async () => {
|
||||
// Given
|
||||
const { result } = renderHook(() =>
|
||||
useRequirementFindings(defaultOptions()),
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.patchTriageUpdate({
|
||||
findingId: "finding-1",
|
||||
findingUid: "uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
isMuted: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.expandedFindings[0]?.triage).toEqual(
|
||||
expect.objectContaining({
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
label: "Remediating",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should ignore stale responses after the query changes", async () => {
|
||||
// Given: first request resolves late, second resolves immediately
|
||||
let resolveFirst: (value: unknown) => void = () => {};
|
||||
const staleResponse = {
|
||||
...makeFindingsResponse(),
|
||||
meta: { pagination: { count: 99, pages: 9 } },
|
||||
};
|
||||
findingsActionsMock.getFindings
|
||||
.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (resolveFirst = resolve)),
|
||||
)
|
||||
.mockResolvedValueOnce(makeFindingsResponse());
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
(props) => useRequirementFindings(props),
|
||||
{ initialProps: defaultOptions() },
|
||||
);
|
||||
|
||||
// When: the query changes before the first request settles
|
||||
rerender(defaultOptions({ pageNumber: "2" }));
|
||||
await flushAsync();
|
||||
act(() => {
|
||||
resolveFirst(staleResponse);
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
// Then: the stale response never overwrites the fresh one
|
||||
expect(result.current.findings?.meta?.pagination?.count).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getFindings } from "@/actions/findings";
|
||||
import { applyOptimisticFindingTriageRowsUpdate } from "@/lib/finding-triage";
|
||||
import { createDict } from "@/lib/utils";
|
||||
import { FindingProps, FindingsResponse } from "@/types/components";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
|
||||
interface UseRequirementFindingsOptions {
|
||||
enabled: boolean;
|
||||
checkIds: string[];
|
||||
scanId: string;
|
||||
pageNumber: string;
|
||||
pageSize: string;
|
||||
sort: string;
|
||||
region: string;
|
||||
mutedFilter: string;
|
||||
}
|
||||
|
||||
interface UseRequirementFindingsReturn {
|
||||
findings: FindingsResponse | null;
|
||||
expandedFindings: FindingProps[];
|
||||
patchTriageUpdate: (input: UpdateFindingTriageInput) => void;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
export function useRequirementFindings({
|
||||
enabled,
|
||||
checkIds,
|
||||
scanId,
|
||||
pageNumber,
|
||||
pageSize,
|
||||
sort,
|
||||
region,
|
||||
mutedFilter,
|
||||
}: UseRequirementFindingsOptions): UseRequirementFindingsReturn {
|
||||
const [findings, setFindings] = useState<FindingsResponse | null>(null);
|
||||
const [expandedFindings, setExpandedFindings] = useState<FindingProps[]>([]);
|
||||
const [reloadNonce, setReloadNonce] = useState(0);
|
||||
|
||||
// Depend on the joined value, not the array: the requirement prop gets a
|
||||
// fresh identity on every parent render and must not retrigger the fetch.
|
||||
const checkIdsKey = checkIds.join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !checkIdsKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadFindings = async () => {
|
||||
try {
|
||||
const findingsData = await getFindings({
|
||||
filters: {
|
||||
"filter[check_id__in]": checkIdsKey,
|
||||
"filter[scan]": scanId,
|
||||
"filter[muted]": mutedFilter,
|
||||
...(region && { "filter[region__in]": region }),
|
||||
},
|
||||
page: parseInt(pageNumber, 10),
|
||||
pageSize: parseInt(pageSize, 10),
|
||||
sort: sort.replace(/^\+/, ""),
|
||||
});
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setFindings(findingsData);
|
||||
|
||||
if (findingsData?.data) {
|
||||
const resourceDict = createDict("resources", findingsData);
|
||||
const scanDict = createDict("scans", findingsData);
|
||||
const providerDict = createDict("providers", findingsData);
|
||||
|
||||
const expandedData = findingsData.data.map(
|
||||
(finding: FindingProps) => {
|
||||
const scan = scanDict[finding.relationships?.scan?.data?.id];
|
||||
const resource =
|
||||
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
|
||||
const provider =
|
||||
providerDict[scan?.relationships?.provider?.data?.id];
|
||||
|
||||
return {
|
||||
...finding,
|
||||
relationships: { scan, resource, provider },
|
||||
};
|
||||
},
|
||||
);
|
||||
setExpandedFindings(expandedData);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error("Error loading findings:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadFindings();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
enabled,
|
||||
checkIdsKey,
|
||||
scanId,
|
||||
pageNumber,
|
||||
pageSize,
|
||||
sort,
|
||||
region,
|
||||
mutedFilter,
|
||||
reloadNonce,
|
||||
]);
|
||||
|
||||
const patchTriageUpdate = (input: UpdateFindingTriageInput) => {
|
||||
setExpandedFindings((currentFindings) =>
|
||||
applyOptimisticFindingTriageRowsUpdate(currentFindings, input),
|
||||
);
|
||||
};
|
||||
|
||||
const reload = () => {
|
||||
setReloadNonce((value) => value + 1);
|
||||
};
|
||||
|
||||
return { findings, expandedFindings, patchTriageUpdate, reload };
|
||||
}
|
||||
@@ -49,6 +49,7 @@ beforeAll(() => {
|
||||
});
|
||||
});
|
||||
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
@@ -147,6 +148,21 @@ describe("FindingNoteModal", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("should render a documentation link without requiring Remediating status", () => {
|
||||
// Given / When
|
||||
renderNoteModal();
|
||||
|
||||
// Then
|
||||
const docsLink = screen.getByRole("link", {
|
||||
name: /docs/i,
|
||||
});
|
||||
expect(docsLink).toHaveAttribute("href", DOCS_URLS.FINDINGS_TRIAGE);
|
||||
expect(docsLink).toHaveAttribute("target", "_blank");
|
||||
expect(
|
||||
screen.queryByText(/automatically changed to Resolved/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should send existing note changes with noteId and without duplicate-note status payload", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink, Info } from "lucide-react";
|
||||
import { type FormEvent, useRef, useState } from "react";
|
||||
|
||||
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
|
||||
@@ -131,6 +132,21 @@ export function FindingNoteModal({
|
||||
unbreakable content (e.g. resource UIDs) widens the grid track past
|
||||
the modal instead of truncating. */}
|
||||
<form className="flex min-w-0 flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="text-text-neutral-secondary flex flex-wrap items-center gap-2 text-sm">
|
||||
<Info className="size-4 shrink-0" />
|
||||
<span>Learn how triage states work in the</span>
|
||||
<Button variant="link" size="link-sm" className="h-auto p-0" asChild>
|
||||
<a
|
||||
href={DOCS_URLS.FINDINGS_TRIAGE}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
<span>docs</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-bg-neutral-tertiary flex size-9 shrink-0 items-center justify-center rounded-lg">
|
||||
{findingContext.providerType ? (
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getResourceDrawerData } from "@/actions/resources";
|
||||
import {
|
||||
applyOptimisticTriageSummaryUpdate,
|
||||
getOptimisticTriageMutedReason,
|
||||
shouldMarkFindingMutedForTriageUpdate,
|
||||
} from "@/lib/finding-triage";
|
||||
import { applyOptimisticFindingTriageRowsUpdate } from "@/lib/finding-triage";
|
||||
import { MetaDataProps } from "@/types";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
import { OrganizationResource } from "@/types/organizations";
|
||||
@@ -57,26 +53,7 @@ export function useResourceDrawerBootstrap({
|
||||
|
||||
const patchTriageUpdate = (input: UpdateFindingTriageInput) => {
|
||||
setFindingsData((findings) =>
|
||||
findings.map((finding) => {
|
||||
if (!finding.triage || finding.triage.findingId !== input.findingId) {
|
||||
return finding;
|
||||
}
|
||||
|
||||
const shouldMarkMuted = shouldMarkFindingMutedForTriageUpdate(input);
|
||||
|
||||
return {
|
||||
...finding,
|
||||
triage: applyOptimisticTriageSummaryUpdate(finding.triage, input),
|
||||
attributes: {
|
||||
...finding.attributes,
|
||||
muted: shouldMarkMuted ? true : finding.attributes.muted,
|
||||
muted_reason:
|
||||
shouldMarkMuted && input.isMuted !== true && input.status
|
||||
? getOptimisticTriageMutedReason(input.status)
|
||||
: finding.attributes.muted_reason,
|
||||
},
|
||||
};
|
||||
}),
|
||||
applyOptimisticFindingTriageRowsUpdate(findings, input),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import {
|
||||
applyOptimisticFindingTriageRowsUpdate,
|
||||
applyOptimisticFindingTriageRowUpdate,
|
||||
} from "./finding-triage";
|
||||
|
||||
interface TestFindingRowAttributes {
|
||||
muted: boolean;
|
||||
muted_reason?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface TestFindingRow {
|
||||
id: string;
|
||||
triage?: FindingTriageSummary;
|
||||
attributes: TestFindingRowAttributes;
|
||||
}
|
||||
|
||||
function makeTriageSummary(
|
||||
overrides?: Partial<FindingTriageSummary>,
|
||||
): FindingTriageSummary {
|
||||
return {
|
||||
findingId: "finding-1",
|
||||
findingUid: "uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
hasVisibleNote: false,
|
||||
isMuted: false,
|
||||
canEdit: true,
|
||||
billingHref: "https://prowler.com/pricing",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeFindingRow(overrides?: Partial<TestFindingRow>): TestFindingRow {
|
||||
return {
|
||||
id: "finding-1",
|
||||
triage: makeTriageSummary(),
|
||||
attributes: {
|
||||
muted: false,
|
||||
muted_reason: undefined,
|
||||
status: "FAIL",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("finding triage optimistic row updates", () => {
|
||||
it("should patch matching finding row triage and muted attributes", () => {
|
||||
// Given
|
||||
const finding = makeFindingRow();
|
||||
|
||||
// When
|
||||
const result = applyOptimisticFindingTriageRowUpdate(finding, {
|
||||
findingId: "finding-1",
|
||||
findingUid: "uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
isMuted: false,
|
||||
note: "Accepted by owner.",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).not.toBe(finding);
|
||||
expect(result.triage).toEqual(
|
||||
expect.objectContaining({
|
||||
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
label: "Risk Accepted",
|
||||
hasVisibleNote: true,
|
||||
notesCount: 1,
|
||||
isMuted: true,
|
||||
}),
|
||||
);
|
||||
expect(result.attributes).toEqual(
|
||||
expect.objectContaining({
|
||||
muted: true,
|
||||
muted_reason: "Finding triage status changed to Risk Accepted.",
|
||||
status: "FAIL",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should leave non-matching rows unchanged when patching a list", () => {
|
||||
// Given
|
||||
const matchingFinding = makeFindingRow();
|
||||
const otherFinding = makeFindingRow({
|
||||
id: "finding-2",
|
||||
triage: makeTriageSummary({
|
||||
findingId: "finding-2",
|
||||
findingUid: "uid-2",
|
||||
triageId: "triage-2",
|
||||
}),
|
||||
});
|
||||
|
||||
// When
|
||||
const result = applyOptimisticFindingTriageRowsUpdate(
|
||||
[matchingFinding, otherFinding],
|
||||
{
|
||||
findingId: "finding-1",
|
||||
findingUid: "uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
isMuted: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(result[0]?.triage).toEqual(
|
||||
expect.objectContaining({
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
label: "Remediating",
|
||||
}),
|
||||
);
|
||||
expect(result[1]).toBe(otherFinding);
|
||||
});
|
||||
|
||||
it("should leave rows without triage unchanged", () => {
|
||||
// Given
|
||||
const finding = makeFindingRow({ triage: undefined });
|
||||
|
||||
// When
|
||||
const result = applyOptimisticFindingTriageRowUpdate(finding, {
|
||||
findingId: "finding-1",
|
||||
findingUid: "uid-1",
|
||||
triageId: null,
|
||||
notesCount: 0,
|
||||
note: "No triage payload on this row.",
|
||||
isMuted: false,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toBe(finding);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,16 @@ import {
|
||||
type UpdateFindingTriageInput,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
interface FindingTriageRowAttributes {
|
||||
muted?: boolean;
|
||||
muted_reason?: string;
|
||||
}
|
||||
|
||||
export interface FindingTriageRow {
|
||||
triage?: FindingTriageSummary;
|
||||
attributes: FindingTriageRowAttributes;
|
||||
}
|
||||
|
||||
export const shouldMarkFindingMutedForTriageUpdate = (
|
||||
input: UpdateFindingTriageInput,
|
||||
): boolean => Boolean(input.status && isMutelistShortcutStatus(input.status));
|
||||
@@ -45,3 +55,39 @@ export const applyOptimisticTriageSummaryUpdate = (
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const applyOptimisticFindingTriageRowUpdate = <
|
||||
TRow extends FindingTriageRow,
|
||||
>(
|
||||
finding: TRow,
|
||||
input: UpdateFindingTriageInput,
|
||||
): TRow => {
|
||||
if (!finding.triage || finding.triage.findingId !== input.findingId) {
|
||||
return finding;
|
||||
}
|
||||
|
||||
const shouldMarkMuted = shouldMarkFindingMutedForTriageUpdate(input);
|
||||
|
||||
return {
|
||||
...finding,
|
||||
triage: applyOptimisticTriageSummaryUpdate(finding.triage, input),
|
||||
attributes: {
|
||||
...finding.attributes,
|
||||
muted: shouldMarkMuted ? true : finding.attributes.muted,
|
||||
muted_reason:
|
||||
shouldMarkMuted && input.isMuted !== true && input.status
|
||||
? getOptimisticTriageMutedReason(input.status)
|
||||
: finding.attributes.muted_reason,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const applyOptimisticFindingTriageRowsUpdate = <
|
||||
TRow extends FindingTriageRow,
|
||||
>(
|
||||
findings: TRow[],
|
||||
input: UpdateFindingTriageInput,
|
||||
): TRow[] =>
|
||||
findings.map((finding) =>
|
||||
applyOptimisticFindingTriageRowUpdate(finding, input),
|
||||
);
|
||||
|
||||
@@ -383,21 +383,6 @@ export const checkTaskStatus = async (
|
||||
export const wait = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Helper function to create dictionaries by type
|
||||
export function createDict(type: string, data: any) {
|
||||
const includedField = data?.included?.filter(
|
||||
(item: { type: string }) => item.type === type,
|
||||
);
|
||||
|
||||
if (!includedField || includedField.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
includedField.map((item: { id: string }) => [item.id, item]),
|
||||
);
|
||||
}
|
||||
|
||||
export const parseStringify = (value: any) => JSON.parse(JSON.stringify(value));
|
||||
|
||||
export const convertFileToUrl = (file: File) => URL.createObjectURL(file);
|
||||
|
||||
@@ -28,3 +28,18 @@ export function getOptionalText(value: unknown): string | undefined {
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// Helper function to create dictionaries by type
|
||||
export function createDict(type: string, data: any) {
|
||||
const includedField = data?.included?.filter(
|
||||
(item: { type: string }) => item.type === type,
|
||||
);
|
||||
|
||||
if (!includedField || includedField.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
includedField.map((item: { id: string }) => [item.id, item]),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user