fix(ui): address findings triage QA feedback (#11791)

This commit is contained in:
Alejandro Bailo
2026-07-02 12:43:06 +02:00
committed by GitHub
parent e1b23e2526
commit cd90a91158
19 changed files with 333 additions and 69 deletions
@@ -400,7 +400,6 @@ describe("adaptFindingTriageDetailResponse", () => {
canEdit: true,
noteBody: "Current note visible only inside the modal.",
maxNoteLength: 500,
privacyCopy: "This note is only visible to your team.",
}),
);
});
@@ -1,7 +1,6 @@
import {
FINDING_TRIAGE_BILLING_HREF,
FINDING_TRIAGE_NOTE_MAX_LENGTH,
FINDING_TRIAGE_NOTE_PRIVACY_COPY,
FINDING_TRIAGE_STATUS,
FINDING_TRIAGE_STATUS_LABELS,
type FindingTriageDetail,
@@ -215,6 +214,5 @@ export function adaptFindingTriageDetailResponse(
noteId: attributes.note_id || null,
noteBody,
maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH,
privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY,
};
}
@@ -6,7 +6,7 @@ import { LinkToFindings } from "@/components/overview";
import { ColumnLatestFindings } from "@/components/overview/new-findings-table/table";
import { CardTitle } from "@/components/shadcn";
import { DataTable } from "@/components/ui/table";
import { FINDINGS_FILTERED_SORT } from "@/lib";
import { FINDINGS_FILTERED_SORT, MUTED_FILTER } from "@/lib";
import { createDict } from "@/lib/helper";
import { FindingProps, SearchParamsProps } from "@/types";
@@ -23,6 +23,7 @@ export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) {
const defaultFilters = {
"filter[status]": "FAIL",
"filter[delta]": "new",
"filter[muted]": MUTED_FILTER.EXCLUDE,
};
const filters = pickFilterParams(searchParams);
@@ -7,6 +7,13 @@ import type {
} from "react";
import { describe, expect, it, vi } from "vitest";
// CustomLink pulls the "@/lib" barrel (and next-auth with it) into the unit env.
vi.mock("@/components/ui/custom/custom-link", () => ({
CustomLink: ({ href, children }: { href: string; children: ReactNode }) => (
<a href={href}>{children}</a>
),
}));
vi.mock("@/components/shadcn", () => ({
Button: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => (
<button {...props}>{children}</button>
@@ -394,6 +401,38 @@ describe("column-finding-resources", () => {
expect(screen.getByText(EDITING_UNAVAILABLE_COPY)).toBeInTheDocument();
});
it("should keep the compact Triage label on resource cells for headerless nested rows", () => {
// Given
const columns = getColumnFindingResources({
rowSelection: {},
selectableRowCount: 1,
});
const triageColumn = columns.find(
(col) => (col as { id?: string }).id === "triage",
);
if (!triageColumn?.cell) {
throw new Error("triage column not found");
}
const CellComponent = triageColumn.cell as (props: {
row: { original: FindingResourceRow };
}) => ReactNode;
// When
render(
<div>
{CellComponent({
row: {
original: makeResource({ triage: makeTriageSummary() }),
},
})}
</div>,
);
// Then — expanded finding-group rows render without a header row, so the
// cell itself must carry the label, like Service/Region/Last seen do.
expect(screen.getByText("Triage")).toBeInTheDocument();
});
it("should disable non-paying Cloud triage control with only-in-Cloud tooltip copy", () => {
// Given
const columns = getColumnFindingResources({
@@ -354,17 +354,20 @@ export function getColumnFindingResources({
},
enableSorting: false,
},
// Triage
// Triage — keep the compact label: these cells also render inside
// expanded finding-group rows, which have no header row of their own.
{
id: "triage",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Triage" />
),
cell: ({ row }) => (
<FindingTriageStatusCell
triage={row.original.triage}
onTriageUpdateAction={onTriageUpdateAction}
/>
<InfoField label="Triage" variant="compact">
<FindingTriageStatusCell
triage={row.original.triage}
onTriageUpdateAction={onTriageUpdateAction}
/>
</InfoField>
),
enableSorting: false,
},
@@ -5,6 +5,13 @@ vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: vi.fn() }),
}));
// CustomLink pulls the "@/lib" barrel (and next-auth with it) into the unit env.
vi.mock("@/components/ui/custom/custom-link", () => ({
CustomLink: ({ href, children }: { href: string; children: ReactNode }) => (
<a href={href}>{children}</a>
),
}));
vi.mock("@/components/findings/mute-findings-modal", () => ({
MuteFindingsModal: () => null,
}));
@@ -67,7 +67,7 @@ function FindingTitleCell({
finding={finding}
defaultOpen={defaultOpen}
trigger={
<div className="max-w-[500px]">
<div className="max-w-[500px] min-w-[160px]">
<p className="text-text-neutral-primary hover:text-button-tertiary cursor-pointer text-left text-sm break-words whitespace-normal hover:underline">
{finding.attributes.check_metadata.checktitle}
</p>
@@ -9,6 +9,13 @@ vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({
),
}));
// CustomLink pulls the "@/lib" barrel (and next-auth with it) into the unit env.
vi.mock("@/components/ui/custom/custom-link", () => ({
CustomLink: ({ href, children }: { href: string; children: ReactNode }) => (
<a href={href}>{children}</a>
),
}));
vi.mock("@/components/shadcn/modal", () => ({
Modal: ({
children,
@@ -44,7 +51,6 @@ beforeAll(() => {
import {
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_NOTE_PRIVACY_COPY,
FINDING_TRIAGE_STATUS,
type FindingTriageDetail,
type UpdateFindingTriageInput,
@@ -72,7 +78,6 @@ function makeTriageDetail(
noteId: "note-1",
noteBody: "Existing investigation note",
maxNoteLength: 500,
privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY,
...overrides,
};
}
@@ -246,7 +251,45 @@ describe("FindingNoteModal", () => {
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});
it("should render counter, privacy copy, and cancel/update actions", async () => {
it("should lock the status picker for resolved findings while keeping the note editable", async () => {
// Given
const user = userEvent.setup();
const onTriageUpdateAction = vi.fn();
renderNoteModal({
triage: makeTriageDetail({
status: FINDING_TRIAGE_STATUS.RESOLVED,
label: "Resolved",
}),
onTriageUpdateAction,
});
// Then — automation owns the transition out of Resolved.
expect(
screen.getByRole("combobox", { name: "Triage status" }),
).toBeDisabled();
expect(
screen.getByText(
"Triage status is managed automatically once the finding is resolved.",
),
).toBeVisible();
expect(screen.getByLabelText("Note text")).toBeEnabled();
// When — the note itself can still be updated.
const textarea = screen.getByLabelText("Note text");
await user.clear(textarea);
await user.type(textarea, "Documenting the resolution.");
await user.click(screen.getByRole("button", { name: "Save changes" }));
// Then
expect(onTriageUpdateAction).toHaveBeenCalledWith(
expect.objectContaining({ note: "Documenting the resolution." }),
);
expect(onTriageUpdateAction).toHaveBeenCalledWith(
expect.not.objectContaining({ status: expect.anything() }),
);
});
it("should render counter and cancel/update actions without privacy copy", async () => {
// Given
const user = userEvent.setup();
const onOpenChange = vi.fn();
@@ -258,7 +301,9 @@ describe("FindingNoteModal", () => {
// Then
expect(screen.getByText("3/500")).toBeInTheDocument();
expect(screen.getByText(FINDING_TRIAGE_NOTE_PRIVACY_COPY)).toBeVisible();
expect(
screen.queryByText("This note is only visible to your team."),
).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(
@@ -305,7 +350,11 @@ describe("FindingNoteModal", () => {
await user.click(screen.getByRole("option", { name: "Risk Accepted" }));
// Then
expect(screen.getByText(/will be muted/i)).toBeVisible();
expect(
screen.getByText(
"Changing triage to Risk Accepted will mute the finding",
),
).toBeVisible();
await waitFor(() =>
expect(screen.queryByRole("listbox")).not.toBeInTheDocument(),
);
@@ -5,14 +5,24 @@ import { type FormEvent, useRef, useState } from "react";
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
import { Alert, AlertDescription, Button, Textarea } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { DOCS_URLS } from "@/lib/external-urls";
import {
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_ORIGIN,
FINDING_TRIAGE_RESOLVED_LOCKED_COPY,
FINDING_TRIAGE_STATUS,
type FindingTriageDetail,
type FindingTriageStatus,
getFindingTriageMuteInfoCopy,
isMutelistShortcutStatus,
isTriageStatusLocked,
} from "@/types/findings-triage";
import type { ProviderType } from "@/types/providers";
@@ -37,10 +47,8 @@ interface FindingNoteModalProps {
onTriageUpdateAction?: FindingTriageUpdateHandler;
}
const MUTELIST_INFO_COPY =
"This finding will be muted through the existing Mutelist flow.";
const REMEDIATING_INFO_COPY =
"Once this finding is fixed and passes in the next scan, it will be automatically changed to Resolved.";
"Once this finding is remediated, if in the following scan its status changes to Pass, it will be automatically changed to Resolved";
export function FindingNoteModal({
open,
@@ -68,6 +76,7 @@ export function FindingNoteModal({
isMutelistShortcutStatus(selectedStatus);
const shouldShowRemediatingInfo =
selectedStatus === FINDING_TRIAGE_STATUS.REMEDIATING;
const isStatusLocked = isTriageStatusLocked(triage.status);
// Opened from a dropdown item: move focus into the dialog on mount so Radix's
// aria-hidden is not applied to the still-focused dropdown that opened it.
const handleOpenAutoFocus = (event: Event) => {
@@ -118,7 +127,10 @@ export function FindingNoteModal({
title="Add Triage Note"
size="lg"
>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
{/* min-w-0: the form is a grid item of DialogContent; without it, long
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="flex items-center gap-4">
<div className="bg-bg-neutral-tertiary flex size-9 shrink-0 items-center justify-center rounded-lg">
{findingContext.providerType ? (
@@ -129,12 +141,17 @@ export function FindingNoteModal({
</span>
)}
</div>
<div>
<p className="text-text-neutral-primary text-sm font-semibold">
{findingContext.title}
</p>
<div className="min-w-0">
<Tooltip>
<TooltipTrigger asChild>
<p className="text-text-neutral-primary truncate text-sm font-semibold">
{findingContext.title}
</p>
</TooltipTrigger>
<TooltipContent>{findingContext.title}</TooltipContent>
</Tooltip>
{(findingContext.resource || findingContext.provider) && (
<p className="text-text-neutral-secondary mt-1 text-xs">
<p className="text-text-neutral-secondary mt-1 truncate text-xs">
{[findingContext.resource, findingContext.provider]
.filter(Boolean)
.join(" · ")}
@@ -143,27 +160,44 @@ export function FindingNoteModal({
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center justify-end gap-3">
<span className="text-text-neutral-primary text-sm font-semibold">
Status:
</span>
<FindingTriageStatusControl
origin={FINDING_TRIAGE_ORIGIN.MODAL}
triage={triage}
value={selectedStatus}
onValueChange={setSelectedStatus}
/>
<div className="w-1/2 min-w-44">
<FindingTriageStatusControl
origin={FINDING_TRIAGE_ORIGIN.MODAL}
triage={triage}
value={selectedStatus}
onValueChange={setSelectedStatus}
/>
</div>
</div>
{isStatusLocked && (
<Alert variant="info">
<AlertDescription>
{FINDING_TRIAGE_RESOLVED_LOCKED_COPY}
</AlertDescription>
</Alert>
)}
{shouldShowMutelistInfo && (
<Alert variant="warning">
<AlertDescription>{MUTELIST_INFO_COPY}</AlertDescription>
<AlertDescription>
{getFindingTriageMuteInfoCopy(selectedStatus)}
</AlertDescription>
</Alert>
)}
{shouldShowRemediatingInfo && (
<Alert variant="info">
<AlertDescription>{REMEDIATING_INFO_COPY}</AlertDescription>
<AlertDescription>
{REMEDIATING_INFO_COPY}.{" "}
<CustomLink href={DOCS_URLS.FINDINGS_TRIAGE} size="sm">
Learn more
</CustomLink>
</AlertDescription>
</Alert>
)}
@@ -184,10 +218,7 @@ export function FindingNoteModal({
textareaSize="lg"
onChange={(event) => setNote(event.target.value)}
/>
<div className="flex items-center justify-between gap-3">
<p className="text-text-neutral-tertiary text-xs">
{triage.privacyCopy}
</p>
<div className="flex items-center justify-end">
<p className="text-text-neutral-tertiary shrink-0 text-xs">
{note.length}/{triage.maxNoteLength}
</p>
@@ -8,14 +8,17 @@ vi.mock("@/components/shadcn/modal", () => ({
children,
open,
title,
description,
}: {
children: ReactNode;
open: boolean;
title?: string;
description?: string;
}) =>
open ? (
<div role="dialog" aria-label={title}>
<h2>{title}</h2>
{description && <p>{description}</p>}
{children}
</div>
) : null,
@@ -27,6 +30,13 @@ vi.mock("next/navigation", () => ({
}),
}));
// CustomLink pulls the "@/lib" barrel (and next-auth with it) into the unit env.
vi.mock("@/components/ui/custom/custom-link", () => ({
CustomLink: ({ href, children }: { href: string; children: ReactNode }) => (
<a href={href}>{children}</a>
),
}));
vi.mock("@/components/shadcn/dropdown", () => ({
ActionDropdownItem: ({
label,
@@ -66,6 +76,7 @@ import {
import {
FindingNoteActionItem,
FindingTriageStatusBadge,
FindingTriageStatusCell,
} from "./finding-triage-cells";
@@ -162,11 +173,6 @@ describe("finding triage cells", () => {
await user.click(statusControl);
// Then
expect(screen.getByText("Triage").parentElement).toHaveClass(
"text-text-neutral-secondary",
"text-[10px]",
"whitespace-nowrap",
);
expect(statusControl.parentElement).toHaveClass("w-32");
expect(statusControl).toHaveAttribute("data-size", "xs");
expect(within(statusControl).getByText("Under Review")).toHaveClass(
@@ -197,6 +203,22 @@ describe("finding triage cells", () => {
).toHaveClass("text-text-neutral-secondary");
});
it("renders a read-only triage status badge with the status color", () => {
// Given / When
render(
<FindingTriageStatusBadge
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.REMEDIATING,
label: "Remediating",
})}
/>,
);
// Then
expect(screen.getByText("Triage:")).toBeInTheDocument();
expect(screen.getByText("Remediating")).toHaveClass("text-bg-data-info");
});
it("should disable table status mutation when no update handler is wired", async () => {
// Given
const user = userEvent.setup();
@@ -222,6 +244,57 @@ describe("finding triage cells", () => {
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
it("should lock the table status picker for resolved findings", async () => {
// Given
const user = userEvent.setup();
const onTriageUpdateAction = vi.fn();
render(
<FindingTriageStatusCell
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.RESOLVED,
label: "Resolved",
})}
onTriageUpdateAction={onTriageUpdateAction}
/>,
);
const statusControl = screen.getByRole("combobox", {
name: "Triage status",
});
// When
await user.click(statusControl);
// Then — automation owns the transition out of Resolved.
expect(statusControl).toBeDisabled();
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
expect(
screen.getAllByText(
"Triage status is managed automatically once the finding is resolved.",
).length,
).toBeGreaterThan(0);
expect(onTriageUpdateAction).not.toHaveBeenCalled();
});
it("should keep the note action available for resolved findings", () => {
// Given
render(
<FindingNoteActionItem
triage={makeTriageSummary({
status: FINDING_TRIAGE_STATUS.RESOLVED,
label: "Resolved",
hasVisibleNote: false,
})}
onTriageUpdateAction={vi.fn()}
/>,
);
// Then — the lock only applies to status transitions, not notes.
expect(
screen.getByRole("button", { name: "Add Triage Note" }),
).toBeEnabled();
});
it("should not open an editable empty-note modal for an existing note without a loader", async () => {
// Given
const user = userEvent.setup();
@@ -647,6 +720,11 @@ describe("finding triage cells", () => {
// Then: the user is warned before the server action handles muting.
expect(screen.getByRole("dialog", { name: "Mute finding?" })).toBeVisible();
expect(
screen.getByText(
"Changing triage to False Positive will mute the finding",
),
).toBeVisible();
expect(onTriageUpdateAction).not.toHaveBeenCalled();
// When
@@ -9,16 +9,18 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { cn } from "@/lib/utils";
import {
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_NOTE_MAX_LENGTH,
FINDING_TRIAGE_NOTE_PRIVACY_COPY,
FINDING_TRIAGE_ORIGIN,
FINDING_TRIAGE_RESOLVED_LOCKED_COPY,
FINDING_TRIAGE_STATUS_LABELS,
type FindingTriageDetail,
type FindingTriageLoadedNote,
type FindingTriageStatus,
type FindingTriageSummary,
isTriageStatusLocked,
type UpdateFindingTriageInput,
} from "@/types/findings-triage";
@@ -29,6 +31,7 @@ import {
import {
FindingTriageStatusControl,
type FindingTriageUpdateHandler,
TRIAGE_STATUS_TEXT_CLASS,
} from "./finding-triage-status-control";
export const CLOUD_ONLY_TOOLTIP_COPY = "Available in Prowler Cloud";
@@ -37,14 +40,21 @@ export const EDITING_UNAVAILABLE_COPY = "Editing is currently unavailable.";
const getDisabledCopy = ({
triage,
hasUpdateHandler,
lockResolved = false,
}: {
triage: FindingTriageSummary;
hasUpdateHandler: boolean;
lockResolved?: boolean;
}): string | undefined => {
if (triage.disabledReason === FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY) {
return CLOUD_ONLY_TOOLTIP_COPY;
}
// Status-picker only: notes stay available on resolved findings.
if (lockResolved && isTriageStatusLocked(triage.status)) {
return FINDING_TRIAGE_RESOLVED_LOCKED_COPY;
}
if (triage.canEdit && !hasUpdateHandler) {
return EDITING_UNAVAILABLE_COPY;
}
@@ -60,7 +70,6 @@ const getTriageDetailFromSummary = (
noteId: loadedNote?.noteId ?? null,
noteBody: loadedNote?.noteBody ?? "",
maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH,
privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY,
});
export function FindingTriageStatusCell({
@@ -151,6 +160,7 @@ export function FindingTriageStatusCell({
const disabledCopy = getDisabledCopy({
triage,
hasUpdateHandler: Boolean(onTriageUpdateAction),
lockResolved: true,
});
if (!disabledCopy) {
return control;
@@ -159,8 +169,7 @@ export function FindingTriageStatusCell({
return (
<Tooltip>
<TooltipTrigger asChild>
{/* Block-level so the tooltip wrapper doesn't add inline baseline spacing
that would push the compact "Triage" label below the sibling columns. */}
{/* Block-level wrapper keeps the picker aligned with the sibling columns. */}
<span className="flex">{control}</span>
</TooltipTrigger>
<TooltipContent>{disabledCopy}</TooltipContent>
@@ -168,6 +177,28 @@ export function FindingTriageStatusCell({
);
}
// Read-only triage status indicator, e.g. for the side drawer header where the
// editable picker would be out of place among the status/severity badges.
export function FindingTriageStatusBadge({
triage,
}: {
triage: FindingTriageSummary;
}) {
return (
<div className="flex items-center gap-1">
<span className="text-text-neutral-tertiary text-xs">Triage:</span>
<span
className={cn(
"text-xs font-medium",
TRIAGE_STATUS_TEXT_CLASS[triage.status],
)}
>
{triage.label}
</span>
</div>
);
}
export function FindingNoteActionItem({
triage,
findingContext = { title: "Finding" },
@@ -3,7 +3,6 @@
import { type ComponentProps, useState } from "react";
import { Button } from "@/components/shadcn";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import { Modal } from "@/components/shadcn/modal";
import {
Select,
@@ -19,8 +18,10 @@ import {
type FindingTriageManualStatus,
type FindingTriageStatus,
type FindingTriageSummary,
getFindingTriageMuteInfoCopy,
isManualStatus,
isMutelistShortcutStatus,
isTriageStatusLocked,
type UpdateFindingTriageInput,
} from "@/types/findings-triage";
@@ -32,7 +33,7 @@ type TriageStatusPickerSize = NonNullable<
ComponentProps<typeof SelectTrigger>["size"]
>;
const TRIAGE_STATUS_TEXT_CLASS = {
export const TRIAGE_STATUS_TEXT_CLASS = {
open: "text-text-error-primary",
under_review: "text-text-warning-primary",
remediating: "text-bg-data-info",
@@ -43,8 +44,6 @@ const TRIAGE_STATUS_TEXT_CLASS = {
} as const satisfies Record<FindingTriageStatus, string>;
const MUTELIST_CONFIRMATION_TITLE = "Mute finding?";
const MUTELIST_CONFIRMATION_COPY =
"Changing to this triage status will mute the finding.";
function TriageStatusPicker({
disabled,
@@ -119,7 +118,7 @@ export function FindingTriageStatusControl(
if (props.origin === FINDING_TRIAGE_ORIGIN.MODAL) {
return (
<TriageStatusPicker
disabled={!triage.canEdit}
disabled={!triage.canEdit || isTriageStatusLocked(triage.status)}
value={props.value}
onValueChange={props.onValueChange}
/>
@@ -127,7 +126,10 @@ export function FindingTriageStatusControl(
}
const canMutateFromTable =
triage.canEdit && Boolean(props.onTriageUpdateAction) && !isTableUpdating;
triage.canEdit &&
Boolean(props.onTriageUpdateAction) &&
!isTableUpdating &&
!isTriageStatusLocked(triage.status);
const applyTableStatus = async (status: FindingTriageManualStatus) => {
if (!props.onTriageUpdateAction || status === triage.status) {
@@ -174,16 +176,14 @@ export function FindingTriageStatusControl(
return (
<>
<InfoField label="Triage" variant="compact">
<div className="w-32">
<TriageStatusPicker
disabled={!canMutateFromTable}
size="xs"
value={triage.status}
onValueChange={handleTableValueChange}
/>
</div>
</InfoField>
<div className="w-32">
<TriageStatusPicker
disabled={!canMutateFromTable}
size="xs"
value={triage.status}
onValueChange={handleTableValueChange}
/>
</div>
{tableUpdateError && (
<span className="sr-only" role="alert">
{tableUpdateError}
@@ -197,7 +197,11 @@ export function FindingTriageStatusControl(
}
}}
title={MUTELIST_CONFIRMATION_TITLE}
description={MUTELIST_CONFIRMATION_COPY}
description={
pendingShortcutStatus
? getFindingTriageMuteInfoCopy(pendingShortcutStatus)
: undefined
}
size="sm"
>
<div className="flex justify-end gap-2 pt-2">
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import {
FINDING_TRIAGE_NOTE_MAX_LENGTH,
FINDING_TRIAGE_NOTE_PRIVACY_COPY,
FINDING_TRIAGE_STATUS,
type FindingTriageDetail,
} from "@/types/findings-triage";
@@ -26,7 +25,6 @@ function makeTriageDetail(
noteId: "note-1",
noteBody: "Existing investigation note",
maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH,
privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY,
...overrides,
};
}
@@ -461,6 +461,12 @@ vi.mock("../finding-triage-cells", () => ({
) : (
<span>-</span>
),
FindingTriageStatusBadge: ({ triage }: { triage: { label: string } }) => (
<div>
<span>Triage:</span>
<span>{triage.label}</span>
</div>
),
}));
vi.mock("./resource-detail-skeleton", () => ({
@@ -84,6 +84,7 @@ import { Muted } from "../../muted";
import { DeltaIndicator } from "../delta-indicator";
import {
FindingNoteActionItem,
FindingTriageStatusBadge,
FindingTriageStatusCell,
} from "../finding-triage-cells";
import { DeltaValues, NotificationIndicator } from "../notification-indicator";
@@ -560,6 +561,7 @@ export function ResourceDetailDrawerContent({
{findingIsMuted !== undefined && (
<Muted isMuted={findingIsMuted} mutedReason={findingMutedReason} />
)}
{findingTriage && <FindingTriageStatusBadge triage={findingTriage} />}
</div>
{showCheckMetaContent ? (
@@ -2,10 +2,18 @@
import { ColumnDef } from "@tanstack/react-table";
import {
loadLatestFindingTriageNote,
updateFindingTriage,
} from "@/actions/findings";
import { getStandaloneFindingColumns } from "@/components/findings/table/column-standalone-findings";
import { FindingProps } from "@/types";
export const ColumnLatestFindings: ColumnDef<FindingProps>[] =
getStandaloneFindingColumns({
includeUpdatedAt: true,
onTriageUpdateAction: async (input) => {
await updateFindingTriage(input);
},
onTriageNoteLoadAction: loadLatestFindingTriageNote,
});
+1 -1
View File
@@ -85,7 +85,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
className={cn("flex flex-col gap-2 text-left", className)}
{...props}
/>
);
+2
View File
@@ -6,6 +6,8 @@ export const DOCS_URLS = {
"https://docs.prowler.com/user-guide/tutorials/prowler-app#step-8:-analyze-the-findings",
FINDINGS_INGESTION:
"https://docs.prowler.com/user-guide/tutorials/prowler-app-import-findings",
FINDINGS_TRIAGE:
"https://docs.prowler.com/user-guide/tutorials/prowler-app-findings-triage",
AWS_ORGANIZATIONS:
"https://docs.prowler.com/user-guide/tutorials/prowler-cloud-aws-organizations",
ALERTS: "https://docs.prowler.com/user-guide/tutorials/prowler-app-alerts",
+11 -3
View File
@@ -54,6 +54,17 @@ export const isMutelistShortcutStatus = (status: unknown): boolean => {
);
};
export const getFindingTriageMuteInfoCopy = (status: FindingTriageStatus) =>
`Changing triage to ${FINDING_TRIAGE_STATUS_LABELS[status]} will mute the finding`;
// Only RESOLVED locks manual edits: automation owns the transition out of it
// (REOPENED on a failing rescan), while REOPENED invites human re-triage.
export const isTriageStatusLocked = (status: FindingTriageStatus): boolean =>
status === FINDING_TRIAGE_STATUS.RESOLVED;
export const FINDING_TRIAGE_RESOLVED_LOCKED_COPY =
"Triage status is managed automatically once the finding is resolved." as const;
export const FINDING_TRIAGE_DISABLED_REASON = {
CLOUD_ONLY: "cloud_only",
FORBIDDEN: "forbidden",
@@ -69,8 +80,6 @@ export const FINDING_TRIAGE_ORIGIN = {
} as const;
export const FINDING_TRIAGE_NOTE_MAX_LENGTH = 500 as const;
export const FINDING_TRIAGE_NOTE_PRIVACY_COPY =
"This note is only visible to your team." as const;
export const FINDING_TRIAGE_BILLING_HREF =
"https://prowler.com/pricing" as const;
@@ -92,7 +101,6 @@ export interface FindingTriageDetail extends FindingTriageSummary {
noteId: string | null;
noteBody: string;
maxNoteLength: typeof FINDING_TRIAGE_NOTE_MAX_LENGTH;
privacyCopy: typeof FINDING_TRIAGE_NOTE_PRIVACY_COPY;
}
export interface UpdateFindingTriageInput {