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:
alejandrobailo
2026-07-03 11:27:49 +02:00
parent cf18093261
commit e3dafa73ef
12 changed files with 661 additions and 125 deletions
@@ -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),
);
};
+146
View File
@@ -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);
});
});
+46
View File
@@ -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),
);
-15
View File
@@ -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);
+15
View 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]),
);
}