+ {/* mt-3 lifts the gap-5 form spacing to 32px so the distance to the
+ footer matches the launch scan and alert modals. */}
+
diff --git a/ui/components/findings/table/finding-triage-cells.test.tsx b/ui/components/findings/table/finding-triage-cells.test.tsx
index 3d0a417860..74fd48764c 100644
--- a/ui/components/findings/table/finding-triage-cells.test.tsx
+++ b/ui/components/findings/table/finding-triage-cells.test.tsx
@@ -373,7 +373,7 @@ describe("finding triage cells", () => {
screen.getByRole("dialog", { name: "Add Triage Note" }),
).toBeVisible();
expect(screen.getByLabelText("Note text")).toBeDisabled();
- expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
expect(
screen.getByRole("link", { name: "Available in Prowler Cloud" }),
).toHaveAttribute("href", "https://prowler.com/pricing");
diff --git a/ui/components/resources/table/use-resource-drawer-bootstrap.ts b/ui/components/resources/table/use-resource-drawer-bootstrap.ts
index 0fd33dba56..675bf337b0 100644
--- a/ui/components/resources/table/use-resource-drawer-bootstrap.ts
+++ b/ui/components/resources/table/use-resource-drawer-bootstrap.ts
@@ -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),
);
};
diff --git a/ui/lib/finding-detail.ts b/ui/lib/finding-detail.ts
index 5566b9f994..73bf2e33aa 100644
--- a/ui/lib/finding-detail.ts
+++ b/ui/lib/finding-detail.ts
@@ -23,12 +23,12 @@ export function expandFindingWithRelationships(
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];
+ const provider = providerDict[scan?.relationships?.provider?.data?.id ?? ""];
return {
...finding,
relationships: { ...finding.relationships, scan, resource, provider },
- } as FindingProps;
+ } as unknown as FindingProps;
}
export function findingToFindingResourceRow(
diff --git a/ui/lib/finding-triage.test.ts b/ui/lib/finding-triage.test.ts
new file mode 100644
index 0000000000..a08eb29804
--- /dev/null
+++ b/ui/lib/finding-triage.test.ts
@@ -0,0 +1,222 @@
+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 {
+ 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 {
+ 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 preserve muted attributes when leaving a mutelist-shortcut status", () => {
+ // Given: a finding muted by a previous shortcut transition. The server
+ // never removes the mute rule when the status moves on, so the optimistic
+ // update must not unmute the row either.
+ const finding = makeFindingRow({
+ triage: makeTriageSummary({
+ status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
+ label: "Risk Accepted",
+ isMuted: true,
+ }),
+ attributes: {
+ muted: true,
+ muted_reason: "Finding triage status changed to Risk Accepted.",
+ status: "FAIL",
+ },
+ });
+
+ // When
+ const result = applyOptimisticFindingTriageRowUpdate(finding, {
+ findingId: "finding-1",
+ findingUid: "uid-1",
+ triageId: "triage-1",
+ notesCount: 0,
+ status: FINDING_TRIAGE_STATUS.REMEDIATING,
+ previousStatus: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
+ isMuted: true,
+ });
+
+ // Then
+ expect(result.triage).toEqual(
+ expect.objectContaining({
+ status: FINDING_TRIAGE_STATUS.REMEDIATING,
+ label: "Remediating",
+ isMuted: true,
+ }),
+ );
+ expect(result.attributes).toEqual(
+ expect.objectContaining({
+ muted: true,
+ muted_reason: "Finding triage status changed to Risk Accepted.",
+ }),
+ );
+ });
+
+ it("should not overwrite muted_reason when an already muted finding enters a shortcut status", () => {
+ // Given: muted through some other channel (e.g. a mutelist rule).
+ const finding = makeFindingRow({
+ triage: makeTriageSummary({ isMuted: true }),
+ attributes: {
+ muted: true,
+ muted_reason: "Muted by mutelist rule.",
+ status: "FAIL",
+ },
+ });
+
+ // When: no new mute rule is created for already muted findings, so the
+ // optimistic reason must keep the original one.
+ 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: true,
+ });
+
+ // Then
+ expect(result.attributes).toEqual(
+ expect.objectContaining({
+ muted: true,
+ muted_reason: "Muted by mutelist rule.",
+ }),
+ );
+ });
+
+ 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);
+ });
+});
diff --git a/ui/lib/finding-triage.ts b/ui/lib/finding-triage.ts
index 4c4d36920b..a7fefef66c 100644
--- a/ui/lib/finding-triage.ts
+++ b/ui/lib/finding-triage.ts
@@ -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),
+ );
diff --git a/ui/lib/helper.ts b/ui/lib/helper.ts
index 9674ea0d73..dd61b238c3 100644
--- a/ui/lib/helper.ts
+++ b/ui/lib/helper.ts
@@ -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);
diff --git a/ui/lib/utils.ts b/ui/lib/utils.ts
index c649ce1787..7a37948ac7 100644
--- a/ui/lib/utils.ts
+++ b/ui/lib/utils.ts
@@ -28,3 +28,39 @@ export function getOptionalText(value: unknown): string | undefined {
? value
: undefined;
}
+
+interface IncludedApiItemRelationshipRef {
+ id: string;
+}
+
+interface IncludedApiItemRelationship {
+ data?: IncludedApiItemRelationshipRef;
+}
+
+interface IncludedApiItem {
+ id: string;
+ type: string;
+ attributes?: Record;
+ relationships?: Record;
+}
+
+// Indexes a JSON:API `included` array by id for the requested resource type.
+export function createDict(
+ type: string,
+ data: { included?: unknown[] } | null | undefined,
+): Record {
+ const includedField = data?.included?.filter(
+ (item): item is T =>
+ typeof item === "object" &&
+ item !== null &&
+ (item as IncludedApiItem).type === type,
+ );
+
+ if (!includedField || includedField.length === 0) {
+ return {};
+ }
+
+ return Object.fromEntries(
+ includedField.map((item): [string, T] => [item.id, item]),
+ );
+}