Merge branch 'master' into cross-provider-compliance-view-ui-side

This commit is contained in:
pedrooot committed 2026-07-02 12:23:56 +02:00
commit 0130764a0e
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>
);
+1
View File
@@ -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>
);
@@ -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();
@@ -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>
@@ -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,
};
}
+1
View File
@@ -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]);
+13 -2
View File
@@ -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}
/>
);
+11
View File
@@ -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();
+3 -9
View File
@@ -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();
});
});
+24 -11
View File
@@ -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");
});
});
+30 -15
View File
@@ -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);
});
});