mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-31 15:54:24 +00:00
Merge branch 'master' into cross-provider-compliance-view-ui-side
This commit is contained in:
348 files changed
+24013
-2851
No files matched your search
@@ -60,11 +60,7 @@ export const ScanSelector = ({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{scans.map((scan) => (
|
||||
<SelectItem
|
||||
key={scan.id}
|
||||
value={scan.id}
|
||||
className="data-[state=checked]:bg-bg-neutral-tertiary [&_svg:not([class*='size-'])]:size-6"
|
||||
>
|
||||
<SelectItem key={scan.id} value={scan.id}>
|
||||
<ComplianceScanInfo scan={scan} />
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
@@ -19,6 +19,9 @@ vi.mock("next/navigation", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Button: ({ children, ...props }: { children: ReactNode }) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
Checkbox: ({
|
||||
"aria-label": ariaLabel,
|
||||
onCheckedChange,
|
||||
@@ -35,6 +38,9 @@ vi.mock("@/components/shadcn", () => ({
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
Textarea: (props: InputHTMLAttributes<HTMLTextAreaElement>) => (
|
||||
<textarea {...props} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
@@ -78,6 +84,44 @@ vi.mock("./notification-indicator", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/modal", () => ({
|
||||
Modal: ({
|
||||
children,
|
||||
open,
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
title?: string;
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/select/select", () => ({
|
||||
Select: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectTrigger: ({
|
||||
children,
|
||||
disabled,
|
||||
"aria-label": ariaLabel,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
"aria-label"?: string;
|
||||
}) => (
|
||||
<button aria-label={ariaLabel} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
@@ -248,7 +292,7 @@ function renderSelectCell(overrides?: Partial<FindingGroupRow>) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("column-finding-groups — accessibility of check title cell", () => {
|
||||
it("should not expose an impacted providers column", () => {
|
||||
it("should not expose triage and notes columns on group-level rows", () => {
|
||||
// Given
|
||||
const columns = getColumnFindingGroups({
|
||||
rowSelection: {},
|
||||
@@ -257,12 +301,16 @@ describe("column-finding-groups — accessibility of check title cell", () => {
|
||||
});
|
||||
|
||||
// When
|
||||
const impactedProvidersColumn = columns.find(
|
||||
(col) => (col as { id?: string }).id === "impactedProviders",
|
||||
const columnIds = columns.map(
|
||||
(column) =>
|
||||
(column as { id?: string; accessorKey?: string }).id ??
|
||||
(column as { id?: string; accessorKey?: string }).accessorKey,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(impactedProvidersColumn).toBeUndefined();
|
||||
expect(columnIds).not.toContain("triage");
|
||||
expect(columnIds).not.toContain("notes");
|
||||
expect(columnIds.at(-1)).toBe("actions");
|
||||
});
|
||||
|
||||
it("should render the first provider icon with its provider name", () => {
|
||||
@@ -290,22 +338,6 @@ describe("column-finding-groups — accessibility of check title cell", () => {
|
||||
expect(button.tagName.toLowerCase()).toBe("button");
|
||||
});
|
||||
|
||||
it("should NOT render the check title as a <p> element", () => {
|
||||
// Given
|
||||
const onDrillDown =
|
||||
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
|
||||
|
||||
// When
|
||||
renderFindingCell("S3 Bucket Public Access", onDrillDown);
|
||||
|
||||
// Then — <p> should not exist as the interactive element
|
||||
const paragraphs = document.querySelectorAll("p");
|
||||
const clickableParagraph = Array.from(paragraphs).find(
|
||||
(p) => p.textContent === "S3 Bucket Public Access",
|
||||
);
|
||||
expect(clickableParagraph).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should call onDrillDown when the button is clicked", async () => {
|
||||
// Given
|
||||
const onDrillDown =
|
||||
@@ -328,23 +360,6 @@ describe("column-finding-groups — accessibility of check title cell", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should call onDrillDown when Enter key is pressed on the button", async () => {
|
||||
// Given
|
||||
const onDrillDown =
|
||||
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderFindingCell("My Check Title", onDrillDown);
|
||||
|
||||
// When — tab to button and press Enter
|
||||
const button = screen.getByRole("button", { name: "My Check Title" });
|
||||
button.focus();
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
// Then — native button handles Enter natively
|
||||
expect(onDrillDown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should allow expanding a group that only has PASS resources", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
@@ -396,26 +411,6 @@ describe("column-finding-groups — accessibility of check title cell", () => {
|
||||
expect(screen.getByText("Fallback IaC Check")).toBeInTheDocument();
|
||||
expect(onDrillDown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should keep fallback groups non-clickable when the displayed total is zero", () => {
|
||||
// Given
|
||||
const onDrillDown =
|
||||
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
|
||||
|
||||
// When
|
||||
renderFindingCell("No failing findings", onDrillDown, {
|
||||
resourcesTotal: 0,
|
||||
resourcesFail: 0,
|
||||
failCount: 0,
|
||||
passCount: 0,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "No failing findings" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("No failing findings")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("column-finding-groups — impacted resources count", () => {
|
||||
@@ -490,23 +485,6 @@ describe("column-finding-groups — group selection", () => {
|
||||
).not.toBeInTheDocument();
|
||||
expect(onDrillDown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should hide the chevron for zero-resource groups when the displayed total is zero", () => {
|
||||
// Given/When
|
||||
renderSelectCell({
|
||||
resourcesTotal: 0,
|
||||
resourcesFail: 0,
|
||||
failCount: 0,
|
||||
passCount: 0,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: "Expand S3 Bucket Public Access",
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("column-finding-groups — indicators", () => {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { InputHTMLAttributes, ReactNode } from "react";
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
InputHTMLAttributes,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Button: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
Checkbox: ({
|
||||
"aria-label": ariaLabel,
|
||||
onCheckedChange,
|
||||
@@ -66,15 +73,65 @@ vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/info-field/info-field", () => ({
|
||||
InfoField: () => null,
|
||||
InfoField: ({
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
variant?: string;
|
||||
}) => (
|
||||
<div>
|
||||
<span>{label}</span>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/spinner/spinner", () => ({
|
||||
Spinner: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/select/select", () => ({
|
||||
Select: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectTrigger: ({
|
||||
children,
|
||||
disabled,
|
||||
"aria-label": ariaLabel,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
"aria-label"?: string;
|
||||
}) => (
|
||||
<button aria-label={ariaLabel} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
SelectValue: ({ children }: { children?: ReactNode }) => (
|
||||
<span>{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => (
|
||||
<span>{children}</span>
|
||||
),
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities", () => ({
|
||||
DateWithTime: () => null,
|
||||
DateWithTime: ({
|
||||
dateTime,
|
||||
inline,
|
||||
}: {
|
||||
dateTime: string | null;
|
||||
inline?: boolean;
|
||||
}) => <time data-inline={inline ? "true" : "false"}>{dateTime ?? "-"}</time>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities/entity-info", () => ({
|
||||
@@ -120,8 +177,35 @@ vi.mock("./notification-indicator", () => ({
|
||||
}));
|
||||
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import { getColumnFindingResources } from "./column-finding-resources";
|
||||
import {
|
||||
CLOUD_ONLY_TOOLTIP_COPY,
|
||||
EDITING_UNAVAILABLE_COPY,
|
||||
} from "./finding-triage-cells";
|
||||
|
||||
function makeTriageSummary(
|
||||
overrides?: Partial<FindingTriageSummary>,
|
||||
): FindingTriageSummary {
|
||||
return {
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-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 makeResource(
|
||||
overrides?: Partial<FindingResourceRow>,
|
||||
@@ -150,78 +234,203 @@ function makeResource(
|
||||
};
|
||||
}
|
||||
|
||||
describe("column-finding-resources", () => {
|
||||
it("should pass delta to NotificationIndicator for resource rows", () => {
|
||||
const columns = getColumnFindingResources({
|
||||
rowSelection: {},
|
||||
selectableRowCount: 1,
|
||||
});
|
||||
function getColumnIds(columns: ReturnType<typeof getColumnFindingResources>) {
|
||||
return columns.map(
|
||||
(column) =>
|
||||
(column as { id?: string; accessorKey?: string }).id ??
|
||||
(column as { id?: string; accessorKey?: string }).accessorKey,
|
||||
);
|
||||
}
|
||||
|
||||
const selectColumn = columns.find(
|
||||
(col) => (col as { id?: string }).id === "select",
|
||||
);
|
||||
if (!selectColumn?.cell) {
|
||||
throw new Error("select column not found");
|
||||
}
|
||||
|
||||
const CellComponent = selectColumn.cell as (props: {
|
||||
row: {
|
||||
id: string;
|
||||
original: FindingResourceRow;
|
||||
toggleSelected: (selected: boolean) => void;
|
||||
};
|
||||
}) => ReactNode;
|
||||
|
||||
render(
|
||||
<div>
|
||||
{CellComponent({
|
||||
row: {
|
||||
id: "0",
|
||||
original: makeResource(),
|
||||
toggleSelected: vi.fn(),
|
||||
},
|
||||
})}
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Select resource")).toBeInTheDocument();
|
||||
expect(notificationIndicatorMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
delta: "new",
|
||||
isMuted: false,
|
||||
}),
|
||||
);
|
||||
function renderResourceActionsCell({
|
||||
resource = makeResource(),
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
}: {
|
||||
resource?: FindingResourceRow;
|
||||
onTriageUpdateAction?: Parameters<
|
||||
typeof getColumnFindingResources
|
||||
>[0]["onTriageUpdateAction"];
|
||||
onTriageNoteLoadAction?: Parameters<
|
||||
typeof getColumnFindingResources
|
||||
>[0]["onTriageNoteLoadAction"];
|
||||
} = {}) {
|
||||
const columns = getColumnFindingResources({
|
||||
rowSelection: {},
|
||||
selectableRowCount: 1,
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
});
|
||||
|
||||
it("should render the resource EntityInfo with resourceName as alias", () => {
|
||||
const actionsColumn = columns.find(
|
||||
(col) => (col as { id?: string }).id === "actions",
|
||||
);
|
||||
if (!actionsColumn?.cell) {
|
||||
throw new Error("actions column not found");
|
||||
}
|
||||
const CellComponent = actionsColumn.cell as (props: {
|
||||
row: { original: FindingResourceRow };
|
||||
}) => ReactNode;
|
||||
|
||||
render(<div>{CellComponent({ row: { original: resource } })}</div>);
|
||||
}
|
||||
|
||||
describe("column-finding-resources", () => {
|
||||
it("should render actions as the last visible column after Triage without Notes", () => {
|
||||
// Given
|
||||
const columns = getColumnFindingResources({
|
||||
rowSelection: {},
|
||||
selectableRowCount: 1,
|
||||
});
|
||||
|
||||
const resourceColumn = columns.find(
|
||||
(col) => (col as { id?: string }).id === "resource",
|
||||
);
|
||||
if (!resourceColumn?.cell) {
|
||||
throw new Error("resource column not found");
|
||||
}
|
||||
// When
|
||||
const columnIds = getColumnIds(columns);
|
||||
|
||||
const CellComponent = resourceColumn.cell as (props: {
|
||||
// Then
|
||||
expect(columnIds.slice(-2)).toEqual(["triage", "actions"]);
|
||||
expect(columnIds).not.toContain("notes");
|
||||
expect(
|
||||
(columns.at(-1) as { id?: string; size?: number } | undefined)?.size,
|
||||
).toBe(56);
|
||||
});
|
||||
|
||||
it("should render Open note in resource actions without exposing note preview metadata", () => {
|
||||
// Given
|
||||
renderResourceActionsCell({
|
||||
resource: makeResource({
|
||||
triage: makeTriageSummary({ hasVisibleNote: true }),
|
||||
}),
|
||||
onTriageUpdateAction: vi.fn(),
|
||||
onTriageNoteLoadAction: vi.fn(),
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("button", { name: "Open note" })).toBeEnabled();
|
||||
expect(screen.queryByText("Sensitive note body")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/author/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/timestamp/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable Add Triage Note when no update handler is wired", () => {
|
||||
// Given
|
||||
renderResourceActionsCell({
|
||||
resource: makeResource({
|
||||
triage: makeTriageSummary({ hasVisibleNote: false }),
|
||||
}),
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add Triage Note" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable Add Triage Note when an update handler is wired", () => {
|
||||
// Given
|
||||
renderResourceActionsCell({
|
||||
resource: makeResource({
|
||||
triage: makeTriageSummary({ hasVisibleNote: false }),
|
||||
}),
|
||||
onTriageUpdateAction: vi.fn(),
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add Triage Note" }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
it("should enable Add Triage Note for Cloud-only rows so users can open the billing upsell modal", () => {
|
||||
// Given
|
||||
renderResourceActionsCell({
|
||||
resource: makeResource({
|
||||
triage: makeTriageSummary({
|
||||
canEdit: false,
|
||||
hasVisibleNote: false,
|
||||
disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add Triage Note" }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
it("should disable editable triage control when no update handler is wired", () => {
|
||||
// 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(),
|
||||
original: makeResource({
|
||||
triage: makeTriageSummary({ canEdit: true }),
|
||||
}),
|
||||
},
|
||||
})}
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("my-bucket")).toBeInTheDocument();
|
||||
expect(screen.getByText("arn:aws:s3:::my-bucket")).toBeInTheDocument();
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Triage status" }),
|
||||
).toBeDisabled();
|
||||
expect(screen.getByText(EDITING_UNAVAILABLE_COPY)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable non-paying Cloud triage control with only-in-Cloud tooltip copy", () => {
|
||||
// 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({
|
||||
canEdit: false,
|
||||
disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
})}
|
||||
</div>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Triage status" }),
|
||||
).toBeDisabled();
|
||||
expect(screen.getByText(CLOUD_ONLY_TOOLTIP_COPY)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open Send to Jira modal with finding UUID directly", async () => {
|
||||
|
||||
@@ -24,15 +24,36 @@ import {
|
||||
} from "@/components/ui/table/status-finding-badge";
|
||||
import { getFailingForLabel } from "@/lib/date-utils";
|
||||
import { FindingResourceRow } from "@/types";
|
||||
import type {
|
||||
FindingTriageLoadedNote,
|
||||
FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import { canMuteFindingResource } from "./finding-resource-selection";
|
||||
import {
|
||||
FindingNoteActionItem,
|
||||
FindingTriageStatusCell,
|
||||
} from "./finding-triage-cells";
|
||||
import type { FindingTriageUpdateHandler } from "./finding-triage-status-control";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
import {
|
||||
type DeltaType,
|
||||
NotificationIndicator,
|
||||
} from "./notification-indicator";
|
||||
|
||||
const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
|
||||
const ResourceRowActions = ({
|
||||
row,
|
||||
findingTitle,
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
}: {
|
||||
row: Row<FindingResourceRow>;
|
||||
findingTitle?: string;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
onTriageNoteLoadAction?: (
|
||||
triage: FindingTriageSummary,
|
||||
) => Promise<FindingTriageLoadedNote>;
|
||||
}) => {
|
||||
const resource = row.original;
|
||||
const canMute = canMuteFindingResource(resource);
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
@@ -113,6 +134,17 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ActionDropdown ariaLabel="Resource actions">
|
||||
<FindingNoteActionItem
|
||||
triage={resource.triage}
|
||||
findingContext={{
|
||||
title: findingTitle || resource.checkId,
|
||||
resource: resource.resourceName,
|
||||
provider: resource.providerAlias,
|
||||
providerType: resource.providerType,
|
||||
}}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
resource.isMuted ? (
|
||||
@@ -141,11 +173,19 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
|
||||
interface GetColumnFindingResourcesOptions {
|
||||
rowSelection: RowSelectionState;
|
||||
selectableRowCount: number;
|
||||
findingTitle?: string;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
onTriageNoteLoadAction?: (
|
||||
triage: FindingTriageSummary,
|
||||
) => Promise<FindingTriageLoadedNote>;
|
||||
}
|
||||
|
||||
export function getColumnFindingResources({
|
||||
rowSelection,
|
||||
selectableRowCount,
|
||||
findingTitle,
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
}: GetColumnFindingResourcesOptions): ColumnDef<FindingResourceRow>[] {
|
||||
const selectedCount = Object.values(rowSelection).filter(Boolean).length;
|
||||
const isAllSelected =
|
||||
@@ -278,7 +318,9 @@ export function getColumnFindingResources({
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<InfoField label="Region" variant="compact">
|
||||
{row.original.region || "-"}
|
||||
<span className="block truncate whitespace-nowrap">
|
||||
{row.original.region || "-"}
|
||||
</span>
|
||||
</InfoField>
|
||||
),
|
||||
enableSorting: false,
|
||||
@@ -291,7 +333,7 @@ export function getColumnFindingResources({
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<InfoField label="Last seen" variant="compact">
|
||||
<DateWithTime dateTime={row.original.lastSeenAt} inline />
|
||||
<DateWithTime dateTime={row.original.lastSeenAt} />
|
||||
</InfoField>
|
||||
),
|
||||
enableSorting: false,
|
||||
@@ -312,11 +354,33 @@ export function getColumnFindingResources({
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
// Actions column — mute only
|
||||
// Triage
|
||||
{
|
||||
id: "triage",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Triage" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<FindingTriageStatusCell
|
||||
triage={row.original.triage}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
// Actions column — utility actions are kept last.
|
||||
{
|
||||
id: "actions",
|
||||
size: 56,
|
||||
header: () => <div className="w-10" />,
|
||||
cell: ({ row }) => <ResourceRowActions row={row} />,
|
||||
cell: ({ row }) => (
|
||||
<ResourceRowActions
|
||||
row={row}
|
||||
findingTitle={findingTitle}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ refresh: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/mute-findings-modal", () => ({
|
||||
MuteFindingsModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/send-to-jira-modal", () => ({
|
||||
SendToJiraModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons/services/IconServices", () => ({
|
||||
JiraIcon: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
ActionDropdown: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
ActionDropdownItem: ({
|
||||
label,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
onSelect?: () => void;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<button disabled={disabled} onClick={onSelect}>
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/spinner/spinner", () => ({
|
||||
Spinner: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities", () => ({
|
||||
DateWithTime: ({ dateTime }: { dateTime: string | null }) => (
|
||||
<time>{dateTime ?? "-"}</time>
|
||||
),
|
||||
EntityInfo: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
|
||||
SeverityBadge: ({ severity }: { severity: string }) => (
|
||||
<span>{severity}</span>
|
||||
),
|
||||
StatusFindingBadge: ({ status }: { status: string }) => <span>{status}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/select/select", () => ({
|
||||
Select: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectTrigger: ({
|
||||
children,
|
||||
disabled,
|
||||
"aria-label": ariaLabel,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
"aria-label"?: string;
|
||||
}) => (
|
||||
<button aria-label={ariaLabel} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => (
|
||||
<span>{children}</span>
|
||||
),
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/region-flags", () => ({
|
||||
getRegionFlag: () => "",
|
||||
}));
|
||||
|
||||
vi.mock("./finding-detail-drawer", () => ({
|
||||
FindingDetailDrawer: ({ trigger }: { trigger: ReactNode }) => <>{trigger}</>,
|
||||
}));
|
||||
|
||||
vi.mock("./notification-indicator", () => ({
|
||||
DeltaValues: { NEW: "new", CHANGED: "changed", NONE: "none" },
|
||||
NotificationIndicator: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./provider-icon-cell", () => ({
|
||||
ProviderIconCell: () => null,
|
||||
}));
|
||||
|
||||
import { getStandaloneFindingColumns } from "./column-standalone-findings";
|
||||
|
||||
describe("column-standalone-findings", () => {
|
||||
it("should render Triage and Actions as the last visible data columns without Notes", () => {
|
||||
// Given
|
||||
const columns = getStandaloneFindingColumns({ includeUpdatedAt: true });
|
||||
|
||||
// When
|
||||
const columnIds = columns.map(
|
||||
(column) =>
|
||||
(column as { id?: string; accessorKey?: string }).id ??
|
||||
(column as { id?: string; accessorKey?: string }).accessorKey,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(columnIds.slice(-2)).toEqual(["triage", "actions"]);
|
||||
expect(columnIds).not.toContain("notes");
|
||||
expect(
|
||||
(columns.at(-1) as { id?: string; size?: number } | undefined)?.size,
|
||||
).toBe(56);
|
||||
});
|
||||
});
|
||||
@@ -10,15 +10,27 @@ import {
|
||||
StatusFindingBadge,
|
||||
} from "@/components/ui/table";
|
||||
import { getRegionFlag } from "@/lib/region-flags";
|
||||
import { getOptionalText } from "@/lib/utils";
|
||||
import { FindingProps, ProviderType } from "@/types";
|
||||
import type {
|
||||
FindingTriageLoadedNote,
|
||||
FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import { DataTableRowActions } from "./data-table-row-actions";
|
||||
import { FindingDetailDrawer } from "./finding-detail-drawer";
|
||||
import { FindingTriageStatusCell } from "./finding-triage-cells";
|
||||
import type { FindingTriageUpdateHandler } from "./finding-triage-status-control";
|
||||
import { DeltaValues, NotificationIndicator } from "./notification-indicator";
|
||||
import { ProviderIconCell } from "./provider-icon-cell";
|
||||
|
||||
interface GetStandaloneFindingColumnsOptions {
|
||||
includeUpdatedAt?: boolean;
|
||||
openFindingId?: string | null;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
onTriageNoteLoadAction?: (
|
||||
triage: FindingTriageSummary,
|
||||
) => Promise<FindingTriageLoadedNote>;
|
||||
}
|
||||
|
||||
const getFindingsData = (row: { original: FindingProps }) => {
|
||||
@@ -68,6 +80,8 @@ function FindingTitleCell({
|
||||
export function getStandaloneFindingColumns({
|
||||
includeUpdatedAt = false,
|
||||
openFindingId = null,
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
}: GetStandaloneFindingColumnsOptions = {}): ColumnDef<FindingProps>[] {
|
||||
const columns: ColumnDef<FindingProps>[] = [
|
||||
{
|
||||
@@ -129,14 +143,8 @@ export function getStandaloneFindingColumns({
|
||||
cell: ({ row }) => {
|
||||
const name = getResourceData(row, "name");
|
||||
const uid = getResourceData(row, "uid");
|
||||
const entityAlias =
|
||||
typeof name === "string" && name.trim().length > 0 && name !== "-"
|
||||
? name
|
||||
: undefined;
|
||||
const entityId =
|
||||
typeof uid === "string" && uid.trim().length > 0 && uid !== "-"
|
||||
? uid
|
||||
: undefined;
|
||||
const entityAlias = getOptionalText(name);
|
||||
const entityId = getOptionalText(uid);
|
||||
|
||||
return (
|
||||
<div className="max-w-[240px]">
|
||||
@@ -209,7 +217,7 @@ export function getStandaloneFindingColumns({
|
||||
const regionFlag =
|
||||
typeof region === "string" ? getRegionFlag(region) : "";
|
||||
return (
|
||||
<span className="text-text-neutral-primary flex max-w-[140px] items-center gap-1.5 truncate text-sm">
|
||||
<span className="text-text-neutral-primary flex max-w-[140px] min-w-0 items-center gap-1.5 truncate text-sm whitespace-nowrap">
|
||||
{regionFlag && (
|
||||
<span className="translate-y-px text-base leading-none">
|
||||
{regionFlag}
|
||||
@@ -242,5 +250,48 @@ export function getStandaloneFindingColumns({
|
||||
});
|
||||
}
|
||||
|
||||
columns.push(
|
||||
{
|
||||
id: "triage",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Triage" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<FindingTriageStatusCell
|
||||
triage={row.original.triage}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 56,
|
||||
header: () => <div className="w-10" />,
|
||||
cell: ({ row }) => {
|
||||
const resourceName = getResourceData(row, "name");
|
||||
const providerAlias = getProviderData(row, "alias");
|
||||
const providerType = getProviderData(row, "provider");
|
||||
|
||||
return (
|
||||
<DataTableRowActions
|
||||
row={row}
|
||||
findingContext={{
|
||||
title: row.original.attributes.check_metadata.checktitle,
|
||||
resource: getOptionalText(resourceName),
|
||||
provider: getOptionalText(providerAlias),
|
||||
providerType: getOptionalText(providerType) as
|
||||
| ProviderType
|
||||
| undefined,
|
||||
}}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
/>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
);
|
||||
|
||||
return columns;
|
||||
}
|
||||
@@ -32,7 +32,7 @@ vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
onSelect: () => void;
|
||||
onSelect?: () => void;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<button onClick={onSelect} disabled={disabled}>
|
||||
@@ -45,7 +45,45 @@ vi.mock("@/components/shadcn/spinner/spinner", () => ({
|
||||
Spinner: () => <span>Loading</span>,
|
||||
}));
|
||||
|
||||
import { DataTableRowActions } from "./data-table-row-actions";
|
||||
vi.mock("./finding-note-modal", () => ({
|
||||
FindingNoteModal: ({
|
||||
open,
|
||||
triage,
|
||||
}: {
|
||||
open: boolean;
|
||||
triage: {
|
||||
noteBody: string;
|
||||
canEdit: boolean;
|
||||
disabledReason?: string;
|
||||
billingHref: string;
|
||||
};
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label="Note">
|
||||
<textarea
|
||||
aria-label="Note text"
|
||||
value={triage.noteBody}
|
||||
disabled={!triage.canEdit}
|
||||
readOnly
|
||||
/>
|
||||
{triage.disabledReason === "cloud_only" && (
|
||||
<a href={triage.billingHref}>Available in Prowler Cloud</a>
|
||||
)}
|
||||
<button disabled={!triage.canEdit}>Save changes</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import {
|
||||
DataTableRowActions,
|
||||
type FindingRowData,
|
||||
} from "./data-table-row-actions";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
|
||||
function deferredPromise<T>() {
|
||||
@@ -59,6 +97,40 @@ function deferredPromise<T>() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function makeTriageSummary(
|
||||
overrides?: Partial<FindingTriageSummary>,
|
||||
): FindingTriageSummary {
|
||||
return {
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-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<FindingRowData>) {
|
||||
return {
|
||||
original: {
|
||||
id: "finding-1",
|
||||
attributes: {
|
||||
muted: false,
|
||||
check_metadata: {
|
||||
checktitle: "S3 public access",
|
||||
},
|
||||
},
|
||||
triage: makeTriageSummary(),
|
||||
...overrides,
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe("DataTableRowActions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -179,4 +251,76 @@ describe("DataTableRowActions", () => {
|
||||
screen.getByRole("button", { name: "Mute Finding Group" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows Add Triage Note for editable findings without a note", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={makeFindingRow()}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add Triage Note" }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
it("loads an existing note before opening the note modal", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageNoteLoadAction = vi.fn().mockResolvedValue({
|
||||
noteId: "note-1",
|
||||
noteBody: "Loaded existing note",
|
||||
});
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={makeFindingRow({
|
||||
triage: makeTriageSummary({ hasVisibleNote: true, notesCount: 1 }),
|
||||
})}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Open note" }));
|
||||
|
||||
// Then
|
||||
expect(onTriageNoteLoadAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ triageId: "triage-1", notesCount: 1 }),
|
||||
);
|
||||
expect(await screen.findByRole("dialog", { name: "Note" })).toBeVisible();
|
||||
expect(screen.getByLabelText("Note text")).toHaveValue(
|
||||
"Loaded existing note",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens a disabled Cloud-only note modal from finding actions", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={makeFindingRow({
|
||||
triage: makeTriageSummary({
|
||||
canEdit: false,
|
||||
hasVisibleNote: false,
|
||||
disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY,
|
||||
}),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Add Triage Note" }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("dialog", { name: "Note" })).toBeVisible();
|
||||
expect(screen.getByLabelText("Note text")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Available in Prowler Cloud" }),
|
||||
).toHaveAttribute("href", "https://prowler.com/pricing");
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,17 @@ import {
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { isFindingGroupMuted } from "@/lib/findings-groups";
|
||||
import { getOptionalText } from "@/lib/utils";
|
||||
import type {
|
||||
FindingTriageLoadedNote,
|
||||
FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
import type { ProviderType } from "@/types/providers";
|
||||
|
||||
import { canMuteFindingGroup } from "./finding-group-selection";
|
||||
import type { FindingTriageContext } from "./finding-note-modal";
|
||||
import { FindingNoteActionItem } from "./finding-triage-cells";
|
||||
import type { FindingTriageUpdateHandler } from "./finding-triage-status-control";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
|
||||
export interface FindingRowData {
|
||||
@@ -26,6 +35,20 @@ export interface FindingRowData {
|
||||
checktitle?: string;
|
||||
};
|
||||
};
|
||||
triage?: FindingTriageSummary;
|
||||
relationships?: {
|
||||
resource?: {
|
||||
attributes?: {
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
provider?: {
|
||||
attributes?: {
|
||||
alias?: string;
|
||||
provider?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
// Flat shape for FindingGroupRow
|
||||
rowType?: string;
|
||||
checkId?: string;
|
||||
@@ -70,11 +93,19 @@ function extractRowInfo(data: FindingRowData) {
|
||||
interface DataTableRowActionsProps<T extends FindingRowData> {
|
||||
row: Row<T>;
|
||||
onMuteComplete?: (findingIds: string[]) => void;
|
||||
findingContext?: FindingTriageContext;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
onTriageNoteLoadAction?: (
|
||||
triage: FindingTriageSummary,
|
||||
) => Promise<FindingTriageLoadedNote>;
|
||||
}
|
||||
|
||||
export function DataTableRowActions<T extends FindingRowData>({
|
||||
row,
|
||||
onMuteComplete,
|
||||
findingContext,
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
}: DataTableRowActionsProps<T>) {
|
||||
const router = useRouter();
|
||||
const finding = row.original;
|
||||
@@ -86,6 +117,18 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
>(null);
|
||||
|
||||
const { isMuted, canMute, title: findingTitle } = extractRowInfo(finding);
|
||||
const resolvedFindingContext = findingContext ?? {
|
||||
title: findingTitle,
|
||||
resource: getOptionalText(
|
||||
finding.relationships?.resource?.attributes?.name,
|
||||
),
|
||||
provider: getOptionalText(
|
||||
finding.relationships?.provider?.attributes?.alias,
|
||||
),
|
||||
providerType: getOptionalText(
|
||||
finding.relationships?.provider?.attributes?.provider,
|
||||
) as ProviderType | undefined,
|
||||
};
|
||||
|
||||
// Get selection context - if there are other selected rows, include them
|
||||
const selectionContext = useContext(FindingsSelectionContext);
|
||||
@@ -204,8 +247,19 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
preparationError={mutePreparationError}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<div
|
||||
className="flex items-center justify-end"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ActionDropdown ariaLabel="Finding actions">
|
||||
{!isGroup && (
|
||||
<FindingNoteActionItem
|
||||
triage={finding.triage}
|
||||
findingContext={resolvedFindingContext}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
isMuted ? (
|
||||
|
||||
@@ -68,6 +68,7 @@ export function FindingDetailDrawer({
|
||||
onNavigatePrev={drawer.navigatePrev}
|
||||
onNavigateNext={drawer.navigateNext}
|
||||
onMuteComplete={handleMuteComplete}
|
||||
onTriageUpdate={drawer.patchTriageUpdate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -93,6 +94,7 @@ export function FindingDetailDrawer({
|
||||
onNavigatePrev={drawer.navigatePrev}
|
||||
onNavigateNext={drawer.navigateNext}
|
||||
onMuteComplete={handleMuteComplete}
|
||||
onTriageUpdate={drawer.patchTriageUpdate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({
|
||||
ProviderTypeIcon: ({ type }: { type: string }) => (
|
||||
<span data-testid={`${type}-provider-badge`}>{type} icon</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/modal", () => ({
|
||||
Modal: ({
|
||||
children,
|
||||
open,
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
title?: string;
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => false),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_NOTE_PRIVACY_COPY,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageDetail,
|
||||
type UpdateFindingTriageInput,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import {
|
||||
FindingNoteModal,
|
||||
type FindingTriageContext,
|
||||
} from "./finding-note-modal";
|
||||
|
||||
function makeTriageDetail(
|
||||
overrides?: Partial<FindingTriageDetail>,
|
||||
): FindingTriageDetail {
|
||||
return {
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
hasVisibleNote: true,
|
||||
isMuted: false,
|
||||
canEdit: true,
|
||||
billingHref: "https://prowler.com/pricing",
|
||||
noteId: "note-1",
|
||||
noteBody: "Existing investigation note",
|
||||
maxNoteLength: 500,
|
||||
privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderNoteModal({
|
||||
triage = makeTriageDetail(),
|
||||
onTriageUpdateAction = vi.fn(),
|
||||
onOpenChange = vi.fn(),
|
||||
findingContext = {
|
||||
title: "S3 bucket allows public reads",
|
||||
resource: "production-bucket",
|
||||
provider: "production-account",
|
||||
},
|
||||
}: {
|
||||
triage?: FindingTriageDetail;
|
||||
onTriageUpdateAction?: (input: UpdateFindingTriageInput) => void;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
findingContext?: FindingTriageContext;
|
||||
} = {}) {
|
||||
render(
|
||||
<FindingNoteModal
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
triage={triage}
|
||||
findingContext={findingContext}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
return { onTriageUpdateAction, onOpenChange };
|
||||
}
|
||||
|
||||
describe("FindingNoteModal", () => {
|
||||
it("should render the provider badge from the row provider type", () => {
|
||||
// Given / When
|
||||
renderNoteModal({
|
||||
findingContext: {
|
||||
title: "Azure finding",
|
||||
provider: "azure-subscription",
|
||||
providerType: "azure",
|
||||
},
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(screen.getByTestId("azure-provider-badge")).toBeVisible();
|
||||
expect(screen.queryByText("AWS")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open with title Add Triage Note and current status preselected", () => {
|
||||
// Given / When
|
||||
renderNoteModal({
|
||||
triage: makeTriageDetail({
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
label: "Remediating",
|
||||
}),
|
||||
});
|
||||
|
||||
// Then
|
||||
const dialog = screen.getByRole("dialog", { name: "Add Triage Note" });
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(within(dialog).getByText("S3 bucket allows public reads"));
|
||||
expect(
|
||||
within(dialog).getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveTextContent("Remediating");
|
||||
expect(
|
||||
within(dialog).getByText(/automatically changed to Resolved/i),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("should send existing note changes with noteId and without duplicate-note status payload", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageUpdateAction = vi.fn();
|
||||
renderNoteModal({ onTriageUpdateAction });
|
||||
|
||||
// When
|
||||
const textarea = screen.getByLabelText("Note text");
|
||||
await user.clear(textarea);
|
||||
await user.type(textarea, "Documented owner follow-up.");
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
// Then
|
||||
expect(onTriageUpdateAction).toHaveBeenCalledWith({
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
noteId: "note-1",
|
||||
isMuted: false,
|
||||
note: "Documented owner follow-up.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should send status plus note only when creating the first note", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageUpdateAction = vi.fn();
|
||||
renderNoteModal({
|
||||
triage: makeTriageDetail({
|
||||
triageId: null,
|
||||
notesCount: 0,
|
||||
noteId: null,
|
||||
noteBody: "",
|
||||
hasVisibleNote: false,
|
||||
}),
|
||||
onTriageUpdateAction,
|
||||
});
|
||||
|
||||
// When
|
||||
const textarea = screen.getByLabelText("Note text");
|
||||
await user.type(textarea, " Initial triage note. ");
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
// Then
|
||||
expect(onTriageUpdateAction).toHaveBeenCalledWith({
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: null,
|
||||
notesCount: 0,
|
||||
noteId: null,
|
||||
isMuted: false,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
note: "Initial triage note.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should send an empty body when an existing note is cleared", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
const onTriageUpdateAction = vi.fn();
|
||||
renderNoteModal({ onOpenChange, onTriageUpdateAction });
|
||||
|
||||
// When
|
||||
await user.clear(screen.getByLabelText("Note text"));
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
// Then
|
||||
expect(onTriageUpdateAction).toHaveBeenCalledWith({
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
noteId: "note-1",
|
||||
isMuted: false,
|
||||
note: "",
|
||||
});
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("should keep the modal open and show an error when note update fails", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
const onTriageUpdateAction = vi.fn().mockRejectedValue(new Error("fail"));
|
||||
renderNoteModal({ onOpenChange, onTriageUpdateAction });
|
||||
|
||||
// When
|
||||
await user.clear(screen.getByLabelText("Note text"));
|
||||
await user.type(screen.getByLabelText("Note text"), "Changed note");
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByText("Could not update the note. Please try again."),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "Add Triage Note" }),
|
||||
).toBeInTheDocument();
|
||||
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("should render counter, privacy copy, and cancel/update actions", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
renderNoteModal({ onOpenChange });
|
||||
|
||||
// When
|
||||
await user.clear(screen.getByLabelText("Note text"));
|
||||
await user.type(screen.getByLabelText("Note text"), "abc");
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("3/500")).toBeInTheDocument();
|
||||
expect(screen.getByText(FINDING_TRIAGE_NOTE_PRIVACY_COPY)).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save changes" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable controls and show the Cloud upsell badge for non-paying users", () => {
|
||||
// Given
|
||||
renderNoteModal({
|
||||
triage: makeTriageDetail({
|
||||
canEdit: false,
|
||||
disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY,
|
||||
}),
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveAttribute("data-disabled", "");
|
||||
expect(screen.getByLabelText("Note text")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Available in Prowler Cloud" }),
|
||||
).toHaveAttribute("href", "https://prowler.com/pricing");
|
||||
expect(screen.queryByText(/will be muted/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show modal-origin Mutelist info and still save accepted-risk statuses", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageUpdateAction = vi.fn();
|
||||
renderNoteModal({
|
||||
triage: makeTriageDetail({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
noteBody: "",
|
||||
}),
|
||||
onTriageUpdateAction,
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "Risk Accepted" }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByText(/will be muted/i)).toBeVisible();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(onTriageUpdateAction).toHaveBeenCalledWith({
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
noteId: "note-1",
|
||||
isMuted: false,
|
||||
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
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 { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge";
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_ORIGIN,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageDetail,
|
||||
type FindingTriageStatus,
|
||||
isMutelistShortcutStatus,
|
||||
} from "@/types/findings-triage";
|
||||
import type { ProviderType } from "@/types/providers";
|
||||
|
||||
import {
|
||||
FindingTriageStatusControl,
|
||||
type FindingTriageUpdateHandler,
|
||||
} from "./finding-triage-status-control";
|
||||
import { buildFindingTriageUpdateInput } from "./finding-triage-submit";
|
||||
|
||||
export interface FindingTriageContext {
|
||||
title: string;
|
||||
resource?: string;
|
||||
provider?: string;
|
||||
providerType?: ProviderType;
|
||||
}
|
||||
|
||||
interface FindingNoteModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
triage: FindingTriageDetail;
|
||||
findingContext: FindingTriageContext;
|
||||
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.";
|
||||
|
||||
export function FindingNoteModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
triage,
|
||||
findingContext,
|
||||
onTriageUpdateAction,
|
||||
}: FindingNoteModalProps) {
|
||||
// Local state needed: modal edits are buffered until the user chooses Update.
|
||||
const [selectedStatus, setSelectedStatus] = useState<FindingTriageStatus>(
|
||||
triage.status,
|
||||
);
|
||||
const [note, setNote] = useState(triage.noteBody);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const noteTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const canSubmit =
|
||||
triage.canEdit && Boolean(onTriageUpdateAction) && !isSubmitting;
|
||||
const isCloudOnly =
|
||||
triage.disabledReason === FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY;
|
||||
const shouldShowMutelistInfo =
|
||||
canSubmit &&
|
||||
!triage.isMuted &&
|
||||
selectedStatus !== triage.status &&
|
||||
isMutelistShortcutStatus(selectedStatus);
|
||||
const shouldShowRemediatingInfo =
|
||||
selectedStatus === FINDING_TRIAGE_STATUS.REMEDIATING;
|
||||
// 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) => {
|
||||
const textarea = noteTextareaRef.current;
|
||||
if (textarea && !textarea.disabled) {
|
||||
event.preventDefault();
|
||||
textarea.focus();
|
||||
}
|
||||
// Otherwise let Radix auto-focus the first control inside the dialog.
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const updateInput = buildFindingTriageUpdateInput({
|
||||
triage,
|
||||
selectedStatus,
|
||||
noteBody: note,
|
||||
});
|
||||
|
||||
if (!updateInput) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await onTriageUpdateAction?.(updateInput);
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
setSubmitError("Could not update the note. Please try again.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onOpenAutoFocus={handleOpenAutoFocus}
|
||||
title="Add Triage Note"
|
||||
size="lg"
|
||||
>
|
||||
<form className="flex 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 ? (
|
||||
<ProviderTypeIcon type={findingContext.providerType} size={22} />
|
||||
) : (
|
||||
<span className="text-text-neutral-secondary text-xs font-semibold">
|
||||
{findingContext.provider?.slice(0, 3).toUpperCase() ?? "—"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text-neutral-primary text-sm font-semibold">
|
||||
{findingContext.title}
|
||||
</p>
|
||||
{(findingContext.resource || findingContext.provider) && (
|
||||
<p className="text-text-neutral-secondary mt-1 text-xs">
|
||||
{[findingContext.resource, findingContext.provider]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center 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>
|
||||
|
||||
{shouldShowMutelistInfo && (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>{MUTELIST_INFO_COPY}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{shouldShowRemediatingInfo && (
|
||||
<Alert variant="info">
|
||||
<AlertDescription>{REMEDIATING_INFO_COPY}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{submitError && (
|
||||
<Alert variant="error">
|
||||
<AlertDescription>{submitError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
ref={noteTextareaRef}
|
||||
id="finding-triage-note"
|
||||
aria-label="Note text"
|
||||
value={note}
|
||||
maxLength={triage.maxNoteLength}
|
||||
disabled={!canSubmit}
|
||||
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>
|
||||
<p className="text-text-neutral-tertiary shrink-0 text-xs">
|
||||
{note.length}/{triage.maxNoteLength}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<span className="relative inline-flex">
|
||||
{isCloudOnly && (
|
||||
<span className="absolute top-0 right-0 z-10 translate-x-1/3 -translate-y-1/2">
|
||||
<CloudFeatureBadgeLink href={triage.billingHref} />
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type={canSubmit ? "submit" : "button"}
|
||||
size="lg"
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{isSubmitting
|
||||
? "Saving..."
|
||||
: canSubmit || isCloudOnly
|
||||
? "Save changes"
|
||||
: "Unavailable"}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/shadcn/modal", () => ({
|
||||
Modal: ({
|
||||
children,
|
||||
open,
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
title?: string;
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
ActionDropdownItem: ({
|
||||
label,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
onSelect?: () => void;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<button disabled={disabled} onClick={onSelect}>
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => false),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import {
|
||||
FindingNoteActionItem,
|
||||
FindingTriageStatusCell,
|
||||
} from "./finding-triage-cells";
|
||||
|
||||
function makeTriageSummary(
|
||||
overrides?: Partial<FindingTriageSummary>,
|
||||
): FindingTriageSummary {
|
||||
return {
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-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,
|
||||
};
|
||||
}
|
||||
|
||||
describe("finding triage cells", () => {
|
||||
it("should open the Note modal from the note action with the current status preselected", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingNoteActionItem
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
label: "Remediating",
|
||||
})}
|
||||
findingContext={{ title: "S3 bucket allows public reads" }}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const addNoteButton = screen.getByRole("button", {
|
||||
name: "Add Triage Note",
|
||||
});
|
||||
expect(addNoteButton).toHaveTextContent("Add Triage Note");
|
||||
await user.click(addNoteButton);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "Add Triage Note" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveTextContent("Remediating");
|
||||
});
|
||||
|
||||
it("should not propagate table status clicks to the row", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = vi.fn();
|
||||
render(
|
||||
<div onClick={onRowClick}>
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
})}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
|
||||
// Then
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should render status picker with fixed width and colored options", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
})}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusControl = screen.getByRole("combobox", {
|
||||
name: "Triage status",
|
||||
});
|
||||
|
||||
// When
|
||||
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(
|
||||
"text-text-warning-primary",
|
||||
);
|
||||
expect(
|
||||
within(screen.getByRole("option", { name: "Open" })).getByText("Open"),
|
||||
).toHaveClass("text-text-error-primary");
|
||||
expect(
|
||||
within(screen.getByRole("option", { name: "Under Review" })).getByText(
|
||||
"Under Review",
|
||||
),
|
||||
).toHaveClass("text-text-warning-primary");
|
||||
expect(
|
||||
within(screen.getByRole("option", { name: "Remediating" })).getByText(
|
||||
"Remediating",
|
||||
),
|
||||
).toHaveClass("text-bg-data-info");
|
||||
expect(
|
||||
within(screen.getByRole("option", { name: "Risk Accepted" })).getByText(
|
||||
"Risk Accepted",
|
||||
),
|
||||
).toHaveClass("text-bg-pass");
|
||||
expect(
|
||||
within(screen.getByRole("option", { name: "False Positive" })).getByText(
|
||||
"False Positive",
|
||||
),
|
||||
).toHaveClass("text-text-neutral-secondary");
|
||||
});
|
||||
|
||||
it("should disable table status mutation when no update handler is wired", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
canEdit: true,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusControl = screen.getByRole("combobox", {
|
||||
name: "Triage status",
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(statusControl);
|
||||
|
||||
// Then
|
||||
expect(statusControl).toBeDisabled();
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not open an editable empty-note modal for an existing note without a loader", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingNoteActionItem
|
||||
triage={makeTriageSummary({ hasVisibleNote: true })}
|
||||
findingContext={{ title: "S3 bucket allows public reads" }}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const existingNoteButton = screen.getByRole("button", {
|
||||
name: "Open note",
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(existingNoteButton);
|
||||
|
||||
// Then
|
||||
expect(existingNoteButton).toBeDisabled();
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "Add Triage Note" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should load an existing note before opening the modal", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageNoteLoadAction = vi.fn().mockResolvedValue({
|
||||
noteId: "note-1",
|
||||
noteBody: "Loaded existing note",
|
||||
});
|
||||
render(
|
||||
<FindingNoteActionItem
|
||||
triage={makeTriageSummary({ hasVisibleNote: true, notesCount: 1 })}
|
||||
findingContext={{ title: "S3 bucket allows public reads" }}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Open note" }));
|
||||
|
||||
// Then
|
||||
expect(onTriageNoteLoadAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ triageId: "triage-1", notesCount: 1 }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("dialog", { name: "Add Triage Note" }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByLabelText("Note text")).toHaveValue(
|
||||
"Loaded existing note",
|
||||
);
|
||||
});
|
||||
|
||||
it("should open a disabled billing upsell modal for Cloud-only Add Triage Note", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingNoteActionItem
|
||||
triage={makeTriageSummary({
|
||||
canEdit: false,
|
||||
hasVisibleNote: false,
|
||||
disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY,
|
||||
})}
|
||||
findingContext={{ title: "S3 bucket allows public reads" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Add Triage Note" }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
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("link", { name: "Available in Prowler Cloud" }),
|
||||
).toHaveAttribute("href", "https://prowler.com/pricing");
|
||||
});
|
||||
|
||||
it("should disable Add Triage Note when no update handler is wired", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingNoteActionItem
|
||||
triage={makeTriageSummary({ hasVisibleNote: false, canEdit: true })}
|
||||
findingContext={{ title: "S3 bucket allows public reads" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const addNoteButton = screen.getByRole("button", {
|
||||
name: "Add Triage Note",
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(addNoteButton);
|
||||
|
||||
// Then
|
||||
expect(addNoteButton).toBeDisabled();
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "Add Triage Note" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should expose a screen-reader error when an existing note cannot load", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageNoteLoadAction = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("load failed"));
|
||||
render(
|
||||
<FindingNoteActionItem
|
||||
triage={makeTriageSummary({ hasVisibleNote: true, notesCount: 1 })}
|
||||
findingContext={{ title: "S3 bucket allows public reads" }}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Open note" }));
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Could not load the existing note.",
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "Add Triage Note" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should keep the optimistic table status while stale props are rendered during update", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
let resolveUpdate: () => void = () => {};
|
||||
const onTriageUpdateAction = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveUpdate = resolve;
|
||||
}),
|
||||
);
|
||||
const { rerender } = render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
})}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusControl = screen.getByRole("combobox", {
|
||||
name: "Triage status",
|
||||
});
|
||||
|
||||
// When: user selects a new status.
|
||||
await user.click(statusControl);
|
||||
await user.click(screen.getByRole("option", { name: "Under Review" }));
|
||||
|
||||
// Then: the optimistic status is visible immediately.
|
||||
expect(statusControl).toHaveTextContent("Under Review");
|
||||
|
||||
// When: the parent renders stale data while the request is still pending.
|
||||
rerender(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
})}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then: the control must not flicker back to Open.
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveTextContent("Under Review");
|
||||
|
||||
// When: backend completes and fresh props arrive.
|
||||
resolveUpdate();
|
||||
await waitFor(() => expect(onTriageUpdateAction).toHaveBeenCalled());
|
||||
rerender(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
})}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveTextContent("Under Review");
|
||||
});
|
||||
|
||||
it("should not resurrect a stale optimistic status after the server later returns the previous status", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageUpdateAction = vi.fn().mockResolvedValue(undefined);
|
||||
const { rerender } = render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
})}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When: user optimistically moves Open -> Under Review and it succeeds.
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "Under Review" }));
|
||||
await waitFor(() => expect(onTriageUpdateAction).toHaveBeenCalled());
|
||||
|
||||
// And: fresh props converge on the optimistic status (server confirmed).
|
||||
rerender(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
})}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When: a later refetch legitimately returns the previous status again.
|
||||
rerender(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
})}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then: the real server status wins; the stale optimistic value is gone.
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveTextContent("Open");
|
||||
});
|
||||
|
||||
it("should refresh the visible table status when triage props change", () => {
|
||||
// Given
|
||||
const { rerender } = render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
})}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveTextContent("Open");
|
||||
|
||||
// When
|
||||
rerender(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
label: "Remediating",
|
||||
})}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveTextContent("Remediating");
|
||||
});
|
||||
|
||||
it("should not submit when table status selection matches the current status", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageUpdateAction = vi.fn();
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
})}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "Open" }));
|
||||
|
||||
// Then
|
||||
expect(onTriageUpdateAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should rollback table status and expose an error when update fails", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageUpdateAction = vi.fn().mockRejectedValue(new Error("fail"));
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
})}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusControl = screen.getByRole("combobox", {
|
||||
name: "Triage status",
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(statusControl);
|
||||
await user.click(screen.getByRole("option", { name: "Remediating" }));
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Could not update triage status.",
|
||||
);
|
||||
expect(statusControl).toHaveTextContent("Open");
|
||||
});
|
||||
|
||||
it("should not confirm when moving between Mutelist shortcut statuses", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageUpdateAction = vi.fn();
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
label: "Risk Accepted",
|
||||
})}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "False Positive" }));
|
||||
|
||||
// Then
|
||||
expect(screen.queryByRole("dialog", { name: "Mute finding?" })).toBeNull();
|
||||
await waitFor(() =>
|
||||
expect(onTriageUpdateAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: FINDING_TRIAGE_STATUS.FALSE_POSITIVE,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
isMuted: false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not confirm or mute again when an already muted finding enters a shortcut status", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageUpdateAction = vi.fn();
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
isMuted: true,
|
||||
})}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "Risk Accepted" }));
|
||||
|
||||
// Then
|
||||
expect(screen.queryByRole("dialog", { name: "Mute finding?" })).toBeNull();
|
||||
await waitFor(() =>
|
||||
expect(onTriageUpdateAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
|
||||
isMuted: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should confirm before applying Mutelist shortcut statuses from the table", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
let resolveUpdate: () => void = () => {};
|
||||
const onTriageUpdateAction = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveUpdate = resolve;
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
})}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusControl = screen.getByRole("combobox", {
|
||||
name: "Triage status",
|
||||
});
|
||||
|
||||
// When: user selects a Mutelist shortcut.
|
||||
await user.click(statusControl);
|
||||
await user.click(screen.getByRole("option", { name: "False Positive" }));
|
||||
|
||||
// Then: the user is warned before the server action handles muting.
|
||||
expect(screen.getByRole("dialog", { name: "Mute finding?" })).toBeVisible();
|
||||
expect(onTriageUpdateAction).not.toHaveBeenCalled();
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Mute finding" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(onTriageUpdateAction).toHaveBeenCalledWith({
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.FALSE_POSITIVE,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
|
||||
isMuted: false,
|
||||
}),
|
||||
);
|
||||
expect(statusControl).toHaveTextContent("False Positive");
|
||||
|
||||
resolveUpdate();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
"use client";
|
||||
|
||||
import { MessageSquareText } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ActionDropdownItem } from "@/components/shadcn/dropdown";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_NOTE_MAX_LENGTH,
|
||||
FINDING_TRIAGE_NOTE_PRIVACY_COPY,
|
||||
FINDING_TRIAGE_ORIGIN,
|
||||
FINDING_TRIAGE_STATUS_LABELS,
|
||||
type FindingTriageDetail,
|
||||
type FindingTriageLoadedNote,
|
||||
type FindingTriageStatus,
|
||||
type FindingTriageSummary,
|
||||
type UpdateFindingTriageInput,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import {
|
||||
FindingNoteModal,
|
||||
type FindingTriageContext,
|
||||
} from "./finding-note-modal";
|
||||
import {
|
||||
FindingTriageStatusControl,
|
||||
type FindingTriageUpdateHandler,
|
||||
} from "./finding-triage-status-control";
|
||||
|
||||
export const CLOUD_ONLY_TOOLTIP_COPY = "Available in Prowler Cloud";
|
||||
export const EDITING_UNAVAILABLE_COPY = "Editing is currently unavailable.";
|
||||
|
||||
const getDisabledCopy = ({
|
||||
triage,
|
||||
hasUpdateHandler,
|
||||
}: {
|
||||
triage: FindingTriageSummary;
|
||||
hasUpdateHandler: boolean;
|
||||
}): string | undefined => {
|
||||
if (triage.disabledReason === FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY) {
|
||||
return CLOUD_ONLY_TOOLTIP_COPY;
|
||||
}
|
||||
|
||||
if (triage.canEdit && !hasUpdateHandler) {
|
||||
return EDITING_UNAVAILABLE_COPY;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getTriageDetailFromSummary = (
|
||||
triage: FindingTriageSummary,
|
||||
loadedNote?: FindingTriageLoadedNote,
|
||||
): FindingTriageDetail => ({
|
||||
...triage,
|
||||
noteId: loadedNote?.noteId ?? null,
|
||||
noteBody: loadedNote?.noteBody ?? "",
|
||||
maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH,
|
||||
privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY,
|
||||
});
|
||||
|
||||
export function FindingTriageStatusCell({
|
||||
triage,
|
||||
onTriageUpdateAction,
|
||||
}: {
|
||||
triage?: FindingTriageSummary;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
}) {
|
||||
const [optimisticStatus, setOptimisticStatus] = useState<{
|
||||
token: string;
|
||||
findingId: string;
|
||||
triageId: string | null;
|
||||
previousStatus: FindingTriageStatus;
|
||||
status: FindingTriageStatus;
|
||||
} | null>(null);
|
||||
|
||||
// Retire the optimistic status once the server converges or the row changes, so a stale value can't resurface.
|
||||
if (
|
||||
optimisticStatus &&
|
||||
(!triage ||
|
||||
optimisticStatus.findingId !== triage.findingId ||
|
||||
optimisticStatus.triageId !== triage.triageId ||
|
||||
triage.status === optimisticStatus.status)
|
||||
) {
|
||||
setOptimisticStatus(null);
|
||||
}
|
||||
|
||||
const optimisticMatchesCurrentTriage =
|
||||
Boolean(triage) &&
|
||||
optimisticStatus?.findingId === triage?.findingId &&
|
||||
optimisticStatus?.triageId === triage?.triageId &&
|
||||
optimisticStatus?.previousStatus === triage?.status &&
|
||||
optimisticStatus?.status !== triage?.status;
|
||||
|
||||
if (!triage) {
|
||||
return <span className="text-text-neutral-tertiary text-sm">-</span>;
|
||||
}
|
||||
|
||||
const displayedTriage =
|
||||
optimisticMatchesCurrentTriage && optimisticStatus
|
||||
? {
|
||||
...triage,
|
||||
status: optimisticStatus.status,
|
||||
label: FINDING_TRIAGE_STATUS_LABELS[optimisticStatus.status],
|
||||
}
|
||||
: triage;
|
||||
|
||||
const handleTriageUpdate = async (input: UpdateFindingTriageInput) => {
|
||||
const optimisticToken = input.status ? crypto.randomUUID() : null;
|
||||
|
||||
if (input.status && optimisticToken) {
|
||||
setOptimisticStatus({
|
||||
token: optimisticToken,
|
||||
findingId: input.findingId,
|
||||
triageId: input.triageId,
|
||||
previousStatus: input.previousStatus ?? triage.status,
|
||||
status: input.status,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await onTriageUpdateAction?.(input);
|
||||
} catch (error) {
|
||||
setOptimisticStatus((current) =>
|
||||
current?.token === optimisticToken ? null : current,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const control = (
|
||||
<div
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<FindingTriageStatusControl
|
||||
key={displayedTriage.findingId}
|
||||
origin={FINDING_TRIAGE_ORIGIN.TABLE}
|
||||
triage={displayedTriage}
|
||||
onTriageUpdateAction={
|
||||
onTriageUpdateAction ? handleTriageUpdate : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const disabledCopy = getDisabledCopy({
|
||||
triage,
|
||||
hasUpdateHandler: Boolean(onTriageUpdateAction),
|
||||
});
|
||||
if (!disabledCopy) {
|
||||
return control;
|
||||
}
|
||||
|
||||
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. */}
|
||||
<span className="flex">{control}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{disabledCopy}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingNoteActionItem({
|
||||
triage,
|
||||
findingContext = { title: "Finding" },
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
}: {
|
||||
triage?: FindingTriageSummary;
|
||||
findingContext?: FindingTriageContext;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
onTriageNoteLoadAction?: (
|
||||
triage: FindingTriageSummary,
|
||||
) => Promise<FindingTriageLoadedNote>;
|
||||
}) {
|
||||
if (!triage) {
|
||||
return <span className="text-text-neutral-tertiary text-sm">-</span>;
|
||||
}
|
||||
|
||||
const triageIdentity = `${triage.findingId}:${triage.triageId ?? "virtual"}`;
|
||||
|
||||
return (
|
||||
<FindingNoteActionItemContent
|
||||
key={triageIdentity}
|
||||
triage={triage}
|
||||
findingContext={findingContext}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FindingNoteActionItemContent({
|
||||
triage,
|
||||
findingContext,
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
}: {
|
||||
triage: FindingTriageSummary;
|
||||
findingContext: FindingTriageContext;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
onTriageNoteLoadAction?: (
|
||||
triage: FindingTriageSummary,
|
||||
) => Promise<FindingTriageLoadedNote>;
|
||||
}) {
|
||||
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
||||
const [loadedNote, setLoadedNote] = useState<FindingTriageLoadedNote>();
|
||||
const [isLoadingNote, setIsLoadingNote] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const hasUpdateHandler = Boolean(onTriageUpdateAction);
|
||||
const isCloudOnly =
|
||||
triage.disabledReason === FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY;
|
||||
const canOpenNewNoteModal =
|
||||
!triage.hasVisibleNote &&
|
||||
((triage.canEdit && hasUpdateHandler) || isCloudOnly);
|
||||
const canOpenExistingNoteModal =
|
||||
triage.hasVisibleNote &&
|
||||
triage.canEdit &&
|
||||
hasUpdateHandler &&
|
||||
Boolean(onTriageNoteLoadAction) &&
|
||||
!isLoadingNote;
|
||||
const disabledCopy = getDisabledCopy({ triage, hasUpdateHandler });
|
||||
const canOpenNoteModal = triage.hasVisibleNote
|
||||
? canOpenExistingNoteModal
|
||||
: canOpenNewNoteModal;
|
||||
const label = isLoadingNote
|
||||
? "Loading note..."
|
||||
: triage.hasVisibleNote
|
||||
? "Open note"
|
||||
: "Add Triage Note";
|
||||
|
||||
const handleNoteSelect = async () => {
|
||||
if (!canOpenNoteModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!triage.hasVisibleNote) {
|
||||
setIsNoteModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!onTriageNoteLoadAction) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadError(null);
|
||||
setIsLoadingNote(true);
|
||||
|
||||
try {
|
||||
const note = await onTriageNoteLoadAction(triage);
|
||||
setLoadedNote(note);
|
||||
setIsNoteModalOpen(true);
|
||||
} catch {
|
||||
setLoadError("Could not load the existing note.");
|
||||
} finally {
|
||||
setIsLoadingNote(false);
|
||||
}
|
||||
};
|
||||
|
||||
const noteModal = isNoteModalOpen ? (
|
||||
<FindingNoteModal
|
||||
open={isNoteModalOpen}
|
||||
onOpenChange={setIsNoteModalOpen}
|
||||
triage={getTriageDetailFromSummary(triage, loadedNote)}
|
||||
findingContext={findingContext}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionDropdownItem
|
||||
icon={<MessageSquareText className="size-5" />}
|
||||
label={label}
|
||||
disabled={!canOpenNoteModal}
|
||||
title={
|
||||
triage.hasVisibleNote && !canOpenExistingNoteModal
|
||||
? "Existing note cannot be loaded from the table."
|
||||
: disabledCopy
|
||||
}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
void handleNoteSelect();
|
||||
}}
|
||||
/>
|
||||
{loadError && (
|
||||
<span className="sr-only" role="alert">
|
||||
{loadError}
|
||||
</span>
|
||||
)}
|
||||
{noteModal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
"use client";
|
||||
|
||||
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,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/shadcn/select/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
FINDING_TRIAGE_MANUAL_STATUS_VALUES,
|
||||
FINDING_TRIAGE_ORIGIN,
|
||||
FINDING_TRIAGE_STATUS_LABELS,
|
||||
type FindingTriageManualStatus,
|
||||
type FindingTriageStatus,
|
||||
type FindingTriageSummary,
|
||||
isManualStatus,
|
||||
isMutelistShortcutStatus,
|
||||
type UpdateFindingTriageInput,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
export type FindingTriageUpdateHandler = (
|
||||
input: UpdateFindingTriageInput,
|
||||
) => void | Promise<void>;
|
||||
|
||||
type TriageStatusPickerSize = NonNullable<
|
||||
ComponentProps<typeof SelectTrigger>["size"]
|
||||
>;
|
||||
|
||||
const TRIAGE_STATUS_TEXT_CLASS = {
|
||||
open: "text-text-error-primary",
|
||||
under_review: "text-text-warning-primary",
|
||||
remediating: "text-bg-data-info",
|
||||
resolved: "text-bg-pass",
|
||||
risk_accepted: "text-bg-pass",
|
||||
false_positive: "text-text-neutral-secondary",
|
||||
reopened: "text-text-error-primary",
|
||||
} 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,
|
||||
size = "sm",
|
||||
value,
|
||||
onValueChange,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
size?: TriageStatusPickerSize;
|
||||
value: FindingTriageStatus;
|
||||
onValueChange: (status: FindingTriageManualStatus) => void;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onValueChange={(nextStatus) => {
|
||||
if (isManualStatus(nextStatus as FindingTriageStatus)) {
|
||||
onValueChange(nextStatus as FindingTriageManualStatus);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="Triage status"
|
||||
disabled={disabled}
|
||||
size={size}
|
||||
iconSize="sm"
|
||||
>
|
||||
<span className={cn("truncate", TRIAGE_STATUS_TEXT_CLASS[value])}>
|
||||
{FINDING_TRIAGE_STATUS_LABELS[value]}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FINDING_TRIAGE_MANUAL_STATUS_VALUES.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
<span className={cn("truncate", TRIAGE_STATUS_TEXT_CLASS[status])}>
|
||||
{FINDING_TRIAGE_STATUS_LABELS[status]}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
type TableStatusControlProps = {
|
||||
origin: typeof FINDING_TRIAGE_ORIGIN.TABLE;
|
||||
triage: FindingTriageSummary;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
};
|
||||
|
||||
type ModalStatusControlProps = {
|
||||
origin: typeof FINDING_TRIAGE_ORIGIN.MODAL;
|
||||
triage: FindingTriageSummary;
|
||||
value: FindingTriageStatus;
|
||||
onValueChange: (status: FindingTriageManualStatus) => void;
|
||||
};
|
||||
|
||||
type FindingTriageStatusControlProps =
|
||||
| TableStatusControlProps
|
||||
| ModalStatusControlProps;
|
||||
|
||||
export function FindingTriageStatusControl(
|
||||
props: FindingTriageStatusControlProps,
|
||||
) {
|
||||
const [tableUpdateError, setTableUpdateError] = useState<string | null>(null);
|
||||
const [isTableUpdating, setIsTableUpdating] = useState(false);
|
||||
const [pendingShortcutStatus, setPendingShortcutStatus] =
|
||||
useState<FindingTriageManualStatus | null>(null);
|
||||
const triage = props.triage;
|
||||
|
||||
if (props.origin === FINDING_TRIAGE_ORIGIN.MODAL) {
|
||||
return (
|
||||
<TriageStatusPicker
|
||||
disabled={!triage.canEdit}
|
||||
value={props.value}
|
||||
onValueChange={props.onValueChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const canMutateFromTable =
|
||||
triage.canEdit && Boolean(props.onTriageUpdateAction) && !isTableUpdating;
|
||||
|
||||
const applyTableStatus = async (status: FindingTriageManualStatus) => {
|
||||
if (!props.onTriageUpdateAction || status === triage.status) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTableUpdateError(null);
|
||||
setIsTableUpdating(true);
|
||||
|
||||
try {
|
||||
await props.onTriageUpdateAction({
|
||||
findingId: triage.findingId,
|
||||
findingUid: triage.findingUid,
|
||||
triageId: triage.triageId,
|
||||
notesCount: triage.notesCount,
|
||||
status,
|
||||
previousStatus: triage.status,
|
||||
isMuted: triage.isMuted,
|
||||
});
|
||||
} catch {
|
||||
setTableUpdateError("Could not update triage status.");
|
||||
} finally {
|
||||
setIsTableUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldConfirmMute = (status: FindingTriageManualStatus) =>
|
||||
!triage.isMuted &&
|
||||
isMutelistShortcutStatus(status) &&
|
||||
!isMutelistShortcutStatus(triage.status);
|
||||
|
||||
const handleTableValueChange = (status: FindingTriageManualStatus) => {
|
||||
if (!props.onTriageUpdateAction || status === triage.status) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldConfirmMute(status)) {
|
||||
setPendingShortcutStatus(status);
|
||||
return;
|
||||
}
|
||||
|
||||
void applyTableStatus(status);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<InfoField label="Triage" variant="compact">
|
||||
<div className="w-32">
|
||||
<TriageStatusPicker
|
||||
disabled={!canMutateFromTable}
|
||||
size="xs"
|
||||
value={triage.status}
|
||||
onValueChange={handleTableValueChange}
|
||||
/>
|
||||
</div>
|
||||
</InfoField>
|
||||
{tableUpdateError && (
|
||||
<span className="sr-only" role="alert">
|
||||
{tableUpdateError}
|
||||
</span>
|
||||
)}
|
||||
<Modal
|
||||
open={pendingShortcutStatus !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPendingShortcutStatus(null);
|
||||
}
|
||||
}}
|
||||
title={MUTELIST_CONFIRMATION_TITLE}
|
||||
description={MUTELIST_CONFIRMATION_COPY}
|
||||
size="sm"
|
||||
>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setPendingShortcutStatus(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const status = pendingShortcutStatus;
|
||||
setPendingShortcutStatus(null);
|
||||
if (status) {
|
||||
void applyTableStatus(status);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Mute finding
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
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";
|
||||
|
||||
import { buildFindingTriageUpdateInput } from "./finding-triage-submit";
|
||||
|
||||
function makeTriageDetail(
|
||||
overrides?: Partial<FindingTriageDetail>,
|
||||
): FindingTriageDetail {
|
||||
return {
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
hasVisibleNote: true,
|
||||
isMuted: false,
|
||||
canEdit: true,
|
||||
billingHref: "https://prowler.com/pricing",
|
||||
noteId: "note-1",
|
||||
noteBody: "Existing investigation note",
|
||||
maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH,
|
||||
privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildFindingTriageUpdateInput", () => {
|
||||
it("should return null when neither status nor note changed", () => {
|
||||
// Given
|
||||
const triage = makeTriageDetail();
|
||||
|
||||
// When
|
||||
const result = buildFindingTriageUpdateInput({
|
||||
triage,
|
||||
selectedStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
noteBody: "Existing investigation note",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should update an existing note through noteId without duplicating note creation", () => {
|
||||
// Given
|
||||
const triage = makeTriageDetail();
|
||||
|
||||
// When
|
||||
const result = buildFindingTriageUpdateInput({
|
||||
triage,
|
||||
selectedStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
noteBody: " Updated existing note ",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
noteId: "note-1",
|
||||
isMuted: false,
|
||||
note: "Updated existing note",
|
||||
});
|
||||
});
|
||||
|
||||
it("should send status plus note only when creating the first note", () => {
|
||||
// Given
|
||||
const triage = makeTriageDetail({
|
||||
triageId: null,
|
||||
notesCount: 0,
|
||||
noteId: null,
|
||||
noteBody: "",
|
||||
hasVisibleNote: false,
|
||||
});
|
||||
|
||||
// When
|
||||
const result = buildFindingTriageUpdateInput({
|
||||
triage,
|
||||
selectedStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
noteBody: " First note ",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: null,
|
||||
notesCount: 0,
|
||||
noteId: null,
|
||||
isMuted: false,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
note: "First note",
|
||||
});
|
||||
});
|
||||
|
||||
it("should send only status when status changes and an existing note is unchanged", () => {
|
||||
// Given
|
||||
const triage = makeTriageDetail();
|
||||
|
||||
// When
|
||||
const result = buildFindingTriageUpdateInput({
|
||||
triage,
|
||||
selectedStatus: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
noteBody: "Existing investigation note",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
noteId: "note-1",
|
||||
isMuted: false,
|
||||
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
});
|
||||
});
|
||||
|
||||
it("should send an empty note when an existing note is cleared", () => {
|
||||
// Given
|
||||
const triage = makeTriageDetail();
|
||||
|
||||
// When
|
||||
const result = buildFindingTriageUpdateInput({
|
||||
triage,
|
||||
selectedStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
noteBody: " ",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
noteId: "note-1",
|
||||
isMuted: false,
|
||||
note: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
type FindingTriageDetail,
|
||||
type FindingTriageManualStatus,
|
||||
type FindingTriageStatus,
|
||||
isManualStatus,
|
||||
type UpdateFindingTriageInput,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
export interface BuildFindingTriageUpdateInputParams {
|
||||
triage: FindingTriageDetail;
|
||||
selectedStatus: FindingTriageStatus;
|
||||
noteBody: string;
|
||||
}
|
||||
|
||||
export function buildFindingTriageUpdateInput({
|
||||
triage,
|
||||
selectedStatus,
|
||||
noteBody,
|
||||
}: BuildFindingTriageUpdateInputParams): UpdateFindingTriageInput | null {
|
||||
const trimmedNote = noteBody.trim();
|
||||
const statusChanged = selectedStatus !== triage.status;
|
||||
const shouldCreateFirstNote =
|
||||
triage.notesCount === 0 && trimmedNote.length > 0;
|
||||
const shouldUpdateExistingNote =
|
||||
triage.notesCount > 0 &&
|
||||
triage.noteId !== null &&
|
||||
trimmedNote !== triage.noteBody;
|
||||
const shouldIncludeStatus =
|
||||
isManualStatus(selectedStatus) && (statusChanged || shouldCreateFirstNote);
|
||||
|
||||
if (
|
||||
!shouldIncludeStatus &&
|
||||
!shouldCreateFirstNote &&
|
||||
!shouldUpdateExistingNote
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
findingId: triage.findingId,
|
||||
findingUid: triage.findingUid,
|
||||
triageId: triage.triageId,
|
||||
notesCount: triage.notesCount,
|
||||
noteId: triage.noteId,
|
||||
isMuted: triage.isMuted,
|
||||
...(shouldIncludeStatus
|
||||
? {
|
||||
status: selectedStatus as FindingTriageManualStatus,
|
||||
previousStatus: triage.status,
|
||||
}
|
||||
: {}),
|
||||
...(shouldCreateFirstNote || shouldUpdateExistingNote
|
||||
? { note: trimmedNote }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import {
|
||||
loadLatestFindingTriageNote,
|
||||
updateFindingTriage,
|
||||
} from "@/actions/findings";
|
||||
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
|
||||
import {
|
||||
Table,
|
||||
@@ -73,6 +77,7 @@ export function FindingsGroupDrillDown({
|
||||
handleMuteComplete,
|
||||
handleRowSelectionChange,
|
||||
resolveSelectedFindingIds,
|
||||
updateTriageOptimistically,
|
||||
} = useFindingGroupResourceState({
|
||||
group,
|
||||
filters,
|
||||
@@ -82,6 +87,10 @@ export function FindingsGroupDrillDown({
|
||||
const columns = getColumnFindingResources({
|
||||
rowSelection,
|
||||
selectableRowCount,
|
||||
findingTitle: group.checkTitle,
|
||||
onTriageUpdateAction: (input) =>
|
||||
updateTriageOptimistically(input, updateFindingTriage),
|
||||
onTriageNoteLoadAction: loadLatestFindingTriageNote,
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -249,6 +258,7 @@ export function FindingsGroupDrillDown({
|
||||
onNavigatePrev={drawer.navigatePrev}
|
||||
onNavigateNext={drawer.navigateNext}
|
||||
onMuteComplete={handleDrawerMuteComplete}
|
||||
onTriageUpdate={drawer.patchTriageUpdate}
|
||||
/>
|
||||
</FindingsSelectionContext.Provider>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ export * from "./column-finding-resources";
|
||||
export * from "./column-standalone-findings";
|
||||
export * from "./data-table-row-actions";
|
||||
export * from "./finding-detail-drawer";
|
||||
export * from "./finding-triage-cells";
|
||||
export * from "./findings-group-drill-down";
|
||||
export * from "./findings-group-table";
|
||||
export * from "./findings-selection-context";
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("inline resource container", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const filePath = path.join(currentDir, "inline-resource-container.tsx");
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
|
||||
it("uses the shared finding-group resource state hook", () => {
|
||||
expect(source).toContain("useFindingGroupResourceState");
|
||||
expect(source).not.toContain("useInfiniteResources");
|
||||
});
|
||||
});
|
||||
@@ -9,11 +9,16 @@ import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ChevronsDown } from "lucide-react";
|
||||
import { useImperativeHandle, useRef } from "react";
|
||||
|
||||
import {
|
||||
loadLatestFindingTriageNote,
|
||||
updateFindingTriage,
|
||||
} from "@/actions/findings";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state";
|
||||
import { useScrollHint } from "@/hooks/use-scroll-hint";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FindingGroupRow } from "@/types";
|
||||
|
||||
import { getColumnFindingResources } from "./column-finding-resources";
|
||||
@@ -52,6 +57,22 @@ interface InlineResourceContainerProps {
|
||||
|
||||
/** Max skeleton rows that fit in the 440px scroll container */
|
||||
const MAX_SKELETON_ROWS = 7;
|
||||
const ACTIONS_COLUMN_ID = "actions";
|
||||
const COMPACT_LABELED_COLUMN_IDS = new Set([
|
||||
"service",
|
||||
"region",
|
||||
"lastSeen",
|
||||
"failingFor",
|
||||
"triage",
|
||||
]);
|
||||
const STICKY_RESOURCE_ACTION_CELL_CLASS =
|
||||
"sticky right-0 z-20 min-w-12 last:rounded-r-none! overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary group-data-[state=selected]:bg-bg-neutral-tertiary group-data-[state=selected]:before:to-bg-neutral-tertiary";
|
||||
|
||||
const getResourceCellClassName = (columnId: string) =>
|
||||
cn(
|
||||
COMPACT_LABELED_COLUMN_IDS.has(columnId) && "align-top",
|
||||
columnId === ACTIONS_COLUMN_ID && STICKY_RESOURCE_ACTION_CELL_CLASS,
|
||||
);
|
||||
|
||||
function ResourceSkeletonRow({
|
||||
isEmptyStateSized = false,
|
||||
@@ -111,9 +132,17 @@ function ResourceSkeletonRow({
|
||||
<TableCell className={cellClassName}>
|
||||
<Skeleton className="h-4.5 w-16 rounded" />
|
||||
</TableCell>
|
||||
{/* Actions */}
|
||||
{/* Triage */}
|
||||
<TableCell className={cellClassName}>
|
||||
<Skeleton className="size-8 rounded-md" />
|
||||
<Skeleton className="h-8 w-20 rounded-lg" />
|
||||
</TableCell>
|
||||
{/* Actions */}
|
||||
<TableCell
|
||||
className={cn(cellClassName, STICKY_RESOURCE_ACTION_CELL_CLASS)}
|
||||
>
|
||||
<div className="flex justify-end">
|
||||
<Skeleton className="size-8 rounded-md" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -160,6 +189,7 @@ export function InlineResourceContainer({
|
||||
handleMuteComplete,
|
||||
handleRowSelectionChange,
|
||||
resolveSelectedFindingIds,
|
||||
updateTriageOptimistically,
|
||||
} = useFindingGroupResourceState({
|
||||
group,
|
||||
filters,
|
||||
@@ -186,6 +216,10 @@ export function InlineResourceContainer({
|
||||
const columns = getColumnFindingResources({
|
||||
rowSelection,
|
||||
selectableRowCount,
|
||||
findingTitle: group.checkTitle,
|
||||
onTriageUpdateAction: (input) =>
|
||||
updateTriageOptimistically(input, updateFindingTriage),
|
||||
onTriageNoteLoadAction: loadLatestFindingTriageNote,
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -214,7 +248,7 @@ export function InlineResourceContainer({
|
||||
}}
|
||||
>
|
||||
<tr>
|
||||
<td colSpan={columnCount} className="p-0">
|
||||
<td colSpan={columnCount} className="max-w-0 p-0">
|
||||
<AnimatePresence initial>
|
||||
<motion.div
|
||||
// Onboarding anchor: the "Review the affected resources" tour step.
|
||||
@@ -228,10 +262,10 @@ export function InlineResourceContainer({
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={combinedScrollRef}
|
||||
className="max-h-[440px] overflow-y-auto pl-6"
|
||||
className="minimal-scrollbar max-h-[440px] overflow-auto pl-6"
|
||||
>
|
||||
{/* Resource rows or skeleton placeholder */}
|
||||
<table className="-mt-2.5 w-full border-separate border-spacing-y-4">
|
||||
<table className="-mt-2.5 w-max min-w-full border-separate border-spacing-y-4">
|
||||
<tbody>
|
||||
{isLoading && rows.length === 0 ? (
|
||||
Array.from({ length: skeletonRowCount }).map((_, i) => (
|
||||
@@ -245,7 +279,7 @@ export function InlineResourceContainer({
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="cursor-pointer"
|
||||
className="group cursor-pointer"
|
||||
onClick={(e) => {
|
||||
// Don't open drawer if clicking interactive elements
|
||||
// (links, buttons, checkboxes, dropdown items)
|
||||
@@ -260,7 +294,12 @@ export function InlineResourceContainer({
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={getResourceCellClassName(
|
||||
cell.column.id,
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
@@ -336,6 +375,7 @@ export function InlineResourceContainer({
|
||||
onNavigatePrev={drawer.navigatePrev}
|
||||
onNavigateNext={drawer.navigateNext}
|
||||
onMuteComplete={handleDrawerMuteComplete}
|
||||
onTriageUpdate={drawer.patchTriageUpdate}
|
||||
/>
|
||||
</FindingsSelectionContext.Provider>
|
||||
);
|
||||
|
||||
+268
-65
@@ -22,6 +22,8 @@ const {
|
||||
mockClipboardWriteText,
|
||||
mockSearchParamsState,
|
||||
mockNotificationIndicator,
|
||||
mockUpdateFindingTriage,
|
||||
mockLoadLatestFindingTriageNote,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetComplianceIcon: vi.fn((_: string) => null as string | null),
|
||||
mockGetCompliancesOverview: vi.fn(),
|
||||
@@ -29,6 +31,8 @@ const {
|
||||
mockClipboardWriteText: vi.fn(),
|
||||
mockSearchParamsState: { value: "" },
|
||||
mockNotificationIndicator: vi.fn(),
|
||||
mockUpdateFindingTriage: vi.fn(),
|
||||
mockLoadLatestFindingTriageNote: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
@@ -255,6 +259,11 @@ vi.mock("@/actions/compliances", () => ({
|
||||
getCompliancesOverview: mockGetCompliancesOverview,
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/findings", () => ({
|
||||
updateFindingTriage: mockUpdateFindingTriage,
|
||||
loadLatestFindingTriageNote: mockLoadLatestFindingTriageNote,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons", () => ({
|
||||
getComplianceIcon: mockGetComplianceIcon,
|
||||
}));
|
||||
@@ -309,8 +318,12 @@ vi.mock("@/components/ui/table", () => ({
|
||||
TableBody: ({ children }: { children: ReactNode }) => (
|
||||
<tbody>{children}</tbody>
|
||||
),
|
||||
TableCell: ({ children }: { children: ReactNode }) => <td>{children}</td>,
|
||||
TableHead: ({ children }: { children: ReactNode }) => <th>{children}</th>,
|
||||
TableCell: ({ children, ...props }: HTMLAttributes<HTMLTableCellElement>) => (
|
||||
<td {...props}>{children}</td>
|
||||
),
|
||||
TableHead: ({ children, ...props }: HTMLAttributes<HTMLTableCellElement>) => (
|
||||
<th {...props}>{children}</th>
|
||||
),
|
||||
TableHeader: ({ children }: { children: ReactNode }) => (
|
||||
<thead>{children}</thead>
|
||||
),
|
||||
@@ -360,6 +373,96 @@ vi.mock("../notification-indicator", () => ({
|
||||
DeltaValues: { NEW: "new", CHANGED: "changed", NONE: "none" } as const,
|
||||
}));
|
||||
|
||||
vi.mock("../finding-triage-cells", () => ({
|
||||
FindingNoteActionItem: ({
|
||||
triage,
|
||||
onTriageUpdateAction,
|
||||
}: {
|
||||
triage?: {
|
||||
findingId: string;
|
||||
findingUid: string;
|
||||
triageId: string | null;
|
||||
notesCount: number;
|
||||
status: string;
|
||||
label: string;
|
||||
isMuted: boolean;
|
||||
};
|
||||
onTriageUpdateAction?: (input: {
|
||||
findingId: string;
|
||||
findingUid: string;
|
||||
triageId: string | null;
|
||||
notesCount: number;
|
||||
status: string;
|
||||
previousStatus: string;
|
||||
isMuted: boolean;
|
||||
note: string;
|
||||
}) => Promise<void>;
|
||||
}) =>
|
||||
triage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onTriageUpdateAction?.({
|
||||
findingId: triage.findingId,
|
||||
findingUid: triage.findingUid,
|
||||
triageId: triage.triageId,
|
||||
notesCount: triage.notesCount,
|
||||
status: "remediating",
|
||||
previousStatus: triage.status,
|
||||
isMuted: triage.isMuted,
|
||||
note: "Investigating",
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Triage Note
|
||||
</button>
|
||||
) : null,
|
||||
FindingTriageStatusCell: ({
|
||||
triage,
|
||||
onTriageUpdateAction,
|
||||
}: {
|
||||
triage?: {
|
||||
findingId: string;
|
||||
findingUid: string;
|
||||
triageId: string | null;
|
||||
notesCount: number;
|
||||
status: string;
|
||||
label: string;
|
||||
isMuted: boolean;
|
||||
};
|
||||
onTriageUpdateAction?: (input: {
|
||||
findingId: string;
|
||||
findingUid: string;
|
||||
triageId: string | null;
|
||||
notesCount: number;
|
||||
status: string;
|
||||
previousStatus: string;
|
||||
isMuted: boolean;
|
||||
}) => Promise<void>;
|
||||
}) =>
|
||||
triage ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Triage status"
|
||||
onClick={() =>
|
||||
onTriageUpdateAction?.({
|
||||
findingId: triage.findingId,
|
||||
findingUid: triage.findingUid,
|
||||
triageId: triage.triageId,
|
||||
notesCount: triage.notesCount,
|
||||
status: "remediating",
|
||||
previousStatus: triage.status,
|
||||
isMuted: triage.isMuted,
|
||||
})
|
||||
}
|
||||
>
|
||||
{triage.label}
|
||||
</button>
|
||||
) : (
|
||||
<span>-</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./resource-detail-skeleton", () => ({
|
||||
ResourceDetailSkeleton: () => <div data-testid="skeleton" />,
|
||||
}));
|
||||
@@ -374,6 +477,10 @@ vi.mock("../../muted", () => ({
|
||||
|
||||
import type { ResourceDrawerFinding } from "@/actions/findings";
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import { ResourceDetailDrawerContent } from "./resource-detail-drawer-content";
|
||||
import type { CheckMeta } from "./use-resource-detail-drawer";
|
||||
@@ -404,6 +511,24 @@ const mockCheckMeta: CheckMeta = {
|
||||
additionalUrls: [],
|
||||
};
|
||||
|
||||
function makeTriageSummary(
|
||||
overrides?: Partial<FindingTriageSummary>,
|
||||
): FindingTriageSummary {
|
||||
return {
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-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,
|
||||
};
|
||||
}
|
||||
|
||||
const mockFinding: ResourceDrawerFinding = {
|
||||
id: "finding-1",
|
||||
uid: "uid-1",
|
||||
@@ -479,6 +604,147 @@ describe("ResourceDetailDrawerContent — resource navigation", () => {
|
||||
expect(srOnlyLabel).toHaveTextContent("View Resource");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceDetailDrawerContent — triage drawer actions", () => {
|
||||
it("should render Triage and Add Triage Note for other findings rows", () => {
|
||||
// Given
|
||||
const otherFinding: ResourceDrawerFinding = {
|
||||
...mockFinding,
|
||||
id: "finding-2",
|
||||
uid: "uid-2",
|
||||
checkId: "ec2_check",
|
||||
checkTitle: "EC2 Check",
|
||||
triage: makeTriageSummary({
|
||||
findingId: "finding-2",
|
||||
findingUid: "uid-2",
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
label: "Remediating",
|
||||
}),
|
||||
};
|
||||
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[otherFinding]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const row = screen.getByText("EC2 Check").closest("tr");
|
||||
expect(row).not.toBeNull();
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Triage")).toBeInTheDocument();
|
||||
expect(
|
||||
within(row as HTMLElement).getByRole("button", {
|
||||
name: "Triage status",
|
||||
}),
|
||||
).toHaveTextContent("Remediating");
|
||||
expect(
|
||||
within(row as HTMLElement).getByRole("button", {
|
||||
name: "Add Triage Note",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(row as HTMLElement).getByRole("button", { name: "Mute" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(row as HTMLElement).getByRole("button", { name: "Send to Jira" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should keep the other findings actions cell sticky on the right edge", () => {
|
||||
// Given
|
||||
const otherFinding: ResourceDrawerFinding = {
|
||||
...mockFinding,
|
||||
id: "finding-2",
|
||||
uid: "uid-2",
|
||||
checkId: "ec2_check",
|
||||
checkTitle: "EC2 Check",
|
||||
triage: makeTriageSummary({
|
||||
findingId: "finding-2",
|
||||
findingUid: "uid-2",
|
||||
}),
|
||||
};
|
||||
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[otherFinding]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const row = screen.getByText("EC2 Check").closest("tr");
|
||||
expect(row).not.toBeNull();
|
||||
const actionsCell = within(row as HTMLElement)
|
||||
.getByRole("button", { name: "Send to Jira" })
|
||||
.closest("td");
|
||||
|
||||
// Then
|
||||
expect(actionsCell).toHaveClass("sticky");
|
||||
expect(actionsCell).toHaveClass("right-0");
|
||||
expect(actionsCell).toHaveClass("z-20");
|
||||
expect(actionsCell).toHaveClass("bg-bg-neutral-secondary");
|
||||
expect(actionsCell).toHaveClass("before:bg-gradient-to-r");
|
||||
expect(actionsCell).toHaveClass("before:to-bg-neutral-secondary");
|
||||
});
|
||||
|
||||
it("should update simple drawer triage without using the mute refresh path", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onMuteComplete = vi.fn();
|
||||
mockUpdateFindingTriage.mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={{
|
||||
...mockFinding,
|
||||
triage: makeTriageSummary(),
|
||||
}}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={onMuteComplete}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Add Triage Note" }));
|
||||
|
||||
// Then
|
||||
expect(mockUpdateFindingTriage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
findingId: "finding-1",
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
note: "Investigating",
|
||||
}),
|
||||
);
|
||||
expect(onMuteComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
const mockResourceRow: FindingResourceRow = {
|
||||
id: "row-1",
|
||||
rowType: "resource",
|
||||
@@ -1002,69 +1268,6 @@ describe("ResourceDetailDrawerContent — Risk section styling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 4: Compliance icon styling should match master
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ResourceDetailDrawerContent — compliance icon styling", () => {
|
||||
it("should render framework icons inside the same white chip used in master", () => {
|
||||
// Given
|
||||
mockGetComplianceIcon.mockImplementation((framework: string) =>
|
||||
framework === "CIS-1.4" ? "/cis.svg" : null,
|
||||
);
|
||||
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const icon = screen.getByRole("img", { name: "CIS-1.4" });
|
||||
const chip = icon.closest("div");
|
||||
|
||||
// Then
|
||||
expect(chip).toHaveClass("bg-white");
|
||||
expect(chip).toHaveClass("border-gray-300");
|
||||
});
|
||||
|
||||
it("should render framework fallback pills with the same master styling", () => {
|
||||
// Given
|
||||
mockGetComplianceIcon.mockReturnValue(null);
|
||||
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const chip = screen.getByText("PCI-DSS");
|
||||
|
||||
// Then
|
||||
expect(chip).toHaveClass("bg-white");
|
||||
expect(chip).toHaveClass("border-gray-300");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceDetailDrawerContent — compliance navigation", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
+74
-5
@@ -16,7 +16,11 @@ import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { getCompliancesOverview } from "@/actions/compliances";
|
||||
import type { ResourceDrawerFinding } from "@/actions/findings";
|
||||
import {
|
||||
loadLatestFindingTriageNote,
|
||||
type ResourceDrawerFinding,
|
||||
updateFindingTriage,
|
||||
} from "@/actions/findings";
|
||||
import { MarkdownContainer } from "@/components/findings/markdown-container";
|
||||
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
|
||||
import { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
|
||||
@@ -69,17 +73,26 @@ import {
|
||||
} from "@/components/ui/table/status-finding-badge";
|
||||
import { getFailingForLabel } from "@/lib/date-utils";
|
||||
import { formatDuration } from "@/lib/date-utils";
|
||||
import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage";
|
||||
import { getRegionFlag } from "@/lib/region-flags";
|
||||
import { getRecommendationLinkLabel } from "@/lib/vulnerability-references";
|
||||
import type { ComplianceOverviewData } from "@/types/compliance";
|
||||
import type { FindingResourceRow } from "@/types/findings-table";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
|
||||
import { Muted } from "../../muted";
|
||||
import { DeltaIndicator } from "../delta-indicator";
|
||||
import {
|
||||
FindingNoteActionItem,
|
||||
FindingTriageStatusCell,
|
||||
} from "../finding-triage-cells";
|
||||
import { DeltaValues, NotificationIndicator } from "../notification-indicator";
|
||||
import { ResourceDetailSkeleton } from "./resource-detail-skeleton";
|
||||
import type { CheckMeta } from "./use-resource-detail-drawer";
|
||||
|
||||
const OTHER_FINDINGS_ACTION_CELL_CLASS =
|
||||
"sticky right-0 z-20 min-w-12 last:rounded-r-none! overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary";
|
||||
|
||||
/** Strip markdown code fences (```lang ... ```) so CodeSnippet shows clean code. */
|
||||
function stripCodeFences(code: string): string {
|
||||
return code
|
||||
@@ -326,6 +339,7 @@ interface ResourceDetailDrawerContentProps {
|
||||
onNavigatePrev: () => void;
|
||||
onNavigateNext: () => void;
|
||||
onMuteComplete: () => void;
|
||||
onTriageUpdate?: (input: UpdateFindingTriageInput) => void;
|
||||
}
|
||||
|
||||
export function ResourceDetailDrawerContent({
|
||||
@@ -341,6 +355,7 @@ export function ResourceDetailDrawerContent({
|
||||
onNavigatePrev,
|
||||
onNavigateNext,
|
||||
onMuteComplete,
|
||||
onTriageUpdate,
|
||||
}: ResourceDetailDrawerContentProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
@@ -410,6 +425,7 @@ export function ResourceDetailDrawerContent({
|
||||
const resourceUid = currentResource?.resourceUid ?? f?.resourceUid;
|
||||
const resourceService = currentResource?.service ?? f?.resourceService;
|
||||
const resourceRegion = currentResource?.region ?? f?.resourceRegion;
|
||||
const findingTriage = f?.triage ?? currentResource?.triage;
|
||||
const resourceRegionLabel = resourceRegion || "-";
|
||||
const firstSeenAt = currentResource?.firstSeenAt ?? f?.firstSeenAt ?? null;
|
||||
const lastSeenAt = currentResource?.lastSeenAt ?? f?.updatedAt ?? null;
|
||||
@@ -455,6 +471,16 @@ export function ResourceDetailDrawerContent({
|
||||
currentResource?.statusExtended || f?.statusExtended;
|
||||
const showOverviewStatusExtended = Boolean(overviewStatusExtended);
|
||||
|
||||
const handleDrawerTriageUpdate = async (input: UpdateFindingTriageInput) => {
|
||||
await updateFindingTriage(input);
|
||||
if (shouldRefreshAfterTriageUpdate(input)) {
|
||||
onMuteComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
onTriageUpdate?.(input);
|
||||
};
|
||||
|
||||
const handleOpenCompliance = async (framework: string) => {
|
||||
if (!complianceScanId || resolvingFramework) {
|
||||
return;
|
||||
@@ -800,6 +826,19 @@ export function ResourceDetailDrawerContent({
|
||||
variant="bordered"
|
||||
ariaLabel="Resource actions"
|
||||
>
|
||||
{findingTriage && (
|
||||
<FindingNoteActionItem
|
||||
triage={findingTriage}
|
||||
findingContext={{
|
||||
title: checkMeta.checkTitle,
|
||||
resource: resourceName,
|
||||
provider: providerAlias,
|
||||
providerType,
|
||||
}}
|
||||
onTriageUpdateAction={handleDrawerTriageUpdate}
|
||||
onTriageNoteLoadAction={loadLatestFindingTriageNote}
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
f.isMuted ? (
|
||||
@@ -1194,6 +1233,11 @@ export function ResourceDetailDrawerContent({
|
||||
Time
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
Triage
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-10" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -1213,11 +1257,12 @@ export function ResourceDetailDrawerContent({
|
||||
new Set(prev).add(finding.id),
|
||||
)
|
||||
}
|
||||
onTriageUpdateAction={handleDrawerTriageUpdate}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-16 text-center">
|
||||
<TableCell colSpan={7} className="h-16 text-center">
|
||||
<span className="text-text-neutral-tertiary text-sm">
|
||||
{showSyntheticResourceHint
|
||||
? "No other findings are available for this IaC resource."
|
||||
@@ -1403,7 +1448,10 @@ function OtherFindingsNavigationSkeletonRows() {
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-20 rounded" />
|
||||
</TableCell>
|
||||
<TableCell className="w-10">
|
||||
<TableCell>
|
||||
<Skeleton className="h-8 w-20 rounded-lg" />
|
||||
</TableCell>
|
||||
<TableCell className={OTHER_FINDINGS_ACTION_CELL_CLASS}>
|
||||
<Skeleton className="h-5 w-5 rounded" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -1495,10 +1543,12 @@ function OtherFindingRow({
|
||||
finding,
|
||||
isOptimisticallyMuted,
|
||||
onMuted,
|
||||
onTriageUpdateAction,
|
||||
}: {
|
||||
finding: ResourceDrawerFinding;
|
||||
isOptimisticallyMuted: boolean;
|
||||
onMuted: () => void;
|
||||
onTriageUpdateAction: (input: UpdateFindingTriageInput) => Promise<void>;
|
||||
}) {
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
|
||||
@@ -1526,7 +1576,7 @@ function OtherFindingRow({
|
||||
findingTitle={finding.checkTitle}
|
||||
/>
|
||||
<TableRow
|
||||
className="cursor-pointer"
|
||||
className="group cursor-pointer"
|
||||
onClick={() => window.open(findingUrl, "_blank", "noopener,noreferrer")}
|
||||
>
|
||||
<TableCell className="w-14">
|
||||
@@ -1559,9 +1609,28 @@ function OtherFindingRow({
|
||||
<TableCell>
|
||||
<DateWithTime dateTime={finding.updatedAt} />
|
||||
</TableCell>
|
||||
<TableCell className="w-10">
|
||||
<TableCell>
|
||||
<FindingTriageStatusCell
|
||||
triage={finding.triage}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className={OTHER_FINDINGS_ACTION_CELL_CLASS}>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ActionDropdown ariaLabel="Finding actions">
|
||||
{finding.triage && (
|
||||
<FindingNoteActionItem
|
||||
triage={finding.triage}
|
||||
findingContext={{
|
||||
title: finding.checkTitle,
|
||||
resource: finding.resourceName,
|
||||
provider: finding.providerAlias,
|
||||
providerType: finding.providerType,
|
||||
}}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageNoteLoadAction={loadLatestFindingTriageNote}
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
isMuted ? (
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DrawerTitle,
|
||||
} from "@/components/shadcn";
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
|
||||
import { ResourceDetailDrawerContent } from "./resource-detail-drawer-content";
|
||||
import type { CheckMeta } from "./use-resource-detail-drawer";
|
||||
@@ -31,6 +32,7 @@ interface ResourceDetailDrawerProps {
|
||||
onNavigatePrev: () => void;
|
||||
onNavigateNext: () => void;
|
||||
onMuteComplete: () => void;
|
||||
onTriageUpdate?: (input: UpdateFindingTriageInput) => void;
|
||||
}
|
||||
|
||||
export function ResourceDetailDrawer({
|
||||
@@ -48,6 +50,7 @@ export function ResourceDetailDrawer({
|
||||
onNavigatePrev,
|
||||
onNavigateNext,
|
||||
onMuteComplete,
|
||||
onTriageUpdate,
|
||||
}: ResourceDetailDrawerProps) {
|
||||
return (
|
||||
<Drawer direction="right" open={open} onOpenChange={onOpenChange}>
|
||||
@@ -76,6 +79,7 @@ export function ResourceDetailDrawer({
|
||||
onNavigatePrev={onNavigatePrev}
|
||||
onNavigateNext={onNavigateNext}
|
||||
onMuteComplete={onMuteComplete}
|
||||
onTriageUpdate={onTriageUpdate}
|
||||
/>
|
||||
)}
|
||||
</DrawerContent>
|
||||
|
||||
+100
@@ -31,6 +31,10 @@ vi.mock("next/navigation", () => ({
|
||||
|
||||
import type { ResourceDrawerFinding } from "@/actions/findings";
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import { useResourceDetailDrawer } from "./use-resource-detail-drawer";
|
||||
|
||||
@@ -106,6 +110,24 @@ function makeDrawerFinding(
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 2: AbortController cleanup on unmount
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -811,4 +833,82 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
|
||||
"finding-4",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should patch current and other finding triage locally without refetching", async () => {
|
||||
const resources = [makeResource()];
|
||||
|
||||
// Given
|
||||
getFindingByIdMock.mockResolvedValue({ data: ["detail"] });
|
||||
getLatestFindingsByResourceUidMock.mockResolvedValue({
|
||||
data: ["resource"],
|
||||
});
|
||||
adaptFindingsByResourceResponseMock.mockImplementation(
|
||||
(response: { data: string[] }) =>
|
||||
response.data[0] === "detail"
|
||||
? [
|
||||
makeDrawerFinding({
|
||||
id: "finding-1",
|
||||
triage: makeTriageSummary(),
|
||||
}),
|
||||
]
|
||||
: [
|
||||
makeDrawerFinding({
|
||||
id: "finding-2",
|
||||
uid: "uid-2",
|
||||
triage: makeTriageSummary({
|
||||
findingId: "finding-2",
|
||||
findingUid: "uid-2",
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResourceDetailDrawer({
|
||||
resources,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.openDrawer(0);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const findingFetchCount = getFindingByIdMock.mock.calls.length;
|
||||
const resourceFetchCount =
|
||||
getLatestFindingsByResourceUidMock.mock.calls.length;
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.patchTriageUpdate({
|
||||
findingId: "finding-2",
|
||||
findingUid: "uid-2",
|
||||
triageId: "triage-2",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
|
||||
isMuted: false,
|
||||
note: "Investigating",
|
||||
});
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.otherFindings[0]?.triage).toEqual(
|
||||
expect.objectContaining({
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
label: "Remediating",
|
||||
hasVisibleNote: true,
|
||||
notesCount: 1,
|
||||
}),
|
||||
);
|
||||
expect(result.current.currentFinding?.triage?.status).toBe(
|
||||
FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
);
|
||||
expect(getFindingByIdMock).toHaveBeenCalledTimes(findingFetchCount);
|
||||
expect(getLatestFindingsByResourceUidMock).toHaveBeenCalledTimes(
|
||||
resourceFetchCount,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,13 @@ import {
|
||||
getLatestFindingsByResourceUid,
|
||||
type ResourceDrawerFinding,
|
||||
} from "@/actions/findings";
|
||||
import {
|
||||
applyOptimisticTriageSummaryUpdate,
|
||||
getOptimisticTriageMutedReason,
|
||||
shouldMarkFindingMutedForTriageUpdate,
|
||||
} from "@/lib/finding-triage";
|
||||
import { FindingResourceRow } from "@/types";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
|
||||
// Keep fast carousel navigations in a loading state for one short beat so
|
||||
// React doesn't batch away the skeleton frame when switching resources.
|
||||
@@ -67,6 +73,8 @@ interface UseResourceDetailDrawerReturn {
|
||||
navigateNext: () => void;
|
||||
/** Clear cache for current resource and re-fetch (e.g. after muting). */
|
||||
refetchCurrent: () => void;
|
||||
/** Patch triage state locally after a successful lightweight triage update. */
|
||||
patchTriageUpdate: (input: UpdateFindingTriageInput) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -287,6 +295,62 @@ export function useResourceDetailDrawer({
|
||||
fetchFindings(resource);
|
||||
};
|
||||
|
||||
const patchFindingTriage = (
|
||||
finding: ResourceDrawerFinding | null,
|
||||
input: UpdateFindingTriageInput,
|
||||
): ResourceDrawerFinding | null => {
|
||||
if (!finding?.triage || finding.triage.findingId !== input.findingId) {
|
||||
return finding;
|
||||
}
|
||||
|
||||
const shouldMarkMuted = shouldMarkFindingMutedForTriageUpdate(input);
|
||||
|
||||
return {
|
||||
...finding,
|
||||
isMuted: shouldMarkMuted ? true : finding.isMuted,
|
||||
mutedReason:
|
||||
shouldMarkMuted && input.isMuted !== true && input.status
|
||||
? getOptimisticTriageMutedReason(input.status)
|
||||
: finding.mutedReason,
|
||||
triage: applyOptimisticTriageSummaryUpdate(finding.triage, input),
|
||||
};
|
||||
};
|
||||
|
||||
const patchTriageUpdate = (input: UpdateFindingTriageInput) => {
|
||||
currentFindingCacheRef.current.forEach((finding, key) => {
|
||||
const patchedFinding = patchFindingTriage(finding, input);
|
||||
if (patchedFinding !== finding) {
|
||||
currentFindingCacheRef.current.set(key, patchedFinding);
|
||||
}
|
||||
});
|
||||
|
||||
otherFindingsCacheRef.current.forEach((findings, key) => {
|
||||
const patchedFindings = findings.map((finding) =>
|
||||
patchFindingTriage(finding, input),
|
||||
);
|
||||
|
||||
if (
|
||||
patchedFindings.some((finding, index) => finding !== findings[index])
|
||||
) {
|
||||
otherFindingsCacheRef.current.set(
|
||||
key,
|
||||
patchedFindings.filter(
|
||||
(finding): finding is ResourceDrawerFinding => finding !== null,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
setCurrentFinding((finding) => patchFindingTriage(finding, input));
|
||||
setOtherFindings((findings) =>
|
||||
findings
|
||||
.map((finding) => patchFindingTriage(finding, input))
|
||||
.filter(
|
||||
(finding): finding is ResourceDrawerFinding => finding !== null,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const navigateTo = (index: number) => {
|
||||
const resource = resources[index];
|
||||
if (!resource) return;
|
||||
@@ -335,5 +399,6 @@ export function useResourceDetailDrawer({
|
||||
navigatePrev,
|
||||
navigateNext,
|
||||
refetchCurrent,
|
||||
patchTriageUpdate,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
PROVIDERS_ROW_TYPE,
|
||||
type ProvidersTableRow,
|
||||
} from "@/types/providers-table";
|
||||
import {
|
||||
SCAN_CONFIGURATION_LIST_STATUS,
|
||||
type ScanConfigurationData,
|
||||
} from "@/types/scan-configurations";
|
||||
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
|
||||
|
||||
const { dataTableMockState, getColumnProvidersMock } = vi.hoisted(() => ({
|
||||
@@ -40,6 +44,7 @@ vi.mock("./table", () => ({
|
||||
|
||||
import {
|
||||
computeSelectedScheduleProviders,
|
||||
createScanConfigIdByProviderId,
|
||||
ProvidersAccountsTable,
|
||||
} from "./providers-accounts-table";
|
||||
|
||||
@@ -90,6 +95,17 @@ const createProviderRow = (
|
||||
const providerOne = createProviderRow("provider-1", "111111111111", "Prod");
|
||||
const providerTwo = createProviderRow("provider-2", "222222222222", "Stage");
|
||||
const providerThree = createProviderRow("provider-3", "333333333333", "Dev");
|
||||
const scanConfig: ScanConfigurationData = {
|
||||
type: "scan-configurations",
|
||||
id: "config-1",
|
||||
attributes: {
|
||||
inserted_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
name: "Strict AWS",
|
||||
configuration: {},
|
||||
providers: ["provider-1"],
|
||||
},
|
||||
};
|
||||
|
||||
const organizationRow: ProvidersTableRow = {
|
||||
id: "org-1",
|
||||
@@ -147,9 +163,44 @@ describe("ProvidersAccountsTable", () => {
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY,
|
||||
[],
|
||||
SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
|
||||
expect.any(Map),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes populated scan configs to provider row action columns", () => {
|
||||
// Given/When
|
||||
render(
|
||||
<ProvidersAccountsTable
|
||||
isCloud
|
||||
metadata={metadata}
|
||||
rows={[]}
|
||||
scanConfigs={[scanConfig]}
|
||||
scanScheduleCapability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
|
||||
onOpenProviderWizard={vi.fn()}
|
||||
onOpenOrganizationWizard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const call = getColumnProvidersMock.mock.calls.at(-1);
|
||||
expect(call?.[8]).toEqual([scanConfig]);
|
||||
expect(call?.[9]).toBe(SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE);
|
||||
expect(call?.[10]).toBeInstanceOf(Map);
|
||||
expect((call?.[10] as Map<string, string>).get("provider-1")).toBe(
|
||||
"config-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("precomputes scan config ids by provider id once for row actions", () => {
|
||||
// Given/When
|
||||
const lookup = createScanConfigIdByProviderId([scanConfig]);
|
||||
|
||||
// Then
|
||||
expect(lookup.get("provider-1")).toBe("config-1");
|
||||
});
|
||||
|
||||
describe("schedule provider selection", () => {
|
||||
it("uses the selected provider id for provider rows", () => {
|
||||
// Given
|
||||
@@ -268,6 +319,9 @@ describe("ProvidersAccountsTable", () => {
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
SCAN_SCHEDULE_CAPABILITY.ADVANCED,
|
||||
[],
|
||||
SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
|
||||
expect.any(Map),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -301,6 +355,9 @@ describe("ProvidersAccountsTable", () => {
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
SCAN_SCHEDULE_CAPABILITY.ADVANCED,
|
||||
[],
|
||||
SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
|
||||
expect.any(Map),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
isProvidersProviderRow,
|
||||
ProvidersTableRow,
|
||||
} from "@/types/providers-table";
|
||||
import {
|
||||
SCAN_CONFIGURATION_LIST_STATUS,
|
||||
ScanConfigurationData,
|
||||
type ScanConfigurationListStatus,
|
||||
} from "@/types/scan-configurations";
|
||||
import type {
|
||||
ScanScheduleCapability,
|
||||
ScanScheduleProvider,
|
||||
@@ -26,6 +31,10 @@ interface ProvidersAccountsTableProps {
|
||||
metadata?: MetaDataProps;
|
||||
rows: ProvidersTableRow[];
|
||||
scanScheduleCapability?: ScanScheduleCapability;
|
||||
/** All scan configurations in the tenant, for the provider row's associate/
|
||||
* disassociate action (Cloud-only). */
|
||||
scanConfigs?: ScanConfigurationData[];
|
||||
scanConfigStatus?: ScanConfigurationListStatus;
|
||||
onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void;
|
||||
onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void;
|
||||
}
|
||||
@@ -154,11 +163,27 @@ export function computeSelectedScheduleProviders(
|
||||
return { providerIds, providers };
|
||||
}
|
||||
|
||||
export function createScanConfigIdByProviderId(
|
||||
scanConfigs: ScanConfigurationData[],
|
||||
): Map<string, string> {
|
||||
const lookup = new Map<string, string>();
|
||||
|
||||
for (const config of scanConfigs) {
|
||||
for (const providerId of config.attributes.providers) {
|
||||
lookup.set(providerId, config.id);
|
||||
}
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function ProvidersAccountsTableContent({
|
||||
isCloud,
|
||||
metadata,
|
||||
rows,
|
||||
scanScheduleCapability,
|
||||
scanConfigs,
|
||||
scanConfigStatus = SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
|
||||
onOpenProviderWizard,
|
||||
onOpenOrganizationWizard,
|
||||
}: ProvidersAccountsTableProps) {
|
||||
@@ -170,6 +195,9 @@ function ProvidersAccountsTableContent({
|
||||
rowSelection,
|
||||
);
|
||||
const selectedScheduleProviderIds = selectedScheduleProviders.providerIds;
|
||||
const scanConfigIdByProviderId = createScanConfigIdByProviderId(
|
||||
scanConfigs ?? [],
|
||||
);
|
||||
|
||||
const clearSelection = () => setRowSelection({});
|
||||
|
||||
@@ -182,6 +210,9 @@ function ProvidersAccountsTableContent({
|
||||
onOpenProviderWizard,
|
||||
onOpenOrganizationWizard,
|
||||
scanScheduleCapability,
|
||||
scanConfigs ?? [],
|
||||
scanConfigStatus,
|
||||
scanConfigIdByProviderId,
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
|
||||
import type { ProviderGroup } from "@/types/components";
|
||||
import type { ProvidersTableRow } from "@/types/providers-table";
|
||||
import type {
|
||||
ScanConfigurationData,
|
||||
ScanConfigurationListStatus,
|
||||
} from "@/types/scan-configurations";
|
||||
import type { ScanScheduleCapability } from "@/types/schedules";
|
||||
|
||||
const addProviderFlow = getFlowById("add-provider")!;
|
||||
@@ -56,6 +60,10 @@ interface ProvidersAccountsViewProps {
|
||||
rows: ProvidersTableRow[];
|
||||
/** Cloud overlay seam for provider-creation scan launch. */
|
||||
scanScheduleCapability?: ScanScheduleCapability;
|
||||
/** All scan configurations in the tenant, for the provider row's associate/
|
||||
* disassociate action (Cloud-only). */
|
||||
scanConfigs?: ScanConfigurationData[];
|
||||
scanConfigStatus?: ScanConfigurationListStatus;
|
||||
isScanLimitReached?: boolean;
|
||||
}
|
||||
|
||||
@@ -67,6 +75,8 @@ export function ProvidersAccountsView({
|
||||
providerGroups = [],
|
||||
rows,
|
||||
scanScheduleCapability,
|
||||
scanConfigs,
|
||||
scanConfigStatus,
|
||||
isScanLimitReached,
|
||||
}: ProvidersAccountsViewProps) {
|
||||
const pathname = usePathname();
|
||||
@@ -157,6 +167,8 @@ export function ProvidersAccountsView({
|
||||
metadata={metadata}
|
||||
rows={rows}
|
||||
scanScheduleCapability={scanScheduleCapability}
|
||||
scanConfigs={scanConfigs}
|
||||
scanConfigStatus={scanConfigStatus}
|
||||
onOpenProviderWizard={openProviderWizard}
|
||||
onOpenOrganizationWizard={openOrganizationWizard}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ScanConfigurationData } from "@/types/scan-configurations";
|
||||
|
||||
import { ManageScanConfigModal } from "./manage-scan-config-modal";
|
||||
|
||||
const { setScanConfigurationProvidersMock, toastMock } = vi.hoisted(() => ({
|
||||
setScanConfigurationProvidersMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/scan-configurations", () => ({
|
||||
setScanConfigurationProviders: setScanConfigurationProvidersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
useToast: () => ({ toast: toastMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/custom/custom-link", () => ({
|
||||
CustomLink: ({ children }: { children: React.ReactNode }) => (
|
||||
<span>{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// Radix Select relies on pointer-capture and scrollIntoView, which jsdom does
|
||||
// not implement. Polyfill them so the dropdown can open in tests.
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => false),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "setPointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
const makeConfig = (
|
||||
id: string,
|
||||
name: string,
|
||||
providers: string[],
|
||||
): ScanConfigurationData => ({
|
||||
type: "scan-configurations",
|
||||
id,
|
||||
attributes: {
|
||||
inserted_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
name,
|
||||
configuration: {},
|
||||
providers,
|
||||
},
|
||||
});
|
||||
|
||||
const renderModal = (
|
||||
overrides: Partial<React.ComponentProps<typeof ManageScanConfigModal>> = {},
|
||||
) => {
|
||||
const onOpenChange = vi.fn();
|
||||
const onSaved = vi.fn();
|
||||
const props: React.ComponentProps<typeof ManageScanConfigModal> = {
|
||||
open: true,
|
||||
onOpenChange,
|
||||
providerId: "provider-1",
|
||||
providerLabel: "AWS App Account",
|
||||
scanConfigs: [
|
||||
makeConfig("config-a", "Config A", []),
|
||||
makeConfig("config-b", "Config B", []),
|
||||
],
|
||||
currentConfigId: null,
|
||||
onSaved,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
render(<ManageScanConfigModal {...props} />);
|
||||
return { onOpenChange, onSaved, props };
|
||||
};
|
||||
|
||||
const openSelectAndChoose = async (
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
optionName: RegExp,
|
||||
) => {
|
||||
await user.click(
|
||||
screen.getByRole("combobox", { name: /scan configuration/i }),
|
||||
);
|
||||
await user.click(await screen.findByRole("option", { name: optionName }));
|
||||
};
|
||||
|
||||
const clickSave = (user: ReturnType<typeof userEvent.setup>) =>
|
||||
user.click(screen.getByRole("button", { name: /^save$/i }));
|
||||
|
||||
describe("ManageScanConfigModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setScanConfigurationProvidersMock.mockResolvedValue({
|
||||
success: "Scan Configuration updated successfully!",
|
||||
});
|
||||
});
|
||||
|
||||
it("has an accessible label on the configuration select", () => {
|
||||
renderModal();
|
||||
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: /scan configuration/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("attaches the provider to the chosen configuration", async () => {
|
||||
// Given an unattached provider.
|
||||
const user = userEvent.setup();
|
||||
const { onOpenChange, onSaved } = renderModal({ currentConfigId: null });
|
||||
|
||||
// When the user picks "Config A" and saves.
|
||||
await openSelectAndChoose(user, /^config a$/i);
|
||||
await clickSave(user);
|
||||
|
||||
// Then the provider is added to that config's provider list.
|
||||
await waitFor(() =>
|
||||
expect(setScanConfigurationProvidersMock).toHaveBeenCalledWith(
|
||||
"config-a",
|
||||
["provider-1"],
|
||||
),
|
||||
);
|
||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("detaches the provider when Default is selected", async () => {
|
||||
// Given a provider currently attached to Config A (alongside another).
|
||||
const user = userEvent.setup();
|
||||
const { onOpenChange, onSaved } = renderModal({
|
||||
currentConfigId: "config-a",
|
||||
scanConfigs: [
|
||||
makeConfig("config-a", "Config A", ["provider-1", "provider-2"]),
|
||||
makeConfig("config-b", "Config B", []),
|
||||
],
|
||||
});
|
||||
|
||||
// When the user switches back to Default and saves.
|
||||
await openSelectAndChoose(user, /^default$/i);
|
||||
await clickSave(user);
|
||||
|
||||
// Then only this provider is dropped — the rest stay attached.
|
||||
await waitFor(() =>
|
||||
expect(setScanConfigurationProvidersMock).toHaveBeenCalledWith(
|
||||
"config-a",
|
||||
["provider-2"],
|
||||
),
|
||||
);
|
||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("moves the provider to another configuration", async () => {
|
||||
// Given a provider attached to Config A.
|
||||
const user = userEvent.setup();
|
||||
renderModal({
|
||||
currentConfigId: "config-a",
|
||||
scanConfigs: [
|
||||
makeConfig("config-a", "Config A", ["provider-1"]),
|
||||
makeConfig("config-b", "Config B", []),
|
||||
],
|
||||
});
|
||||
|
||||
// When the user picks Config B and saves.
|
||||
await openSelectAndChoose(user, /^config b$/i);
|
||||
await clickSave(user);
|
||||
|
||||
// Then the provider is attached to Config B (the backend detaches it from A).
|
||||
await waitFor(() =>
|
||||
expect(setScanConfigurationProvidersMock).toHaveBeenCalledWith(
|
||||
"config-b",
|
||||
["provider-1"],
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not call the action when the selection is unchanged", async () => {
|
||||
// Given a provider already attached to Config A.
|
||||
const user = userEvent.setup();
|
||||
const { onOpenChange } = renderModal({
|
||||
currentConfigId: "config-a",
|
||||
scanConfigs: [makeConfig("config-a", "Config A", ["provider-1"])],
|
||||
});
|
||||
|
||||
// When the user saves without changing the selection.
|
||||
await clickSave(user);
|
||||
|
||||
// Then no request is sent, and the modal just closes.
|
||||
expect(setScanConfigurationProvidersMock).not.toHaveBeenCalled();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("surfaces a destructive toast and keeps the modal open on failure", async () => {
|
||||
// Given the action returns a field error.
|
||||
const user = userEvent.setup();
|
||||
setScanConfigurationProvidersMock.mockResolvedValue({
|
||||
errors: { general: "Boom" },
|
||||
});
|
||||
const { onOpenChange, onSaved } = renderModal({ currentConfigId: null });
|
||||
|
||||
// When the user attaches and saves.
|
||||
await openSelectAndChoose(user, /^config a$/i);
|
||||
await clickSave(user);
|
||||
|
||||
// Then the error is toasted and the modal stays open for a retry.
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variant: "destructive",
|
||||
description: "Boom",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resets a cancelled selection when the modal is reopened", async () => {
|
||||
// Given a provider with no attached config.
|
||||
const user = userEvent.setup();
|
||||
const baseProps: React.ComponentProps<typeof ManageScanConfigModal> = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
providerId: "provider-1",
|
||||
providerLabel: "AWS App Account",
|
||||
scanConfigs: [
|
||||
makeConfig("config-a", "Config A", []),
|
||||
makeConfig("config-b", "Config B", []),
|
||||
],
|
||||
currentConfigId: null,
|
||||
onSaved: vi.fn(),
|
||||
};
|
||||
const { rerender } = render(<ManageScanConfigModal {...baseProps} />);
|
||||
|
||||
// When the user picks Config A but closes the modal without saving.
|
||||
await openSelectAndChoose(user, /^config a$/i);
|
||||
rerender(<ManageScanConfigModal {...baseProps} open={false} />);
|
||||
|
||||
// And reopens it for the same (still unattached) provider.
|
||||
rerender(<ManageScanConfigModal {...baseProps} open />);
|
||||
|
||||
// Then the selection falls back to Default — the stale choice is gone, so
|
||||
// saving without touching the dropdown sends nothing.
|
||||
await clickSave(user);
|
||||
expect(setScanConfigurationProvidersMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { setScanConfigurationProviders } from "@/actions/scan-configurations";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn/select/select";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { ScanConfigurationData } from "@/types/scan-configurations";
|
||||
|
||||
// Sentinel for the "Default" option: detaches the provider so its scans fall
|
||||
// back to Prowler's built-in SDK defaults. Select values must be non-empty
|
||||
// strings, so we can't use "".
|
||||
const DEFAULT_VALUE = "__default__";
|
||||
|
||||
interface ManageScanConfigModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
providerId: string;
|
||||
providerLabel: string;
|
||||
scanConfigs: ScanConfigurationData[];
|
||||
/** The config this provider is currently attached to, if any. */
|
||||
currentConfigId: string | null;
|
||||
/** Called after a successful associate/disassociate so the parent can refresh. */
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
type ManageScanConfigFormProps = Omit<ManageScanConfigModalProps, "open">;
|
||||
|
||||
export function ManageScanConfigModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
...formProps
|
||||
}: ManageScanConfigModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Scan Configuration"
|
||||
size="md"
|
||||
>
|
||||
{/* Only mount the form while the modal is open so a fresh instance is
|
||||
created on every reopen — its selection always initializes from the
|
||||
provider's current config, never from a stale, cancelled selection.
|
||||
The key resets it again if the attached config changes mid-open. */}
|
||||
{open && (
|
||||
<ManageScanConfigForm
|
||||
key={`${formProps.providerId}:${formProps.currentConfigId ?? DEFAULT_VALUE}`}
|
||||
onOpenChange={onOpenChange}
|
||||
{...formProps}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ManageScanConfigForm({
|
||||
onOpenChange,
|
||||
providerId,
|
||||
providerLabel,
|
||||
scanConfigs,
|
||||
currentConfigId,
|
||||
onSaved,
|
||||
}: ManageScanConfigFormProps) {
|
||||
const { toast } = useToast();
|
||||
const [selected, setSelected] = useState<string>(
|
||||
currentConfigId ?? DEFAULT_VALUE,
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
// No change — nothing to do.
|
||||
if (selected === (currentConfigId ?? DEFAULT_VALUE)) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reportError = (description: string) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description,
|
||||
});
|
||||
};
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
let result;
|
||||
if (selected === DEFAULT_VALUE) {
|
||||
// Detach: drop this provider from its current config.
|
||||
if (!currentConfigId) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
const current = scanConfigs.find((c) => c.id === currentConfigId);
|
||||
// Bail if we don't have the current config loaded: sending a full
|
||||
// provider_ids replacement off a synthetic empty list would clear every
|
||||
// other provider attached to this configuration.
|
||||
if (!current) {
|
||||
reportError(
|
||||
"This scan configuration is no longer available. Refresh and try again.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const next = current.attributes.providers.filter(
|
||||
(id) => id !== providerId,
|
||||
);
|
||||
result = await setScanConfigurationProviders(currentConfigId, next);
|
||||
} else {
|
||||
// Attach: add this provider to the chosen config. The backend moves it
|
||||
// off any other config automatically (one config per provider).
|
||||
const target = scanConfigs.find((c) => c.id === selected);
|
||||
// Same guard as the detach path: never replace provider_ids based on a
|
||||
// config we don't actually have.
|
||||
if (!target) {
|
||||
reportError(
|
||||
"This scan configuration is no longer available. Refresh and try again.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const next = Array.from(
|
||||
new Set([...target.attributes.providers, providerId]),
|
||||
);
|
||||
result = await setScanConfigurationProviders(selected, next);
|
||||
}
|
||||
|
||||
if (result?.success) {
|
||||
toast({
|
||||
title: "Scan Configuration updated",
|
||||
description: result.success,
|
||||
});
|
||||
onSaved();
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
reportError(
|
||||
result?.errors?.general ||
|
||||
result?.errors?.provider_ids ||
|
||||
"Failed to update the Scan Configuration. Please try again.",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// An invocation-level failure (transport/framework) rejects instead of
|
||||
// returning an error object — surface it instead of failing silently.
|
||||
reportError("Failed to update the Scan Configuration. Please try again.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-default-500 text-tiny">
|
||||
Choose the scan configuration to apply to{" "}
|
||||
<strong>{providerLabel}</strong> on its next scan, or leave default. To
|
||||
create or edit configurations, go to{" "}
|
||||
<CustomLink size="xs" href="/scans/config" target="_self">
|
||||
Scan Config
|
||||
</CustomLink>
|
||||
.
|
||||
</p>
|
||||
|
||||
{/* Always show the dropdown with Default — even with no custom configs,
|
||||
the provider can fall back to Prowler's SDK defaults. */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<Select value={selected} onValueChange={setSelected}>
|
||||
<SelectTrigger aria-label="Scan configuration">
|
||||
<SelectValue placeholder="Default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={DEFAULT_VALUE}>Default</SelectItem>
|
||||
{scanConfigs.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.attributes.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-default-500 text-tiny">
|
||||
<strong>Default</strong>
|
||||
{
|
||||
" uses Prowler's scan configuration baseline. Read more about it in the "
|
||||
}
|
||||
<CustomLink
|
||||
size="xs"
|
||||
href="https://docs.prowler.com/user-guide/tutorials/prowler-app-scan-configuration"
|
||||
>
|
||||
documentation
|
||||
</CustomLink>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
ProvidersProviderRow,
|
||||
ProvidersTableRow,
|
||||
} from "@/types/providers-table";
|
||||
import {
|
||||
SCAN_CONFIGURATION_LIST_STATUS,
|
||||
ScanConfigurationData,
|
||||
type ScanConfigurationListStatus,
|
||||
} from "@/types/scan-configurations";
|
||||
import type {
|
||||
ScanScheduleCapability,
|
||||
ScanScheduleProvider,
|
||||
@@ -113,6 +118,9 @@ export function getColumnProviders(
|
||||
onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void,
|
||||
onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void,
|
||||
scanScheduleCapability?: ScanScheduleCapability,
|
||||
scanConfigs: ScanConfigurationData[] = [],
|
||||
scanConfigStatus: ScanConfigurationListStatus = SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
|
||||
scanConfigIdByProviderId: ReadonlyMap<string, string> = new Map(),
|
||||
): ColumnDef<ProvidersTableRow>[] {
|
||||
return [
|
||||
{
|
||||
@@ -315,6 +323,9 @@ export function getColumnProviders(
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const hasSelection = Object.values(rowSelection).some(Boolean);
|
||||
const currentScanConfigId = isProvidersOrganizationRow(row.original)
|
||||
? null
|
||||
: (scanConfigIdByProviderId.get(row.original.id) ?? null);
|
||||
|
||||
return (
|
||||
<DataTableRowActions
|
||||
@@ -327,6 +338,9 @@ export function getColumnProviders(
|
||||
onClearSelection={onClearSelection}
|
||||
onOpenProviderWizard={onOpenProviderWizard}
|
||||
onOpenOrganizationWizard={onOpenOrganizationWizard}
|
||||
scanConfigs={scanConfigs}
|
||||
scanConfigStatus={scanConfigStatus}
|
||||
currentScanConfigId={currentScanConfigId}
|
||||
capability={scanScheduleCapability}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PROVIDERS_ROW_TYPE,
|
||||
ProvidersTableRow,
|
||||
} from "@/types/providers-table";
|
||||
import type { ScanConfigurationData } from "@/types/scan-configurations";
|
||||
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
|
||||
|
||||
const { checkConnectionProviderMock, getScheduleMock, pushMock } = vi.hoisted(
|
||||
@@ -47,6 +48,22 @@ vi.mock("../forms/edit-name-form", () => ({
|
||||
EditNameForm: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../scan-config/manage-scan-config-modal", () => ({
|
||||
ManageScanConfigModal: ({
|
||||
open,
|
||||
currentConfigId,
|
||||
}: {
|
||||
open: boolean;
|
||||
currentConfigId: string | null;
|
||||
}) =>
|
||||
open ? (
|
||||
<div
|
||||
data-testid="manage-scan-config-modal"
|
||||
data-current-config-id={currentConfigId ?? ""}
|
||||
/>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/scans/schedule/edit-scan-schedule-modal", () => ({
|
||||
EDIT_SCAN_SCHEDULE_STATE: {
|
||||
LOADING: "loading",
|
||||
@@ -182,6 +199,18 @@ const createOuRow = () =>
|
||||
},
|
||||
}) as unknown as Row<ProvidersTableRow>;
|
||||
|
||||
const scanConfig: ScanConfigurationData = {
|
||||
type: "scan-configurations",
|
||||
id: "config-1",
|
||||
attributes: {
|
||||
inserted_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
name: "Strict AWS",
|
||||
configuration: {},
|
||||
providers: ["provider-1"],
|
||||
},
|
||||
};
|
||||
|
||||
describe("DataTableRowActions", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
@@ -361,6 +390,64 @@ describe("DataTableRowActions", () => {
|
||||
expect(screen.queryByText("Edit Scan Schedule")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens scan config management with the precomputed current config id", async () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createRow(true)}
|
||||
hasSelection={false}
|
||||
isRowSelected={false}
|
||||
testableProviderIds={[]}
|
||||
onClearSelection={vi.fn()}
|
||||
onOpenProviderWizard={vi.fn()}
|
||||
onOpenOrganizationWizard={vi.fn()}
|
||||
scanConfigs={[scanConfig]}
|
||||
currentScanConfigId="config-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button"));
|
||||
await user.click(screen.getByText("Edit Scan Configuration"));
|
||||
|
||||
// Then
|
||||
expect(screen.getByTestId("manage-scan-config-modal")).toHaveAttribute(
|
||||
"data-current-config-id",
|
||||
"config-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows scan config management as unavailable when scan configs failed to load", async () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createRow(true)}
|
||||
hasSelection={false}
|
||||
isRowSelected={false}
|
||||
testableProviderIds={[]}
|
||||
onClearSelection={vi.fn()}
|
||||
onOpenProviderWizard={vi.fn()}
|
||||
onOpenOrganizationWizard={vi.fn()}
|
||||
scanConfigStatus="unavailable"
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
// Then
|
||||
const item = screen
|
||||
.getByText("Scan Configuration unavailable")
|
||||
.closest("[role='menuitem']");
|
||||
expect(item).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("renders Update Credentials for provider rows with credentials", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
KeyRound,
|
||||
Pencil,
|
||||
Rocket,
|
||||
SlidersHorizontal,
|
||||
Timer,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
@@ -45,6 +46,11 @@ import {
|
||||
ProvidersOrganizationRow,
|
||||
ProvidersTableRow,
|
||||
} from "@/types/providers-table";
|
||||
import {
|
||||
SCAN_CONFIGURATION_LIST_STATUS,
|
||||
ScanConfigurationData,
|
||||
type ScanConfigurationListStatus,
|
||||
} from "@/types/scan-configurations";
|
||||
import {
|
||||
SCAN_SCHEDULE_CAPABILITY,
|
||||
type ScanScheduleCapability,
|
||||
@@ -55,6 +61,7 @@ import {
|
||||
import { DeleteForm } from "../forms/delete-form";
|
||||
import { DeleteOrganizationForm } from "../forms/delete-organization-form";
|
||||
import { EditNameForm } from "../forms/edit-name-form";
|
||||
import { ManageScanConfigModal } from "../scan-config/manage-scan-config-modal";
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<ProvidersTableRow>;
|
||||
@@ -72,6 +79,13 @@ interface DataTableRowActionsProps {
|
||||
onClearSelection: () => void;
|
||||
onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void;
|
||||
onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void;
|
||||
/**
|
||||
* All scan configurations in the tenant, used to associate/disassociate this
|
||||
* provider's config from the row menu (Cloud-only feature). Empty in OSS.
|
||||
*/
|
||||
scanConfigs?: ScanConfigurationData[];
|
||||
scanConfigStatus?: ScanConfigurationListStatus;
|
||||
currentScanConfigId?: string | null;
|
||||
/**
|
||||
* Schedule capability override. Absent in OSS (defaults to a Cloud-vs-non-Cloud
|
||||
* decision). The prowler-cloud overlay injects a billing-aware capability so
|
||||
@@ -271,6 +285,9 @@ export function DataTableRowActions({
|
||||
onClearSelection,
|
||||
onOpenProviderWizard,
|
||||
onOpenOrganizationWizard,
|
||||
scanConfigs = [],
|
||||
scanConfigStatus = SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
|
||||
currentScanConfigId = null,
|
||||
capability,
|
||||
}: DataTableRowActionsProps) {
|
||||
const canEditSchedule =
|
||||
@@ -282,6 +299,7 @@ export function DataTableRowActions({
|
||||
kind: EDIT_SCAN_SCHEDULE_STATE.LOADING,
|
||||
});
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [isScanConfigOpen, setIsScanConfigOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
@@ -295,6 +313,10 @@ export function DataTableRowActions({
|
||||
const providerAlias = provider?.attributes.alias ?? null;
|
||||
const providerSecretId = provider?.relationships.secret.data?.id ?? null;
|
||||
const hasSecret = Boolean(provider?.relationships.secret.data);
|
||||
const isCloudProvider = isCloud() && Boolean(provider);
|
||||
const canManageScanConfig =
|
||||
isCloudProvider &&
|
||||
scanConfigStatus === SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE;
|
||||
const scheduleProvider: ScanScheduleProvider | undefined = provider
|
||||
? {
|
||||
providerId,
|
||||
@@ -548,6 +570,17 @@ export function DataTableRowActions({
|
||||
provider={scheduleProvider}
|
||||
state={scheduleState}
|
||||
/>
|
||||
{canManageScanConfig && provider && (
|
||||
<ManageScanConfigModal
|
||||
open={isScanConfigOpen}
|
||||
onOpenChange={setIsScanConfigOpen}
|
||||
providerId={providerId}
|
||||
providerLabel={providerAlias || providerUid}
|
||||
scanConfigs={scanConfigs}
|
||||
currentConfigId={currentScanConfigId}
|
||||
onSaved={() => router.refresh()}
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<ActionDropdown>
|
||||
<ActionDropdownItem
|
||||
@@ -575,6 +608,22 @@ export function DataTableRowActions({
|
||||
onSelect={() => void openScheduleEditor()}
|
||||
/>
|
||||
)}
|
||||
{canManageScanConfig && (
|
||||
<ActionDropdownItem
|
||||
icon={<SlidersHorizontal />}
|
||||
label="Edit Scan Configuration"
|
||||
onSelect={() => setIsScanConfigOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{isCloudProvider &&
|
||||
scanConfigStatus === SCAN_CONFIGURATION_LIST_STATUS.UNAVAILABLE && (
|
||||
<ActionDropdownItem
|
||||
icon={<SlidersHorizontal />}
|
||||
label="Scan Configuration unavailable"
|
||||
description="Try again later."
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
icon={<KeyRound />}
|
||||
label={hasSecret ? "Update Credentials" : "Add Credentials"}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { Row, RowSelectionState } from "@tanstack/react-table";
|
||||
import { Container, CornerDownRight, Link } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
loadLatestFindingTriageNote,
|
||||
updateFindingTriage,
|
||||
} from "@/actions/findings";
|
||||
import { FloatingMuteButton } from "@/components/findings/floating-mute-button";
|
||||
import { FindingDetailDrawer } from "@/components/findings/table";
|
||||
import {
|
||||
@@ -28,8 +32,10 @@ import { DateWithTime } from "@/components/ui/entities/date-with-time";
|
||||
import { EntityInfo } from "@/components/ui/entities/entity-info";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { getGroupLabel } from "@/lib/categories";
|
||||
import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage";
|
||||
import { getRegionFlag } from "@/lib/region-flags";
|
||||
import { ProviderType, ResourceProps } from "@/types";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
|
||||
import {
|
||||
getResourceFindingsColumns,
|
||||
@@ -100,6 +106,7 @@ export const ResourceDetailContent = ({
|
||||
hasInitiallyLoaded,
|
||||
providerOrg,
|
||||
resourceTags,
|
||||
patchTriageUpdate,
|
||||
} = useResourceDrawerBootstrap({
|
||||
resourceId,
|
||||
resourceUid: attributes.uid,
|
||||
@@ -140,6 +147,17 @@ export const ResourceDetailContent = ({
|
||||
if (ids.length > 0) setFindingsReloadNonce((v) => v + 1);
|
||||
};
|
||||
|
||||
const handleTriageUpdate = async (input: UpdateFindingTriageInput) => {
|
||||
await updateFindingTriage(input);
|
||||
|
||||
if (shouldRefreshAfterTriageUpdate(input)) {
|
||||
setFindingsReloadNonce((value) => value + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
patchTriageUpdate(input);
|
||||
};
|
||||
|
||||
const failedFindings = findingsData;
|
||||
|
||||
const selectableRowCount = failedFindings.filter(
|
||||
@@ -161,6 +179,8 @@ export const ResourceDetailContent = ({
|
||||
selectableRowCount,
|
||||
navigateToFinding,
|
||||
handleMuteComplete,
|
||||
handleTriageUpdate,
|
||||
loadLatestFindingTriageNote,
|
||||
);
|
||||
|
||||
const findingTitle =
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/findings/table", () => ({
|
||||
DataTableRowActions: ({
|
||||
row,
|
||||
onTriageUpdateAction,
|
||||
}: {
|
||||
row: { original: ResourceFinding };
|
||||
onTriageUpdateAction?: unknown;
|
||||
}) => (
|
||||
<button disabled={!row.original.triage || !onTriageUpdateAction}>
|
||||
{row.original.triage ? "Add Triage Note" : "-"}
|
||||
</button>
|
||||
),
|
||||
FindingTriageStatusCell: ({ triage }: { triage?: { label: string } }) =>
|
||||
triage ? (
|
||||
<button aria-label="Triage status">{triage.label}</button>
|
||||
) : (
|
||||
<span>-</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/table/notification-indicator", () => ({
|
||||
NotificationIndicator: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Checkbox: ({ "aria-label": ariaLabel }: { "aria-label"?: string }) => (
|
||||
<input type="checkbox" aria-label={ariaLabel} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities", () => ({
|
||||
DateWithTime: ({ dateTime }: { dateTime: string }) => <time>{dateTime}</time>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
|
||||
SeverityBadge: ({ severity }: { severity: string }) => (
|
||||
<span>{severity}</span>
|
||||
),
|
||||
StatusFindingBadge: ({ status }: { status: string }) => <span>{status}</span>,
|
||||
}));
|
||||
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import {
|
||||
getResourceFindingsColumns,
|
||||
type ResourceFinding,
|
||||
} from "./resource-findings-columns";
|
||||
|
||||
function makeTriageSummary(
|
||||
overrides?: Partial<FindingTriageSummary>,
|
||||
): FindingTriageSummary {
|
||||
return {
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-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 makeFinding(overrides?: Partial<ResourceFinding>): ResourceFinding {
|
||||
return {
|
||||
type: "findings",
|
||||
id: "finding-1",
|
||||
triage: makeTriageSummary(),
|
||||
attributes: {
|
||||
status: "FAIL",
|
||||
severity: "critical",
|
||||
muted: false,
|
||||
updated_at: "2026-03-30T10:05:00Z",
|
||||
check_metadata: {
|
||||
checktitle: "S3 public access",
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function getColumnIds(columns: ReturnType<typeof getResourceFindingsColumns>) {
|
||||
return columns.map(
|
||||
(column) =>
|
||||
(column as { id?: string; accessorKey?: string }).id ??
|
||||
(column as { id?: string; accessorKey?: string }).accessorKey,
|
||||
);
|
||||
}
|
||||
|
||||
describe("resource-findings-columns", () => {
|
||||
it("should render Triage before actions without adding a Notes column", () => {
|
||||
// Given
|
||||
const columns = getResourceFindingsColumns({}, 1, vi.fn());
|
||||
|
||||
// When
|
||||
const columnIds = getColumnIds(columns);
|
||||
|
||||
// Then
|
||||
expect(columnIds.slice(-2)).toEqual(["triage", "actions"]);
|
||||
expect(columnIds).not.toContain("notes");
|
||||
});
|
||||
|
||||
it("should render triage status and Add Triage Note action from the finding DTO", () => {
|
||||
// Given
|
||||
const columns = getResourceFindingsColumns(
|
||||
{},
|
||||
1,
|
||||
vi.fn(),
|
||||
vi.fn(),
|
||||
vi.fn(),
|
||||
);
|
||||
const triageColumn = columns.find(
|
||||
(col) => (col as { id?: string }).id === "triage",
|
||||
);
|
||||
const actionsColumn = columns.find(
|
||||
(col) => (col as { id?: string }).id === "actions",
|
||||
);
|
||||
if (!triageColumn?.cell || !actionsColumn?.cell) {
|
||||
throw new Error("triage/actions columns not found");
|
||||
}
|
||||
const finding = makeFinding({
|
||||
triage: makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
label: "Remediating",
|
||||
}),
|
||||
});
|
||||
const TriageCell = triageColumn.cell as (props: {
|
||||
row: { original: ResourceFinding };
|
||||
}) => ReactNode;
|
||||
const ActionsCell = actionsColumn.cell as (props: {
|
||||
row: { original: ResourceFinding };
|
||||
}) => ReactNode;
|
||||
|
||||
// When
|
||||
render(
|
||||
<div>
|
||||
{TriageCell({ row: { original: finding } })}
|
||||
{ActionsCell({ row: { original: finding } })}
|
||||
</div>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Triage status" }),
|
||||
).toHaveTextContent("Remediating");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add Triage Note" }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import { ColumnDef, RowSelectionState } from "@tanstack/react-table";
|
||||
|
||||
import { DataTableRowActions } from "@/components/findings/table";
|
||||
import {
|
||||
DataTableRowActions,
|
||||
FindingTriageStatusCell,
|
||||
} from "@/components/findings/table";
|
||||
import type { FindingTriageUpdateHandler } from "@/components/findings/table/finding-triage-status-control";
|
||||
import {
|
||||
DeltaType,
|
||||
NotificationIndicator,
|
||||
@@ -15,10 +19,15 @@ import {
|
||||
SeverityBadge,
|
||||
StatusFindingBadge,
|
||||
} from "@/components/ui/table";
|
||||
import type {
|
||||
FindingTriageLoadedNote,
|
||||
FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
export interface ResourceFinding {
|
||||
type: "findings";
|
||||
id: string;
|
||||
triage?: FindingTriageSummary;
|
||||
attributes: {
|
||||
status: "PASS" | "FAIL" | "MANUAL";
|
||||
severity: Severity;
|
||||
@@ -37,6 +46,10 @@ export const getResourceFindingsColumns = (
|
||||
selectableRowCount: number,
|
||||
onNavigate: (id: string) => void,
|
||||
onMuteComplete?: (findingIds: string[]) => void,
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler,
|
||||
onTriageNoteLoadAction?: (
|
||||
triage: FindingTriageSummary,
|
||||
) => Promise<FindingTriageLoadedNote>,
|
||||
): ColumnDef<ResourceFinding>[] => {
|
||||
const selectedCount = Object.values(rowSelection).filter(Boolean).length;
|
||||
const isAllSelected =
|
||||
@@ -136,11 +149,29 @@ export const getResourceFindingsColumns = (
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: "triage",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Triage" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<FindingTriageStatusCell
|
||||
triage={row.original.triage}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <div className="w-10" />,
|
||||
cell: ({ row }) => (
|
||||
<DataTableRowActions row={row} onMuteComplete={onMuteComplete} />
|
||||
<DataTableRowActions
|
||||
row={row}
|
||||
onMuteComplete={onMuteComplete}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { getResourceDrawerDataMock } = vi.hoisted(() => ({
|
||||
getResourceDrawerDataMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/resources", () => ({
|
||||
getResourceDrawerData: getResourceDrawerDataMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import type { ResourceFinding } from "./resource-findings-columns";
|
||||
import { useResourceDrawerBootstrap } from "./use-resource-drawer-bootstrap";
|
||||
|
||||
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 makeFinding(overrides?: Partial<ResourceFinding>): ResourceFinding {
|
||||
return {
|
||||
type: "findings",
|
||||
id: "finding-1",
|
||||
triage: makeTriageSummary(),
|
||||
attributes: {
|
||||
status: "FAIL",
|
||||
severity: "critical",
|
||||
muted: false,
|
||||
muted_reason: undefined,
|
||||
updated_at: "2026-03-30T10:05:00Z",
|
||||
check_metadata: {
|
||||
checktitle: "S3 public access",
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderBootstrap(findingsReloadNonce = 0) {
|
||||
return renderHook(() =>
|
||||
useResourceDrawerBootstrap({
|
||||
resourceId: "resource-1",
|
||||
resourceUid: "resource-uid-1",
|
||||
providerId: "provider-1",
|
||||
providerType: "aws",
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
searchQuery: "",
|
||||
findingsReloadNonce,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("useResourceDrawerBootstrap", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getResourceDrawerDataMock.mockResolvedValue({
|
||||
findings: [makeFinding()],
|
||||
findingsMeta: null,
|
||||
providerOrg: null,
|
||||
resourceTags: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("should patch finding triage locally without reloading drawer data", async () => {
|
||||
// Given
|
||||
const { result } = renderBootstrap();
|
||||
|
||||
await waitFor(() => expect(result.current.findingsLoading).toBe(false));
|
||||
const loadCount = getResourceDrawerDataMock.mock.calls.length;
|
||||
|
||||
// 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,
|
||||
note: "Investigating",
|
||||
});
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.findingsData[0]?.triage).toEqual(
|
||||
expect.objectContaining({
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
label: "Remediating",
|
||||
hasVisibleNote: true,
|
||||
notesCount: 1,
|
||||
}),
|
||||
);
|
||||
expect(getResourceDrawerDataMock).toHaveBeenCalledTimes(loadCount);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getResourceDrawerData } from "@/actions/resources";
|
||||
import {
|
||||
applyOptimisticTriageSummaryUpdate,
|
||||
getOptimisticTriageMutedReason,
|
||||
shouldMarkFindingMutedForTriageUpdate,
|
||||
} from "@/lib/finding-triage";
|
||||
import { MetaDataProps } from "@/types";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
import { OrganizationResource } from "@/types/organizations";
|
||||
|
||||
import { ResourceFinding } from "./resource-findings-columns";
|
||||
@@ -26,6 +32,7 @@ interface UseResourceDrawerBootstrapReturn {
|
||||
hasInitiallyLoaded: boolean;
|
||||
providerOrg: OrganizationResource | null;
|
||||
resourceTags: Record<string, string>;
|
||||
patchTriageUpdate: (input: UpdateFindingTriageInput) => void;
|
||||
}
|
||||
|
||||
export function useResourceDrawerBootstrap({
|
||||
@@ -48,6 +55,31 @@ export function useResourceDrawerBootstrap({
|
||||
null,
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -111,5 +143,6 @@ export function useResourceDrawerBootstrap({
|
||||
hasInitiallyLoaded,
|
||||
providerOrg,
|
||||
resourceTags,
|
||||
patchTriageUpdate,
|
||||
};
|
||||
}
|
||||
@@ -28,6 +28,7 @@ const cardVariants = cva("flex flex-col gap-6 rounded-xl border", {
|
||||
sm: "px-3 py-2",
|
||||
md: "px-4 py-3",
|
||||
lg: "px-5 py-4",
|
||||
xl: "p-8",
|
||||
none: "p-0",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -44,10 +44,21 @@ export function ActionDropdown({
|
||||
}: ActionDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Close dropdown when any ancestor scrolls (capture phase catches all scroll events)
|
||||
// Close dropdown when any ancestor scrolls (capture phase catches all scroll events),
|
||||
// but ignore scrolls originating inside a nested dialog (e.g. pasting into a modal
|
||||
// textarea) so they don't unmount a modal rendered within this menu.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleScroll = () => setOpen(false);
|
||||
const handleScroll = (event: Event) => {
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof Element &&
|
||||
target.closest('[data-slot="dialog-content"]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
return () => window.removeEventListener("scroll", handleScroll, true);
|
||||
}, [open]);
|
||||
|
||||
@@ -25,11 +25,22 @@ function FieldLabel({ className, ...props }: React.ComponentProps<"label">) {
|
||||
);
|
||||
}
|
||||
|
||||
function FieldError({ className, ...props }: React.ComponentProps<"p">) {
|
||||
function FieldError({
|
||||
className,
|
||||
multiline = false,
|
||||
...props
|
||||
}: React.ComponentProps<"p"> & {
|
||||
/** Preserve newlines for multi-line messages (e.g. server validation lists). */
|
||||
multiline?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-error"
|
||||
className={cn("text-text-error-primary max-w-full text-xs", className)}
|
||||
className={cn(
|
||||
"text-text-error-primary max-w-full text-xs",
|
||||
multiline && "whitespace-pre-wrap",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -33,6 +33,13 @@ interface ModalProps {
|
||||
size?: ModalSize;
|
||||
className?: string;
|
||||
onOpenAutoFocus?: (event: Event) => void;
|
||||
/**
|
||||
* Cap the dialog at 90dvh and scroll overflowing content, instead of
|
||||
* letting it grow past the viewport. Opt-in per modal (e.g. for content
|
||||
* whose height depends on user input) rather than a DS-wide default, so
|
||||
* existing modals keep their current sizing.
|
||||
*/
|
||||
scrollable?: boolean;
|
||||
}
|
||||
|
||||
export const Modal = ({
|
||||
@@ -44,13 +51,17 @@ export const Modal = ({
|
||||
size = "xl",
|
||||
className,
|
||||
onOpenAutoFocus = preventInitialAutoFocus,
|
||||
scrollable = false,
|
||||
}: ModalProps) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
onOpenAutoFocus={onOpenAutoFocus}
|
||||
// Radix requires an accessible description; opt out explicitly when none is provided.
|
||||
{...(description ? {} : { "aria-describedby": undefined })}
|
||||
className={cn(
|
||||
"border-text-neutral-tertiary bg-bg-neutral-secondary rounded-[24px] border shadow-[0_0_200px_0_rgba(15,44,46,0.50)]",
|
||||
scrollable && "max-h-[90dvh] overflow-y-auto",
|
||||
SIZE_CLASSES[size],
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -83,6 +83,43 @@ describe("MultiSelect", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses a selected background instead of a check icon for active items", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MultiSelect values={["aws-prod"]} onValuesChange={() => {}}>
|
||||
<MultiSelectTrigger>
|
||||
<MultiSelectValue placeholder="Select accounts" />
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent search={false}>
|
||||
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
|
||||
<MultiSelectItem value="azure-dev">Development Azure</MultiSelectItem>
|
||||
</MultiSelectContent>
|
||||
</MultiSelect>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
|
||||
// Then
|
||||
const selectedItem = screen.getByRole("option", {
|
||||
name: "Production AWS",
|
||||
});
|
||||
expect(selectedItem).toHaveAttribute("data-state", "checked");
|
||||
expect(selectedItem).toHaveClass(
|
||||
"data-[state=checked]:bg-button-tertiary/10",
|
||||
);
|
||||
expect(selectedItem).not.toHaveClass(
|
||||
"data-[state=checked]:bg-bg-neutral-tertiary",
|
||||
);
|
||||
expect(selectedItem).toHaveClass(
|
||||
"data-[state=checked]:hover:bg-button-tertiary/15",
|
||||
);
|
||||
expect(selectedItem).toHaveClass("hover:bg-slate-200");
|
||||
expect(selectedItem).toHaveClass("dark:hover:bg-slate-700/50");
|
||||
expect(selectedItem.querySelector("svg")).toBeNull();
|
||||
});
|
||||
|
||||
it("filters items without crashing when search is enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -181,39 +218,6 @@ describe("MultiSelect", () => {
|
||||
expect(screen.getByPlaceholderText("Search accounts...")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("closes the dropdown when clicking outside", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<div>
|
||||
<MultiSelect values={[]} onValuesChange={() => {}}>
|
||||
<MultiSelectTrigger>
|
||||
<MultiSelectValue placeholder="Select accounts" />
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent
|
||||
search={{
|
||||
placeholder: "Search accounts...",
|
||||
emptyMessage: "No accounts found.",
|
||||
}}
|
||||
>
|
||||
<MultiSelectItem value="aws-prod">Production AWS</MultiSelectItem>
|
||||
</MultiSelectContent>
|
||||
</MultiSelect>
|
||||
<button type="button">Outside target</button>
|
||||
</div>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
expect(screen.getByPlaceholderText("Search accounts...")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: /outside target/i }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByPlaceholderText("Search accounts..."),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sizes the dropdown to its content with a capped width", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -237,46 +241,6 @@ describe("MultiSelect", () => {
|
||||
expect(screen.getByRole("dialog")).toHaveClass("sm:max-w-[22rem]");
|
||||
});
|
||||
|
||||
it("keeps long option lists scrollable inside the dropdown", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<MultiSelect values={[]} onValuesChange={() => {}}>
|
||||
<MultiSelectTrigger>
|
||||
<MultiSelectValue placeholder="Select accounts" />
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent search={false}>
|
||||
{Array.from({ length: 20 }, (_, index) => (
|
||||
<MultiSelectItem key={index} value={`account-${index}`}>
|
||||
Account {index}
|
||||
</MultiSelectItem>
|
||||
))}
|
||||
</MultiSelectContent>
|
||||
</MultiSelect>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
|
||||
// Then
|
||||
const list = screen
|
||||
.getByRole("dialog")
|
||||
.querySelector('[data-slot="command-list"]');
|
||||
|
||||
expect(screen.getByRole("dialog")).toHaveStyle({
|
||||
maxHeight:
|
||||
"min(360px, var(--radix-popover-content-available-height, 360px))",
|
||||
});
|
||||
expect(list).toHaveClass("minimal-scrollbar");
|
||||
expect(list).toHaveStyle({
|
||||
maxHeight:
|
||||
"min(300px, var(--radix-popover-content-available-height, 300px))",
|
||||
});
|
||||
expect(list).toHaveClass("overflow-y-auto");
|
||||
expect(list).toHaveClass("overscroll-contain");
|
||||
});
|
||||
|
||||
it("keeps the legacy clear-all behavior by default", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onValuesChange = vi.fn();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, ChevronDown, XIcon } from "lucide-react";
|
||||
import { ChevronDown, XIcon } from "lucide-react";
|
||||
import {
|
||||
type ComponentPropsWithoutRef,
|
||||
createContext,
|
||||
@@ -411,12 +411,12 @@ export function MultiSelectItem({
|
||||
disabled={disabled}
|
||||
aria-disabled={disabled}
|
||||
data-disabled={disabled ? "true" : undefined}
|
||||
data-state={isSelected ? "checked" : "unchecked"}
|
||||
value={value}
|
||||
keywords={keywords}
|
||||
data-slot="multiselect-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary my-1 flex w-full cursor-pointer items-center justify-between gap-3 overflow-hidden rounded-lg px-4 py-3 text-sm outline-hidden select-none first:mt-0 last:mb-0 hover:bg-slate-200 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
|
||||
isSelected && "bg-slate-100 dark:bg-slate-800/50",
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary data-[state=checked]:bg-button-tertiary/10 data-[state=checked]:text-text-neutral-primary data-[state=checked]:hover:bg-button-tertiary/15 data-[state=checked]:focus:bg-button-tertiary/15 data-[selected=true]:data-[state=checked]:bg-button-tertiary/15 my-1 flex w-full cursor-pointer items-center gap-3 overflow-hidden rounded-lg px-4 py-3 text-sm outline-hidden select-none first:mt-0 last:mb-0 hover:bg-slate-200 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
|
||||
disabled && "cursor-not-allowed opacity-50 hover:bg-transparent",
|
||||
className,
|
||||
)}
|
||||
@@ -429,12 +429,6 @@ export function MultiSelectItem({
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||
{children}
|
||||
</span>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"text-bg-button-secondary size-5 shrink-0",
|
||||
isSelected ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "./select";
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
|
||||
configurable: true,
|
||||
value: () => false,
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
|
||||
configurable: true,
|
||||
value: () => {},
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: () => {},
|
||||
});
|
||||
});
|
||||
|
||||
describe("Select", () => {
|
||||
it("supports an extra-small trigger size", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<Select value="open">
|
||||
<SelectTrigger aria-label="Compact status" size="xs">
|
||||
Open
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">Open</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const trigger = screen.getByRole("combobox", { name: "Compact status" });
|
||||
expect(trigger).toHaveAttribute("data-size", "xs");
|
||||
expect(trigger).toHaveClass(
|
||||
"data-[size=xs]:h-8",
|
||||
"data-[size=xs]:px-3",
|
||||
"data-[size=xs]:py-0",
|
||||
"data-[size=xs]:text-xs",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a selected background instead of a check icon for the active item", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Select value="under_review">
|
||||
<SelectTrigger aria-label="Triage status">Under Review</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">Open</SelectItem>
|
||||
<SelectItem value="under_review">Under Review</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
|
||||
// Then
|
||||
const selectedItem = screen.getByRole("option", {
|
||||
name: "Under Review",
|
||||
});
|
||||
expect(selectedItem).toHaveAttribute("data-state", "checked");
|
||||
expect(selectedItem).toHaveClass(
|
||||
"data-[state=checked]:bg-button-tertiary/10",
|
||||
);
|
||||
expect(selectedItem).not.toHaveClass(
|
||||
"data-[state=checked]:bg-bg-neutral-tertiary",
|
||||
);
|
||||
expect(selectedItem).toHaveClass(
|
||||
"data-[state=checked]:hover:bg-button-tertiary/15",
|
||||
);
|
||||
expect(selectedItem).toHaveClass("hover:bg-slate-200");
|
||||
expect(selectedItem).toHaveClass("dark:hover:bg-slate-700/50");
|
||||
expect(
|
||||
within(selectedItem).queryByRole("img", { hidden: true }),
|
||||
).toBeNull();
|
||||
expect(selectedItem.querySelector("svg")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import { ComponentProps, type WheelEvent } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -10,6 +10,22 @@ const stopWheelPropagation = (event: WheelEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const SELECT_TRIGGER_SIZES = {
|
||||
XS: "xs",
|
||||
SM: "sm",
|
||||
DEFAULT: "default",
|
||||
} as const;
|
||||
|
||||
const SELECT_TRIGGER_ICON_SIZES = {
|
||||
SM: "sm",
|
||||
DEFAULT: "default",
|
||||
} as const;
|
||||
|
||||
type SelectTriggerSize =
|
||||
(typeof SELECT_TRIGGER_SIZES)[keyof typeof SELECT_TRIGGER_SIZES];
|
||||
type SelectTriggerIconSize =
|
||||
(typeof SELECT_TRIGGER_ICON_SIZES)[keyof typeof SELECT_TRIGGER_ICON_SIZES];
|
||||
|
||||
function Select({
|
||||
allowDeselect = false,
|
||||
...props
|
||||
@@ -49,20 +65,20 @@ function SelectValue({
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
iconSize = "default",
|
||||
size = SELECT_TRIGGER_SIZES.DEFAULT,
|
||||
iconSize = SELECT_TRIGGER_ICON_SIZES.DEFAULT,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
iconSize?: "sm" | "default";
|
||||
size?: SelectTriggerSize;
|
||||
iconSize?: SelectTriggerIconSize;
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group border-border-input-primary bg-bg-input-primary text-bg-button-secondary data-[placeholder]:text-bg-button-secondary [&_svg:not([class*='text-'])]:text-bg-button-secondary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:bg-bg-neutral-tertiary active:bg-border-neutral-tertiary dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex w-full items-center justify-between gap-2 overflow-hidden rounded-lg border px-4 py-3 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 has-[>svg]:px-3 data-[size=default]:h-[52px] data-[size=sm]:h-10 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:focus-visible:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6",
|
||||
"group border-border-input-primary bg-bg-input-primary text-bg-button-secondary data-[placeholder]:text-bg-button-secondary [&_svg:not([class*='text-'])]:text-bg-button-secondary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:bg-bg-neutral-tertiary active:bg-border-neutral-tertiary dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex w-full items-center justify-between gap-2 overflow-hidden rounded-lg border px-4 py-3 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 has-[>svg]:px-3 data-[size=default]:h-[52px] data-[size=sm]:h-10 data-[size=xs]:h-8 data-[size=xs]:gap-1.5 data-[size=xs]:px-3 data-[size=xs]:py-0 data-[size=xs]:text-xs *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:focus-visible:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -72,7 +88,7 @@ function SelectTrigger({
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"text-bg-button-secondary shrink-0 opacity-70 transition-transform duration-200 group-data-[state=open]:rotate-180",
|
||||
iconSize === "sm" ? "size-4" : "size-6",
|
||||
iconSize === SELECT_TRIGGER_ICON_SIZES.SM ? "size-4" : "size-6",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -162,7 +178,7 @@ function SelectItem({
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary relative flex w-full cursor-pointer items-center gap-2 rounded-lg py-3 pr-12 pl-4 text-sm outline-hidden select-none hover:bg-slate-200 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-bg-button-secondary text-bg-button-secondary data-[state=checked]:bg-button-tertiary/10 data-[state=checked]:text-text-neutral-primary data-[state=checked]:hover:bg-button-tertiary/15 data-[state=checked]:focus:bg-button-tertiary/15 relative flex w-full cursor-pointer items-center gap-2 rounded-lg py-3 pr-4 pl-4 text-sm outline-hidden select-none hover:bg-slate-200 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 dark:hover:bg-slate-700/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -172,9 +188,6 @@ function SelectItem({
|
||||
{children}
|
||||
</span>
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator asChild>
|
||||
<CheckIcon className="text-bg-button-secondary absolute right-4 size-5" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("Textarea", () => {
|
||||
it("does not import Next font loaders from the shared primitive", () => {
|
||||
// Given
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(path.join(currentDir, "textarea.tsx"), "utf8");
|
||||
|
||||
// Then
|
||||
expect(source).not.toContain("@/config/fonts");
|
||||
expect(source).not.toContain("next/font");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { ComponentProps, forwardRef } from "react";
|
||||
import type { ComponentProps, Ref } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -20,31 +20,46 @@ const textareaVariants = cva(
|
||||
sm: "min-h-12 px-3 py-2 text-xs",
|
||||
lg: "min-h-24 px-5 py-4",
|
||||
},
|
||||
font: {
|
||||
sans: "",
|
||||
mono: "font-mono",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
textareaSize: "default",
|
||||
font: "sans",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface TextareaProps
|
||||
extends Omit<ComponentProps<"textarea">, "size">,
|
||||
VariantProps<typeof textareaVariants> {}
|
||||
VariantProps<typeof textareaVariants> {
|
||||
ref?: Ref<HTMLTextAreaElement>;
|
||||
}
|
||||
|
||||
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, variant, textareaSize, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
data-slot="textarea"
|
||||
className={cn(textareaVariants({ variant, textareaSize, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
export const Textarea = ({
|
||||
className,
|
||||
variant,
|
||||
textareaSize,
|
||||
font,
|
||||
ref,
|
||||
...props
|
||||
}: TextareaProps) => {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
textareaVariants({ variant, textareaSize, font }),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea, textareaVariants };
|
||||
export { textareaVariants };
|
||||
@@ -1,10 +1,14 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { BreadcrumbNavigation } from "./breadcrumb-navigation";
|
||||
|
||||
const navigationMock = vi.hoisted(() => ({
|
||||
pathname: "/findings",
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/findings",
|
||||
usePathname: () => navigationMock.pathname,
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
@@ -22,6 +26,10 @@ vi.mock("@heroui/breadcrumbs", () => ({
|
||||
}));
|
||||
|
||||
describe("BreadcrumbNavigation", () => {
|
||||
afterEach(() => {
|
||||
navigationMock.pathname = "/findings";
|
||||
});
|
||||
|
||||
it("renders the title action next to the current breadcrumb title", () => {
|
||||
// Given / When
|
||||
render(
|
||||
@@ -40,4 +48,25 @@ describe("BreadcrumbNavigation", () => {
|
||||
screen.getByRole("button", { name: "Start product tour" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render icons for secondary breadcrumb items", () => {
|
||||
// Given
|
||||
navigationMock.pathname = "/scans/config";
|
||||
|
||||
// When
|
||||
render(
|
||||
<BreadcrumbNavigation
|
||||
mode="auto"
|
||||
title="Configuration"
|
||||
icon="lucide:sliders"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("lucide:timer")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("lucide:sliders")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Configuration" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,7 @@ export function BreadcrumbNavigation({
|
||||
"/users": "lucide:users",
|
||||
"/compliance": "lucide:shield-check",
|
||||
"/findings": "lucide:search",
|
||||
"/scans": "lucide:activity",
|
||||
"/scans": "lucide:timer",
|
||||
"/roles": "lucide:key",
|
||||
"/resources": "lucide:database",
|
||||
"/lighthouse": <LighthouseIcon />,
|
||||
@@ -108,16 +108,20 @@ export function BreadcrumbNavigation({
|
||||
return path;
|
||||
};
|
||||
|
||||
const renderTitleWithIcon = (titleText: string, isLink: boolean = false) => (
|
||||
const renderTitleWithIcon = (
|
||||
titleText: string,
|
||||
isLink: boolean = false,
|
||||
showIcon: boolean = true,
|
||||
) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{typeof icon === "string" ? (
|
||||
{showIcon && typeof icon === "string" ? (
|
||||
<Icon
|
||||
className="text-text-neutral-primary"
|
||||
height={24}
|
||||
icon={icon}
|
||||
width={24}
|
||||
/>
|
||||
) : icon ? (
|
||||
) : showIcon && icon ? (
|
||||
<div className="flex h-8 w-8 items-center justify-center *:h-full *:w-full">
|
||||
{icon}
|
||||
</div>
|
||||
@@ -151,13 +155,15 @@ export function BreadcrumbNavigation({
|
||||
{breadcrumbItems.map((breadcrumb, index) => (
|
||||
<BreadcrumbItem key={breadcrumb.path || index}>
|
||||
{breadcrumb.isLast && showTitle && title ? (
|
||||
renderTitleWithIcon(title)
|
||||
renderTitleWithIcon(title, false, index === 0)
|
||||
) : breadcrumb.isClickable && breadcrumb.path ? (
|
||||
<Link
|
||||
href={buildNavigationUrl(breadcrumb.path)}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
{breadcrumb.icon && typeof breadcrumb.icon === "string" ? (
|
||||
{index === 0 &&
|
||||
breadcrumb.icon &&
|
||||
typeof breadcrumb.icon === "string" ? (
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className="text-text-neutral-primary"
|
||||
@@ -165,7 +171,7 @@ export function BreadcrumbNavigation({
|
||||
icon={breadcrumb.icon}
|
||||
width={24}
|
||||
/>
|
||||
) : breadcrumb.icon ? (
|
||||
) : index === 0 && breadcrumb.icon ? (
|
||||
<div className="flex h-6 w-6 items-center justify-center *:h-full *:w-full">
|
||||
{breadcrumb.icon}
|
||||
</div>
|
||||
@@ -179,7 +185,9 @@ export function BreadcrumbNavigation({
|
||||
onClick={breadcrumb.onClick}
|
||||
className="text-text-neutral-primary hover:text-text-neutral-primary-hover flex cursor-pointer items-center gap-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
{breadcrumb.icon && typeof breadcrumb.icon === "string" ? (
|
||||
{index === 0 &&
|
||||
breadcrumb.icon &&
|
||||
typeof breadcrumb.icon === "string" ? (
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className="text-text-neutral-primary"
|
||||
@@ -187,7 +195,7 @@ export function BreadcrumbNavigation({
|
||||
icon={breadcrumb.icon}
|
||||
width={24}
|
||||
/>
|
||||
) : breadcrumb.icon ? (
|
||||
) : index === 0 && breadcrumb.icon ? (
|
||||
<div className="flex h-6 w-6 items-center justify-center *:h-full *:w-full">
|
||||
{breadcrumb.icon}
|
||||
</div>
|
||||
@@ -198,7 +206,9 @@ export function BreadcrumbNavigation({
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
{breadcrumb.icon && typeof breadcrumb.icon === "string" ? (
|
||||
{index === 0 &&
|
||||
breadcrumb.icon &&
|
||||
typeof breadcrumb.icon === "string" ? (
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className="text-default-500"
|
||||
@@ -206,7 +216,7 @@ export function BreadcrumbNavigation({
|
||||
icon={breadcrumb.icon}
|
||||
width={24}
|
||||
/>
|
||||
) : breadcrumb.icon ? (
|
||||
) : index === 0 && breadcrumb.icon ? (
|
||||
<div className="flex h-6 w-6 items-center justify-center *:h-full *:w-full">
|
||||
{breadcrumb.icon}
|
||||
</div>
|
||||
|
||||
@@ -43,4 +43,34 @@ describe("SubmenuItem", () => {
|
||||
await screen.findAllByText("Available in Prowler Cloud"),
|
||||
).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should render disabled Scan config menu items like disabled Alerts", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<SubmenuItem
|
||||
href="/scans/config"
|
||||
label="Scan"
|
||||
icon={TestIcon}
|
||||
disabled
|
||||
highlight
|
||||
cloudOnly
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const button = screen.getByRole("button", { name: /scan/i });
|
||||
await user.hover(button.parentElement as HTMLElement);
|
||||
|
||||
// Then
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
expect(button).toHaveClass(
|
||||
"cursor-not-allowed",
|
||||
"text-text-neutral-tertiary",
|
||||
);
|
||||
expect(screen.getByText("New")).toHaveClass("h-5", "text-[10px]");
|
||||
expect(
|
||||
await screen.findAllByText("Available in Prowler Cloud"),
|
||||
).not.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user