mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
fix(ui): address findings triage QA feedback (#11791)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+6
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user