feat(ui): improve Mutelist UX and mute modal (#10846)

This commit is contained in:
Alejandro Bailo
2026-04-23 17:36:32 +02:00
committed by GitHub
parent ffb1bb89e1
commit d1fc482832
26 changed files with 1720 additions and 353 deletions
+1
View File
@@ -14,6 +14,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787)
- Shared filter dropdowns now support local option search and auto-scroll to the first visible match across table and provider filters [(#10859)](https://github.com/prowler-cloud/prowler/pull/10859)
- Backward-compatibility middleware redirect from `/sign-up?invitation_token=…` to `/invitation/accept?invitation_token=…`; new invitation emails use `/invitation/accept` directly [(#10797)](https://github.com/prowler-cloud/prowler/pull/10797)
- Mutelist improvements: table now supports name/reason search and visual count badges for finding targets [(#10846)](https://github.com/prowler-cloud/prowler/pull/10846)
---
@@ -0,0 +1,132 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
const { updateMuteRuleMock, toastMock } = vi.hoisted(() => ({
updateMuteRuleMock: vi.fn(),
toastMock: vi.fn(),
}));
vi.mock("@/actions/mute-rules", () => ({
updateMuteRule: updateMuteRuleMock,
}));
vi.mock("@/components/ui", () => ({
useToast: () => ({
toast: toastMock,
}),
}));
vi.mock("@/components/ui/form", () => ({
FormButtons: ({
onCancel,
submitText = "Save",
isDisabled,
}: {
onCancel?: () => void;
submitText?: string;
isDisabled?: boolean;
}) => (
<div>
<button type="button" onClick={onCancel}>
Cancel
</button>
<button type="submit" disabled={isDisabled}>
{submitText}
</button>
</div>
),
}));
vi.mock("@/components/shadcn", () => ({
Input: ({
defaultValue,
...props
}: React.InputHTMLAttributes<HTMLInputElement>) => (
<input defaultValue={defaultValue} {...props} />
),
Textarea: ({
value,
...props
}: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => (
<textarea value={value} {...props} />
),
}));
vi.mock("@/components/ui/form/Label", () => ({
Label: ({
children,
...props
}: React.LabelHTMLAttributes<HTMLLabelElement>) => (
<label {...props}>{children}</label>
),
}));
import { MuteRuleEditForm } from "./mute-rule-edit-form";
const muteRule = {
type: "mute-rules" as const,
id: "mute-rule-1",
attributes: {
inserted_at: "2026-04-22T09:00:00Z",
updated_at: "2026-04-22T09:05:00Z",
name: "Ignore dev bucket",
reason: "Existing reason",
enabled: true,
finding_uids: ["uid-1", "uid-2", "uid-3"],
},
};
describe("MuteRuleEditForm", () => {
it("submits successfully with a single toast and closes once", async () => {
updateMuteRuleMock.mockResolvedValue({
success: "Mute rule updated successfully!",
});
const user = userEvent.setup();
const onSuccess = vi.fn();
render(
<MuteRuleEditForm
muteRule={muteRule}
onSuccess={onSuccess}
onCancel={vi.fn()}
/>,
);
await user.clear(screen.getByLabelText("Reason"));
await user.type(screen.getByLabelText("Reason"), "Updated reason");
await user.click(screen.getByRole("button", { name: "Update" }));
await waitFor(() => {
expect(updateMuteRuleMock).toHaveBeenCalledTimes(1);
expect(toastMock).toHaveBeenCalledTimes(1);
expect(toastMock).toHaveBeenCalledWith({
title: "Success",
description: "Mute rule updated successfully!",
});
expect(onSuccess).toHaveBeenCalledTimes(1);
});
});
it("shows the shared 500-char counter and clamps oversized input with a local error", () => {
render(
<MuteRuleEditForm
muteRule={muteRule}
onSuccess={vi.fn()}
onCancel={vi.fn()}
/>,
);
const textarea = screen.getByLabelText("Reason");
fireEvent.change(textarea, {
target: { value: "a".repeat(501) },
});
expect(textarea).toHaveAttribute("maxLength", "500");
expect(screen.getByText("500/500 characters")).toBeInTheDocument();
expect(
screen.getByText("Reason must be 500 characters or fewer"),
).toBeInTheDocument();
});
});
@@ -1,12 +1,17 @@
"use client";
import { Input, Textarea } from "@heroui/input";
import { useActionState, useEffect } from "react";
import { FormEvent, useState } from "react";
import { updateMuteRule } from "@/actions/mute-rules";
import { MuteRuleActionState, MuteRuleData } from "@/actions/mute-rules/types";
import { useToast } from "@/components/ui";
import { Input, Textarea } from "@/components/shadcn";
import { FormButtons } from "@/components/ui/form";
import { Label } from "@/components/ui/form/Label";
import { useMuteRuleAction } from "@/hooks/use-mute-rule-action";
import {
enforceMuteRuleReasonLimit,
getMuteRuleReasonCounterText,
} from "@/lib/mute-rules";
interface MuteRuleEditFormProps {
muteRule: MuteRuleData;
@@ -19,68 +24,143 @@ export function MuteRuleEditForm({
onSuccess,
onCancel,
}: MuteRuleEditFormProps) {
const { toast } = useToast();
const [state, setState] = useState<MuteRuleActionState>(null);
const [reason, setReason] = useState(muteRule.attributes.reason);
const [reasonLengthError, setReasonLengthError] = useState<string>();
const { isPending, runAction } = useMuteRuleAction();
const [state, formAction, isPending] = useActionState<
MuteRuleActionState,
FormData
>(updateMuteRule, null);
const handleReasonChange = (value: string) => {
const nextReason = enforceMuteRuleReasonLimit(value);
useEffect(() => {
if (state?.success) {
toast({
title: "Success",
description: state.success,
});
onSuccess();
} else if (state?.errors?.general) {
toast({
variant: "destructive",
title: "Error",
description: state.errors.general,
});
setReason(nextReason.value);
setReasonLengthError(nextReason.error);
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
formData.set("reason", reason);
const nextReason = enforceMuteRuleReasonLimit(reason);
if (nextReason.error) {
setReasonLengthError(nextReason.error);
return;
}
}, [state, toast, onSuccess]);
runAction(() => updateMuteRule(null, formData), {
setState,
onSuccess,
});
};
return (
<form action={formAction} className="flex flex-col gap-4">
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<input type="hidden" name="id" value={muteRule.id} />
<Input
name="name"
label="Name"
placeholder="Enter rule name"
defaultValue={muteRule.attributes.name}
isRequired
variant="bordered"
isInvalid={!!state?.errors?.name}
errorMessage={state?.errors?.name}
isDisabled={isPending}
/>
<div className="space-y-4">
<div className="space-y-4">
<div className="space-y-2">
<Label
className="text-text-neutral-secondary text-xs font-light tracking-tight"
htmlFor="mute-rule-name"
>
Name
</Label>
<Input
id="mute-rule-name"
name="name"
placeholder="Enter rule name"
defaultValue={muteRule.attributes.name}
required
disabled={isPending}
aria-invalid={state?.errors?.name ? "true" : "false"}
aria-describedby={
state?.errors?.name
? "mute-rule-name-error"
: "mute-rule-name-description"
}
/>
<p
id="mute-rule-name-description"
className="text-text-neutral-tertiary text-xs"
>
A short label that helps identify this mute rule in the table
</p>
{state?.errors?.name ? (
<p
id="mute-rule-name-error"
className="text-text-error-primary text-xs"
>
{state.errors.name}
</p>
) : null}
</div>
<Textarea
name="reason"
label="Reason"
placeholder="Enter the reason for muting these findings"
defaultValue={muteRule.attributes.reason}
isRequired
variant="bordered"
minRows={3}
maxRows={6}
isInvalid={!!state?.errors?.reason}
errorMessage={state?.errors?.reason}
isDisabled={isPending}
/>
<div className="space-y-2">
<Label
className="text-text-neutral-secondary text-xs font-light tracking-tight"
htmlFor="mute-rule-reason"
>
Reason
</Label>
<Textarea
id="mute-rule-reason"
name="reason"
placeholder="Enter the reason for muting these findings"
value={reason}
onChange={(event) => handleReasonChange(event.target.value)}
required
rows={4}
maxLength={500}
disabled={isPending}
aria-invalid={
reasonLengthError || state?.errors?.reason ? "true" : "false"
}
aria-describedby={
reasonLengthError || state?.errors?.reason
? "mute-rule-reason-error"
: "mute-rule-reason-description"
}
/>
<div className="flex items-center justify-between gap-3">
<p
id="mute-rule-reason-description"
className="text-text-neutral-tertiary text-xs"
>
Explain why these findings are being muted
</p>
<p className="text-text-neutral-tertiary shrink-0 text-xs">
{getMuteRuleReasonCounterText(reason)}
</p>
</div>
{reasonLengthError || state?.errors?.reason ? (
<p
id="mute-rule-reason-error"
className="text-text-error-primary text-xs"
>
{reasonLengthError || state?.errors?.reason}
</p>
) : null}
</div>
</div>
<div className="text-default-500 text-xs">
<p>
This rule is applied to{" "}
{muteRule.attributes.finding_uids?.length || 0} findings.
</p>
<p className="mt-1">
Note: You cannot modify the findings associated with this rule after
creation.
</p>
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary rounded-xl border p-4">
<p className="text-text-neutral-tertiary text-xs font-medium tracking-[0.08em] uppercase">
Muted findings
</p>
<p className="text-text-neutral-secondary mt-2 text-sm">
This rule is currently applied to{" "}
<span className="text-text-neutral-primary font-medium">
{muteRule.attributes.finding_uids?.length || 0}
</span>{" "}
findings.
</p>
<p className="text-text-neutral-tertiary mt-1 text-xs">
The associated findings stay fixed after creation and can&apos;t be
changed from this dialog.
</p>
</div>
</div>
<FormButtons
@@ -0,0 +1,103 @@
import { describe, expect, it, vi } from "vitest";
const { getLatestFindingsMock, adaptFindingsByResourceResponseMock } =
vi.hoisted(() => ({
getLatestFindingsMock: vi.fn(),
adaptFindingsByResourceResponseMock: vi.fn(),
}));
vi.mock("@/actions/findings", () => ({
getLatestFindings: getLatestFindingsMock,
adaptFindingsByResourceResponse: adaptFindingsByResourceResponseMock,
}));
import {
formatMuteRuleTargetPreview,
hydrateMuteRuleTargetPreviews,
} from "./mute-rule-target-previews";
function makeFinding(
overrides?: Partial<{
uid: string;
checkTitle: string;
checkId: string;
resourceName: string;
resourceUid: string;
}>,
) {
return {
uid: "uid-1",
checkTitle: "S3 Bucket Public Access",
checkId: "s3_bucket_public_access",
resourceName: "bucket-a",
resourceUid: "arn:aws:s3:::bucket-a",
...overrides,
};
}
describe("mute rule target previews", () => {
it("formats previews as checkTitle • resourceName with safe fallbacks", () => {
const preview = formatMuteRuleTargetPreview(makeFinding());
const fallbackPreview = formatMuteRuleTargetPreview(
makeFinding({
checkId: "ec2_public_ip",
checkTitle: "",
resourceName: "",
resourceUid: "arn:aws:ec2:::instance/i-123",
}),
);
expect(preview).toBe("S3 Bucket Public Access • bucket-a");
expect(fallbackPreview).toBe(
"ec2_public_ip • arn:aws:ec2:::instance/i-123",
);
});
it("hydrates all target labels for a rule and derives a compact summary", async () => {
getLatestFindingsMock.mockResolvedValue({ data: [] });
adaptFindingsByResourceResponseMock.mockReturnValue([
makeFinding(),
makeFinding({
uid: "uid-2",
checkId: "ec2_public_ip",
checkTitle: "EC2 Public IP",
resourceName: "instance-a",
resourceUid: "arn:aws:ec2:::instance/i-123",
}),
]);
const result = await hydrateMuteRuleTargetPreviews([
{
type: "mute-rules",
id: "mute-rule-1",
attributes: {
inserted_at: "2026-04-22T09:00:00Z",
updated_at: "2026-04-22T09:05:00Z",
name: "Rule 1",
reason: "Reason 1",
enabled: true,
finding_uids: ["uid-1", "uid-2", "uid-3"],
},
},
]);
expect(getLatestFindingsMock).toHaveBeenCalledWith({
pageSize: 3,
filters: {
"filter[uid__in]": "uid-1,uid-2,uid-3",
},
});
expect(adaptFindingsByResourceResponseMock).toHaveBeenCalledWith({
data: [],
});
expect(result[0].targetLabels).toEqual([
"S3 Bucket Public Access • bucket-a",
"EC2 Public IP • instance-a",
"uid-3",
]);
expect(result[0].targetSummaryLabel).toBe(
"S3 Bucket Public Access • bucket-a",
);
expect(result[0].hiddenTargetCount).toBe(2);
});
});
@@ -0,0 +1,72 @@
import {
adaptFindingsByResourceResponse,
getLatestFindings,
type ResourceDrawerFinding,
} from "@/actions/findings";
import { MuteRuleData } from "@/actions/mute-rules/types";
export interface MuteRuleTableData extends MuteRuleData {
targetLabels: string[];
targetSummaryLabel: string;
hiddenTargetCount: number;
}
export function formatMuteRuleTargetPreview(
finding: Pick<
ResourceDrawerFinding,
"checkTitle" | "checkId" | "resourceName" | "resourceUid"
>,
): string {
const checkTitle = finding.checkTitle?.trim();
const checkId = finding.checkId?.trim();
const resourceName = finding.resourceName?.trim();
const resourceUid = finding.resourceUid?.trim();
const left = checkTitle || checkId || "Unknown finding";
const right = resourceName || resourceUid;
return right ? `${left}${right}` : left;
}
export async function hydrateMuteRuleTargetPreviews(
muteRules: MuteRuleData[],
): Promise<MuteRuleTableData[]> {
const targetUids = Array.from(
new Set(muteRules.flatMap((muteRule) => muteRule.attributes.finding_uids)),
);
const previewByUid = new Map<string, string>();
if (targetUids.length > 0) {
const findings = await getLatestFindings({
pageSize: targetUids.length,
filters: {
"filter[uid__in]": targetUids.join(","),
},
});
const adaptedFindings = adaptFindingsByResourceResponse(findings);
adaptedFindings.forEach((finding) => {
previewByUid.set(finding.uid, formatMuteRuleTargetPreview(finding));
});
}
return muteRules.map((muteRule) => {
const targetLabels = muteRule.attributes.finding_uids.map(
(uid) => previewByUid.get(uid) ?? uid,
);
const targetSummaryLabel =
targetLabels[0] ||
`${muteRule.attributes.finding_uids.length} ${
muteRule.attributes.finding_uids.length === 1 ? "finding" : "findings"
}`;
return {
...muteRule,
targetLabels,
targetSummaryLabel,
hiddenTargetCount: Math.max(targetLabels.length - 1, 0),
};
});
}
@@ -0,0 +1,78 @@
"use client";
import { Modal } from "@/components/shadcn/modal";
import { MuteRuleTableData } from "./mute-rule-target-previews";
interface MuteRuleTargetsModalProps {
muteRule: MuteRuleTableData | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function MuteRuleTargetsModal({
muteRule,
open,
onOpenChange,
}: MuteRuleTargetsModalProps) {
if (!muteRule) {
return null;
}
const targetCount = muteRule.targetLabels.length;
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Muted Findings"
description="Review every finding currently muted by this rule."
size="xl"
>
<div className="flex flex-col gap-5">
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary flex items-start justify-between gap-4 rounded-xl border p-4">
<div className="min-w-0">
<p className="text-text-neutral-tertiary text-xs font-medium tracking-[0.08em] uppercase">
Mute rule
</p>
<p className="text-text-neutral-primary mt-2 truncate text-sm font-medium">
{muteRule.attributes.name}
</p>
<p className="text-text-neutral-secondary mt-1 text-xs">
This mute rule currently affects {targetCount}{" "}
{targetCount === 1 ? "finding" : "findings"}.
</p>
</div>
<div className="border-border-neutral-secondary bg-bg-neutral-secondary text-text-neutral-primary shrink-0 rounded-full border px-3 py-1 text-xs font-medium">
{targetCount} {targetCount === 1 ? "finding" : "findings"}
</div>
</div>
<div className="minimal-scrollbar border-border-neutral-secondary bg-bg-neutral-tertiary max-h-[60vh] overflow-y-auto rounded-xl border">
<ul className="divide-border-neutral-secondary divide-y">
{muteRule.targetLabels.map((label, index) => {
const [title, ...metaParts] = label.split(" • ");
const meta = metaParts.join(" • ").trim();
return (
<li
key={`${muteRule.id}-${label}-${index}`}
className="min-w-0 px-4 py-3"
>
<p className="text-text-neutral-primary text-sm font-medium break-all">
{title}
</p>
{meta ? (
<p className="text-text-neutral-tertiary mt-1 text-xs break-all">
{meta}
</p>
) : null}
</li>
);
})}
</ul>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,73 @@
import type { CellContext } from "@tanstack/react-table";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { createMuteRulesColumns } from "./mute-rules-columns";
vi.mock("@/components/ui/entities", () => ({
DateWithTime: () => null,
}));
vi.mock("@/components/ui/table", () => ({
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
}));
vi.mock("./mute-rule-enabled-toggle", () => ({
MuteRuleEnabledToggle: () => null,
}));
vi.mock("./mute-rule-row-actions", () => ({
MuteRuleRowActions: () => null,
}));
describe("createMuteRulesColumns", () => {
it("renders a compact actionable summary that opens the full list via callback", async () => {
const onViewTargets = vi.fn();
const columns = createMuteRulesColumns(vi.fn(), vi.fn(), onViewTargets);
const findingsColumn = columns.find(
(column) =>
"accessorKey" in column && column.accessorKey === "finding_count",
);
if (!findingsColumn) throw new Error("finding_count column not found");
const row = {
original: {
type: "mute-rules" as const,
id: "mute-rule-1",
attributes: {
inserted_at: "2026-04-22T09:00:00Z",
updated_at: "2026-04-22T09:05:00Z",
name: "Ignore dev bucket",
reason: "Existing reason",
enabled: true,
finding_uids: ["uid-1", "uid-2", "uid-3"],
},
targetLabels: [
"S3 Bucket Public Access • bucket-a",
"EC2 Public IP • instance-a",
"uid-3",
],
targetSummaryLabel: "S3 Bucket Public Access • bucket-a",
hiddenTargetCount: 2,
},
};
const findingsCell = findingsColumn.cell as (
context: CellContext<(typeof row)["original"], unknown>,
) => React.ReactNode;
render(<>{findingsCell({ row } as never)}</>);
const button = screen.getByRole("button", {
name: "View muted findings for Ignore dev bucket",
});
expect(
screen.getByText("S3 Bucket Public Access • bucket-a"),
).toBeInTheDocument();
expect(screen.getByText("+2 more")).toBeInTheDocument();
button.click();
expect(onViewTargets).toHaveBeenCalledWith(row.original);
});
});
@@ -1,18 +1,22 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { List } from "lucide-react";
import { MuteRuleData } from "@/actions/mute-rules/types";
import { Button } from "@/components/shadcn";
import { DateWithTime } from "@/components/ui/entities";
import { DataTableColumnHeader } from "@/components/ui/table";
import { MuteRuleEnabledToggle } from "./mute-rule-enabled-toggle";
import { MuteRuleRowActions } from "./mute-rule-row-actions";
import { MuteRuleTableData } from "./mute-rule-target-previews";
export const createMuteRulesColumns = (
onEdit: (muteRule: MuteRuleData) => void,
onDelete: (muteRule: MuteRuleData) => void,
): ColumnDef<MuteRuleData>[] => [
onViewTargets: (muteRule: MuteRuleTableData) => void,
): ColumnDef<MuteRuleTableData>[] => [
{
accessorKey: "name",
header: ({ column }) => (
@@ -22,7 +26,9 @@ export const createMuteRulesColumns = (
const name = row.original.attributes.name;
return (
<div className="max-w-[200px]">
<p className="truncate text-sm font-medium">{name}</p>
<p className="text-text-neutral-primary truncate text-sm font-medium">
{name}
</p>
</div>
);
},
@@ -36,7 +42,7 @@ export const createMuteRulesColumns = (
const reason = row.original.attributes.reason;
return (
<div className="max-w-[300px]">
<p className="truncate text-sm text-slate-600 dark:text-slate-400">
<p className="text-text-neutral-tertiary truncate text-sm">
{reason}
</p>
</div>
@@ -51,12 +57,26 @@ export const createMuteRulesColumns = (
),
cell: ({ row }) => {
const count = row.original.attributes.finding_uids?.length || 0;
const summaryLabel = row.original.targetSummaryLabel;
const extraCount = Math.max(count - 1, 0);
return (
<div className="w-[80px]">
<span className="rounded-full bg-slate-100 px-2 py-1 text-xs font-medium dark:bg-slate-800">
{count}
<Button
variant="outline"
onClick={() => onViewTargets(row.original)}
className="h-auto max-w-[290px] justify-start gap-2 px-3 py-2"
aria-label={`View muted findings for ${row.original.attributes.name}`}
>
<span className="text-text-neutral-primary min-w-0 flex-1 truncate text-left text-sm font-medium">
{summaryLabel}
</span>
</div>
{extraCount > 0 ? (
<span className="text-text-neutral-tertiary shrink-0 text-xs font-medium">
+{extraCount} more
</span>
) : null}
<List className="text-button-tertiary size-4 shrink-0" />
</Button>
);
},
enableSorting: false,
@@ -0,0 +1,199 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
const { deleteMuteRuleMock, toastMock, routerRefreshMock } = vi.hoisted(() => ({
deleteMuteRuleMock: vi.fn(),
toastMock: vi.fn(),
routerRefreshMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: routerRefreshMock,
}),
}));
vi.mock("@/actions/mute-rules", () => ({
deleteMuteRule: deleteMuteRuleMock,
}));
vi.mock("@/components/ui", () => ({
useToast: () => ({
toast: toastMock,
}),
}));
vi.mock("@/components/shadcn", () => ({
Button: ({
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button {...props}>{children}</button>
),
CardTitle: ({ children }: { children: ReactNode }) => <h3>{children}</h3>,
}));
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,
}));
const dataTableMock = vi.fn();
vi.mock("@/components/ui/table", () => ({
DataTable: (props: {
columns: Array<{
id?: string;
cell?: (args: { row: { original: unknown } }) => ReactNode;
}>;
data: unknown[];
showSearch?: boolean;
}) => {
dataTableMock(props);
const actionsColumn = props.columns.find(
(column) => column.id === "actions",
);
const findingsColumn = props.columns[2];
return (
<div>
{props.data.map((row, index) => (
<div key={index}>
{findingsColumn?.cell?.({ row: { original: row } })}
{actionsColumn?.cell?.({ row: { original: row } })}
</div>
))}
</div>
);
},
}));
vi.mock("@/components/shadcn/dropdown", () => ({
ActionDropdown: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
ActionDropdownDangerZone: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
ActionDropdownItem: ({
label,
onSelect,
}: {
label: string;
onSelect?: () => void;
}) => (
<button type="button" onClick={onSelect}>
{label}
</button>
),
}));
vi.mock("./mute-rule-edit-form", () => ({
MuteRuleEditForm: () => null,
}));
vi.mock("./mute-rule-targets-modal", () => ({
MuteRuleTargetsModal: ({
muteRule,
open,
}: {
muteRule: { targetLabels?: string[] } | null;
open: boolean;
}) =>
open ? (
<div role="dialog" aria-label="Muted Findings">
{muteRule?.targetLabels?.map((label) => (
<span key={label}>{label}</span>
))}
</div>
) : null,
}));
import { MuteRulesTableClient } from "./mute-rules-table-client";
const muteRule = {
type: "mute-rules" as const,
id: "mute-rule-1",
attributes: {
inserted_at: "2026-04-22T09:00:00Z",
updated_at: "2026-04-22T09:05:00Z",
name: "Ignore dev bucket",
reason: "Existing reason",
enabled: true,
finding_uids: ["uid-1", "uid-2", "uid-3"],
},
targetLabels: ["Check title • bucket-a", "Other check • bucket-b", "uid-3"],
targetSummaryLabel: "Check title • bucket-a",
hiddenTargetCount: 2,
};
describe("MuteRulesTableClient", () => {
it("deletes a mute rule with a single toast", async () => {
deleteMuteRuleMock.mockResolvedValue({
success: "Mute rule deleted successfully!",
});
const user = userEvent.setup();
render(<MuteRulesTableClient muteRules={[muteRule]} />);
await user.click(screen.getByRole("button", { name: "Delete Mute Rule" }));
await user.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => {
expect(deleteMuteRuleMock).toHaveBeenCalledTimes(1);
expect(toastMock).toHaveBeenCalledTimes(1);
expect(toastMock).toHaveBeenCalledWith({
title: "Success",
description: "Mute rule deleted successfully!",
});
expect(routerRefreshMock).toHaveBeenCalledTimes(1);
});
});
it("opens the muted findings modal from the actionable findings cell", async () => {
const user = userEvent.setup();
render(<MuteRulesTableClient muteRules={[muteRule]} />);
await user.click(
screen.getByRole("button", {
name: "View muted findings for Ignore dev bucket",
}),
);
const dialog = screen.getByRole("dialog", { name: "Muted Findings" });
expect(dialog).toBeInTheDocument();
expect(
within(dialog).getByText("Check title • bucket-a"),
).toBeInTheDocument();
expect(
within(dialog).getByText("Other check • bucket-b"),
).toBeInTheDocument();
expect(within(dialog).getByText("uid-3")).toBeInTheDocument();
});
it("enables search on the DataTable", () => {
dataTableMock.mockClear();
render(<MuteRulesTableClient muteRules={[muteRule]} />);
expect(dataTableMock).toHaveBeenCalled();
const props = dataTableMock.mock.calls[0][0];
expect(props.showSearch).toBe(true);
});
});
@@ -3,21 +3,24 @@
import { useDisclosure } from "@heroui/use-disclosure";
import { Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useActionState, useEffect, useRef, useState } from "react";
import { FormEvent, useState } from "react";
import { deleteMuteRule } from "@/actions/mute-rules";
import { MuteRuleData } from "@/actions/mute-rules/types";
import { Button } from "@/components/shadcn";
import { CardTitle } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { FormButtons } from "@/components/ui/form";
import { DataTable } from "@/components/ui/table";
import { useMuteRuleAction } from "@/hooks/use-mute-rule-action";
import { MetaDataProps } from "@/types";
import { MuteRuleEditForm } from "./mute-rule-edit-form";
import { MuteRuleTableData } from "./mute-rule-target-previews";
import { MuteRuleTargetsModal } from "./mute-rule-targets-modal";
import { createMuteRulesColumns } from "./mute-rules-columns";
interface MuteRulesTableClientProps {
muteRules: MuteRuleData[];
muteRules: MuteRuleTableData[];
metadata?: MetaDataProps;
}
@@ -26,38 +29,17 @@ export function MuteRulesTableClient({
metadata,
}: MuteRulesTableClientProps) {
const router = useRouter();
const { toast } = useToast();
const [selectedMuteRule, setSelectedMuteRule] = useState<MuteRuleData | null>(
null,
);
const [selectedTargetsRule, setSelectedTargetsRule] =
useState<MuteRuleTableData | null>(null);
const { isPending: isDeleting, runAction: runDeleteAction } =
useMuteRuleAction();
const editModal = useDisclosure();
const deleteModal = useDisclosure();
const deleteModalRef = useRef(deleteModal);
deleteModalRef.current = deleteModal;
const [deleteState, deleteAction, isDeleting] = useActionState(
deleteMuteRule,
null,
);
// Handle delete state changes
useEffect(() => {
if (deleteState?.success) {
toast({
title: "Success",
description: deleteState.success,
});
deleteModalRef.current.onClose();
router.refresh();
} else if (deleteState?.errors?.general) {
toast({
variant: "destructive",
title: "Error",
description: deleteState.errors.general,
});
}
}, [deleteState, toast, router]);
const targetsModal = useDisclosure();
const handleEditClick = (muteRule: MuteRuleData) => {
setSelectedMuteRule(muteRule);
@@ -69,16 +51,60 @@ export function MuteRulesTableClient({
deleteModal.onOpen();
};
const handleViewTargets = (muteRule: MuteRuleTableData) => {
setSelectedTargetsRule(muteRule);
targetsModal.onOpen();
};
const handleEditSuccess = () => {
editModal.onClose();
router.refresh();
};
const columns = createMuteRulesColumns(handleEditClick, handleDeleteClick);
const handleDeleteSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
runDeleteAction(() => deleteMuteRule(null, formData), {
onSuccess: () => {
deleteModal.onClose();
router.refresh();
},
});
};
const columns = createMuteRulesColumns(
handleEditClick,
handleDeleteClick,
handleViewTargets,
);
return (
<>
<DataTable columns={columns} data={muteRules} metadata={metadata} />
<DataTable
columns={columns}
data={muteRules}
metadata={metadata}
showSearch
header={
<div className="flex w-full items-center justify-between gap-4">
<div className="flex flex-col gap-0.5">
<CardTitle>Mutelist Rules</CardTitle>
<p className="text-text-neutral-tertiary text-xs">
Rules created from the Findings page apply immediately and can
be toggled on or off at any time.
</p>
</div>
</div>
}
/>
<MuteRuleTargetsModal
muteRule={selectedTargetsRule}
open={targetsModal.isOpen}
onOpenChange={targetsModal.onOpenChange}
/>
{/* Edit Modal */}
{selectedMuteRule && (
@@ -86,9 +112,11 @@ export function MuteRulesTableClient({
open={editModal.isOpen}
onOpenChange={editModal.onOpenChange}
title="Edit Mute Rule"
description="Update the rule metadata without changing the muted findings linked to it."
size="lg"
>
<MuteRuleEditForm
key={selectedMuteRule.id}
muteRule={selectedMuteRule}
onSuccess={handleEditSuccess}
onCancel={editModal.onClose}
@@ -102,42 +130,36 @@ export function MuteRulesTableClient({
open={deleteModal.isOpen}
onOpenChange={deleteModal.onOpenChange}
title="Delete Mute Rule"
description="Remove this rule from Mutelist. Existing muted findings will remain muted."
size="md"
>
<div className="flex flex-col gap-4">
<p className="text-default-600 text-sm">
Are you sure you want to delete the mute rule &quot;
{selectedMuteRule.attributes.name}&quot;? This action cannot be
undone.
</p>
<p className="text-default-500 text-xs">
Note: This will not unmute the findings that were muted by this
rule.
</p>
<div className="flex w-full justify-end gap-4">
<Button
type="button"
variant="ghost"
size="lg"
onClick={deleteModal.onClose}
disabled={isDeleting}
>
Cancel
</Button>
<form action={deleteAction}>
<input type="hidden" name="id" value={selectedMuteRule.id} />
<Button
type="submit"
variant="destructive"
size="lg"
disabled={isDeleting}
>
<Trash2 className="size-4" />
{isDeleting ? "Deleting..." : "Delete"}
</Button>
</form>
<form onSubmit={handleDeleteSubmit} className="flex flex-col gap-5">
<input type="hidden" name="id" value={selectedMuteRule.id} />
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary rounded-xl border p-4">
<p className="text-text-neutral-tertiary text-xs font-medium tracking-[0.08em] uppercase">
Rule to delete
</p>
<p className="text-text-neutral-primary mt-2 text-sm font-medium">
{selectedMuteRule.attributes.name}
</p>
<p className="text-text-neutral-secondary mt-1 text-sm">
Deleting this rule removes it from Mutelist immediately.
</p>
<p className="text-text-neutral-tertiary mt-1 text-xs">
This action will not unmute the findings that were already muted
by the rule.
</p>
</div>
</div>
<FormButtons
onCancel={deleteModal.onClose}
submitText={isDeleting ? "Deleting..." : "Delete"}
isDisabled={isDeleting}
submitColor="danger"
rightIcon={isDeleting ? undefined : <Trash2 className="size-4" />}
/>
</form>
</Modal>
)}
</>
@@ -0,0 +1,39 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/actions/mute-rules", () => ({
getMuteRules: vi.fn(),
}));
vi.mock("./mute-rule-target-previews", () => ({
hydrateMuteRuleTargetPreviews: vi.fn(),
}));
vi.mock("./mute-rules-table-client", () => ({
MuteRulesTableClient: () => null,
}));
import { MuteRulesTableSkeleton } from "./mute-rules-table";
describe("MuteRulesTableSkeleton", () => {
it("renders the table skeleton with the new header, toolbar, rows, and 6 columns", () => {
render(<MuteRulesTableSkeleton />);
const skeleton = screen.getByTestId("mute-rules-table-skeleton");
const intro = screen.getByTestId("mute-rules-table-skeleton-intro");
expect(skeleton).toHaveClass(
"bg-bg-neutral-secondary",
"border-border-neutral-secondary",
"rounded-large",
);
// Intro: title + 1 description line
expect(intro.querySelectorAll("[data-slot='skeleton']").length).toBe(2);
expect(skeleton.querySelector("table")).toBeInTheDocument();
// 6 columns: name + reason + findings + created + enabled + actions
expect(skeleton.querySelectorAll("thead th").length).toBe(6);
expect(skeleton.querySelectorAll("tbody tr").length).toBeGreaterThanOrEqual(
8,
);
});
});
@@ -4,6 +4,7 @@ import { getMuteRules } from "@/actions/mute-rules";
import { Card, Skeleton } from "@/components/shadcn";
import { SearchParamsProps } from "@/types/components";
import { hydrateMuteRuleTargetPreviews } from "./mute-rule-target-previews";
import { MuteRulesTableClient } from "./mute-rules-table-client";
interface MuteRulesTableProps {
@@ -14,27 +15,33 @@ export async function MuteRulesTable({ searchParams }: MuteRulesTableProps) {
const page = parseInt(searchParams.page?.toString() || "1", 10);
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
const sort = searchParams.sort?.toString() || "-inserted_at";
const search = searchParams["filter[search]"]?.toString();
const muteRulesData = await getMuteRules({
page,
pageSize,
sort,
filters: search ? { search } : undefined,
});
const muteRules = muteRulesData?.data || [];
const muteRules = await hydrateMuteRuleTargetPreviews(
muteRulesData?.data || [],
);
if (muteRules.length === 0) {
const hasActiveSearch = Boolean(search);
if (muteRules.length === 0 && !hasActiveSearch) {
return (
<Card variant="base" className="p-8">
<Card variant="base" className="gap-0">
<div className="flex flex-col items-center justify-center gap-4 text-center">
<div className="rounded-full bg-slate-100 p-4 dark:bg-slate-800">
<Info className="size-8 text-slate-500" />
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary rounded-full border p-4">
<Info className="text-text-neutral-tertiary size-8" />
</div>
<div>
<h3 className="text-lg font-medium text-slate-900 dark:text-white">
<h3 className="text-text-neutral-primary text-lg font-medium">
No mute rules yet
</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
<p className="text-text-neutral-secondary mt-1 text-sm">
Mute rules are created when you mute findings from the Findings
page. Select findings and click &quot;Mute&quot; to create your
first rule.
@@ -46,63 +53,104 @@ export async function MuteRulesTable({ searchParams }: MuteRulesTableProps) {
}
return (
<Card variant="base" className="p-6">
<div className="mb-6">
<h3 className="text-default-700 mb-2 text-lg font-semibold">
Simple Mutelist Rules
</h3>
<ul className="text-default-600 list-disc pl-5 text-sm">
<li>
<strong>
These rules take effect immediately on existing findings.
</strong>
</li>
<li>
Create rules by selecting findings from the Findings page and
clicking &quot;Mute&quot;.
</li>
<li>Toggle rules on/off to enable or disable muting.</li>
</ul>
</div>
<MuteRulesTableClient
muteRules={muteRules}
metadata={
muteRulesData?.meta
? { ...muteRulesData.meta, version: "" }
: undefined
}
/>
</Card>
<MuteRulesTableClient
muteRules={muteRules}
metadata={
muteRulesData?.meta ? { ...muteRulesData.meta, version: "" } : undefined
}
/>
);
}
function MuteRulesSkeletonRow() {
return (
<tr className="border-border-neutral-secondary border-b last:border-b-0">
<td className="px-3 py-4">
<Skeleton className="h-4 w-32 rounded" />
</td>
<td className="px-3 py-4">
<Skeleton className="h-4 w-56 rounded" />
</td>
<td className="px-3 py-4">
<div className="border-border-neutral-secondary flex w-[240px] items-center gap-2 rounded-md border px-3 py-2">
<Skeleton className="h-4 flex-1 rounded" />
<Skeleton className="size-4 rounded" />
</div>
</td>
<td className="px-3 py-4">
<Skeleton className="h-4 w-28 rounded" />
</td>
<td className="px-3 py-4">
<Skeleton className="h-6 w-12 rounded-full" />
</td>
<td className="px-2 py-4">
<Skeleton className="size-8 rounded-md" />
</td>
</tr>
);
}
export function MuteRulesTableSkeleton() {
return (
<div className="flex flex-col gap-4">
<div className="rounded-lg border border-slate-200 dark:border-slate-800">
<div className="border-b border-slate-200 p-4 dark:border-slate-800">
<div className="flex gap-8">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-16" />
<div
data-testid="mute-rules-table-skeleton"
className="rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary flex w-full flex-col gap-4 overflow-hidden border p-4"
>
<div
data-testid="mute-rules-table-skeleton-intro"
className="flex flex-col gap-1.5"
>
<Skeleton className="h-5 w-40 rounded" />
<Skeleton className="h-3 w-[28rem] max-w-full rounded" />
</div>
<div className="flex items-center justify-between">
<Skeleton className="h-9 w-64 rounded-md" />
<Skeleton className="h-4 w-24 rounded" />
</div>
<table className="w-full" aria-hidden="true">
<thead>
<tr className="border-border-neutral-secondary border-b">
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-12 rounded" />
</th>
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-14 rounded" />
</th>
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-16 rounded" />
</th>
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-16 rounded" />
</th>
<th className="px-3 py-3 text-left">
<Skeleton className="h-4 w-16 rounded" />
</th>
<th className="w-10 py-3" />
</tr>
</thead>
<tbody>
{Array.from({ length: 8 }).map((_, index) => (
<MuteRulesSkeletonRow key={index} />
))}
</tbody>
</table>
<div className="flex items-center justify-between pt-2">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-24 rounded" />
<Skeleton className="h-9 w-16 rounded-md" />
</div>
<div className="flex items-center gap-4">
<Skeleton className="h-4 w-24 rounded" />
<div className="flex gap-1">
<Skeleton className="size-9 rounded-md" />
<Skeleton className="size-9 rounded-md" />
<Skeleton className="size-9 rounded-md" />
<Skeleton className="size-9 rounded-md" />
</div>
</div>
{[...Array(5)].map((_, i) => (
<div
key={i}
className="flex items-center gap-8 border-b border-slate-200 p-4 last:border-0 dark:border-slate-800"
>
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-28" />
<Skeleton className="h-5 w-10" />
<Skeleton className="size-8 rounded-full" />
</div>
))}
</div>
</div>
);
@@ -1,4 +1,4 @@
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
@@ -98,6 +98,8 @@ describe("MuteFindingsModal", () => {
expect(
screen.getByText("You are about to mute", { exact: false }),
).toBeInTheDocument();
expect(screen.getByText("Selected findings")).toBeInTheDocument();
expect(screen.getByText("Rule details")).toBeInTheDocument();
expect(screen.getByLabelText("Rule Name")).toBeInTheDocument();
expect(screen.getByLabelText("Reason")).toBeInTheDocument();
expect(
@@ -106,6 +108,8 @@ describe("MuteFindingsModal", () => {
expect(
screen.getByText("Explain why these findings are being muted"),
).toBeInTheDocument();
expect(screen.getByText("0/500 characters")).toBeInTheDocument();
expect(screen.getByLabelText("Reason")).toHaveAttribute("maxLength", "500");
});
it("renders the preparing state and blocks submission", () => {
@@ -119,10 +123,12 @@ describe("MuteFindingsModal", () => {
);
expect(
screen.getByText("Preparing findings to mute...", { exact: false }),
screen.getByText("Preparing mute rule", { exact: false }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Preparing..." })).toBeDisabled();
expect(screen.queryByLabelText("Rule Name")).not.toBeInTheDocument();
expect(screen.queryByTestId("spinner")).not.toBeInTheDocument();
expect(screen.getAllByTestId("skeleton").length).toBeGreaterThanOrEqual(8);
});
it("submits the form, shows the success toast, and closes the modal", async () => {
@@ -162,4 +168,23 @@ describe("MuteFindingsModal", () => {
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
it("clamps oversized reason input and shows a local validation error", () => {
render(
<MuteFindingsModal
isOpen
onOpenChange={vi.fn()}
findingIds={["finding-1"]}
/>,
);
fireEvent.change(screen.getByLabelText("Reason"), {
target: { value: "a".repeat(501) },
});
expect(screen.getByText("500/500 characters")).toBeInTheDocument();
expect(
screen.getByText("Reason must be 500 characters or fewer"),
).toBeInTheDocument();
});
});
+168 -138
View File
@@ -1,15 +1,19 @@
"use client";
import { Dispatch, SetStateAction, useState, useTransition } from "react";
import { Dispatch, SetStateAction, useState } from "react";
import { createMuteRule } from "@/actions/mute-rules";
import { MuteRuleActionState } from "@/actions/mute-rules/types";
import { Button, Input, Textarea } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { useToast } from "@/components/ui";
import { FormButtons } from "@/components/ui/form";
import { Label } from "@/components/ui/form/Label";
import { useMuteRuleAction } from "@/hooks/use-mute-rule-action";
import {
enforceMuteRuleReasonLimit,
getMuteRuleReasonCounterText,
} from "@/lib/mute-rules";
interface MuteFindingsModalProps {
isOpen: boolean;
@@ -30,9 +34,10 @@ export function MuteFindingsModal({
isPreparing = false,
preparationError = null,
}: MuteFindingsModalProps) {
const { toast } = useToast();
const [state, setState] = useState<MuteRuleActionState | null>(null);
const [isPending, startTransition] = useTransition();
const [reason, setReason] = useState("");
const [reasonLengthError, setReasonLengthError] = useState<string>();
const { isPending, runAction } = useMuteRuleAction();
const handleCancel = () => {
onOpenChange(false);
@@ -44,17 +49,27 @@ export function MuteFindingsModal({
findingIds.length === 0 ||
Boolean(preparationError);
const nameError = state?.errors?.name;
const reasonError = state?.errors?.reason;
const reasonError = reasonLengthError || state?.errors?.reason;
const handleReasonChange = (
event: React.ChangeEvent<HTMLTextAreaElement>,
) => {
const nextReason = enforceMuteRuleReasonLimit(event.target.value);
setReason(nextReason.value);
setReasonLengthError(nextReason.error);
};
return (
<Modal
open={isOpen}
onOpenChange={onOpenChange}
title="Mute Findings"
description="Create a mute rule for the selected findings."
size="lg"
>
<form
className="flex flex-col gap-4"
className="flex flex-col gap-5"
onSubmit={(e) => {
e.preventDefault();
if (isSubmitDisabled) {
@@ -62,30 +77,23 @@ export function MuteFindingsModal({
}
const formData = new FormData(e.currentTarget);
formData.set("reason", reason);
startTransition(() => {
void (async () => {
const result = await createMuteRule(null, formData);
if (!result) return;
const nextReason = enforceMuteRuleReasonLimit(reason);
if (nextReason.error) {
setReasonLengthError(nextReason.error);
return;
}
if (result.success) {
toast({
title: "Success",
description: isBulkOperation
? "Mute rule created. It may take a few minutes for all findings to update."
: result.success,
});
onComplete?.();
onOpenChange(false);
} else if (result.errors?.general) {
toast({
variant: "destructive",
title: "Error",
description: result.errors.general,
});
}
setState(result);
})();
runAction(() => createMuteRule(null, formData), {
setState,
successMessage: isBulkOperation
? "Mute rule created. It may take a few minutes for all findings to update."
: undefined,
onSuccess: () => {
onComplete?.();
onOpenChange(false);
},
});
}}
>
@@ -97,31 +105,33 @@ export function MuteFindingsModal({
{isPreparing ? (
<>
<div className="rounded-lg bg-slate-50 p-4 dark:bg-slate-800/50">
<div className="flex items-start gap-3">
<Spinner className="mt-0.5 size-5 shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium text-slate-900 dark:text-white">
Preparing findings to mute...
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Large finding groups can take a few seconds while we gather
the matching findings.
</p>
</div>
</div>
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary rounded-xl border p-4">
<p className="text-text-neutral-primary text-sm font-medium">
Preparing mute rule
</p>
<p className="text-text-neutral-tertiary mt-1 text-xs">
Large finding groups can take a few seconds while we gather the
matching findings for this rule.
</p>
</div>
<div className="space-y-3" aria-hidden="true">
<div className="space-y-2">
<Skeleton className="h-4 w-24 rounded" />
<Skeleton className="h-11 w-full rounded-lg" />
<Skeleton className="h-3 w-40 rounded" />
<div className="space-y-4" aria-hidden="true">
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary space-y-3 rounded-xl border p-4">
<Skeleton className="h-3 w-24 rounded" />
<Skeleton className="h-5 w-36 rounded" />
<Skeleton className="h-4 w-56 rounded" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-20 rounded" />
<Skeleton className="h-24 w-full rounded-lg" />
<Skeleton className="h-3 w-44 rounded" />
<div className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-3 w-20 rounded" />
<Skeleton className="h-11 w-full rounded-lg" />
<Skeleton className="h-3 w-44 rounded" />
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-20 rounded" />
<Skeleton className="h-28 w-full rounded-lg" />
<Skeleton className="h-3 w-36 rounded" />
</div>
</div>
</div>
@@ -135,18 +145,17 @@ export function MuteFindingsModal({
Cancel
</Button>
<Button type="button" size="lg" disabled>
<Spinner className="size-4" />
Preparing...
</Button>
</div>
</>
) : preparationError ? (
<>
<div className="rounded-lg bg-slate-50 p-4 dark:bg-slate-800/50">
<p className="text-sm font-medium text-slate-900 dark:text-white">
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary rounded-xl border p-4">
<p className="text-text-neutral-primary text-sm font-medium">
We couldn&apos;t prepare this mute action.
</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
<p className="text-text-neutral-secondary mt-1 text-xs">
{preparationError}
</p>
</div>
@@ -164,91 +173,112 @@ export function MuteFindingsModal({
</>
) : (
<>
<div className="rounded-lg bg-slate-50 p-3 dark:bg-slate-800/50">
<p className="text-sm text-slate-600 dark:text-slate-400">
You are about to mute{" "}
<span className="font-semibold text-slate-900 dark:text-white">
{findingIds.length}
</span>{" "}
{findingIds.length === 1 ? "finding" : "findings"}.
</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-500">
Muted findings will be hidden by default but can be shown using
filters.
</p>
</div>
<div className="space-y-2">
<label
className="text-sm font-medium text-slate-900 dark:text-white"
htmlFor="mute-rule-name"
>
Rule Name
</label>
<Input
id="mute-rule-name"
name="name"
placeholder="e.g., Ignore dev environment S3 buckets"
required
disabled={isPending}
aria-invalid={nameError ? "true" : "false"}
aria-describedby={
nameError
? "mute-rule-name-error"
: "mute-rule-name-description"
}
/>
<p
id="mute-rule-name-description"
className="text-xs text-slate-500 dark:text-slate-400"
>
A descriptive name for this mute rule
</p>
{nameError ? (
<p
id="mute-rule-name-error"
className="text-text-error-primary text-xs"
>
{nameError}
<div className="space-y-4">
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary rounded-xl border p-4">
<p className="text-text-neutral-tertiary text-xs font-medium tracking-[0.08em] uppercase">
Selected findings
</p>
) : null}
</div>
<div className="space-y-2">
<label
className="text-sm font-medium text-slate-900 dark:text-white"
htmlFor="mute-rule-reason"
>
Reason
</label>
<Textarea
id="mute-rule-reason"
name="reason"
placeholder="e.g., These are expected findings in the development environment"
required
disabled={isPending}
rows={4}
aria-invalid={reasonError ? "true" : "false"}
aria-describedby={
reasonError
? "mute-rule-reason-error"
: "mute-rule-reason-description"
}
/>
<p
id="mute-rule-reason-description"
className="text-xs text-slate-500 dark:text-slate-400"
>
Explain why these findings are being muted
</p>
{reasonError ? (
<p
id="mute-rule-reason-error"
className="text-text-error-primary text-xs"
>
{reasonError}
<p className="text-text-neutral-secondary mt-2 text-sm">
You are about to mute{" "}
<span className="text-text-neutral-primary font-semibold">
{findingIds.length}
</span>{" "}
{findingIds.length === 1 ? "finding" : "findings"}.
</p>
) : null}
<p className="text-text-neutral-tertiary mt-1 text-xs">
Muted findings remain hidden by default and can still be
reviewed by enabling muted filters.
</p>
</div>
<div className="space-y-4">
<div>
<p className="text-text-neutral-tertiary text-xs font-medium tracking-[0.08em] uppercase">
Rule details
</p>
</div>
<div className="space-y-2">
<Label
className="text-text-neutral-secondary text-xs font-light tracking-tight"
htmlFor="mute-rule-name"
>
Rule Name
</Label>
<Input
id="mute-rule-name"
name="name"
placeholder="e.g., Ignore dev environment S3 buckets"
required
disabled={isPending}
aria-invalid={nameError ? "true" : "false"}
aria-describedby={
nameError
? "mute-rule-name-error"
: "mute-rule-name-description"
}
/>
<p
id="mute-rule-name-description"
className="text-text-neutral-tertiary text-xs"
>
A descriptive name for this mute rule
</p>
{nameError ? (
<p
id="mute-rule-name-error"
className="text-text-error-primary text-xs"
>
{nameError}
</p>
) : null}
</div>
<div className="space-y-2">
<Label
className="text-text-neutral-secondary text-xs font-light tracking-tight"
htmlFor="mute-rule-reason"
>
Reason
</Label>
<Textarea
id="mute-rule-reason"
name="reason"
placeholder="e.g., These are expected findings in the development environment"
required
disabled={isPending}
value={reason}
onChange={handleReasonChange}
rows={4}
maxLength={500}
aria-invalid={reasonError ? "true" : "false"}
aria-describedby={
reasonError
? "mute-rule-reason-error"
: "mute-rule-reason-description"
}
/>
<div className="flex items-center justify-between gap-3">
<p
id="mute-rule-reason-description"
className="text-text-neutral-tertiary text-xs"
>
Explain why these findings are being muted
</p>
<p className="text-text-neutral-tertiary shrink-0 text-xs">
{getMuteRuleReasonCounterText(reason)}
</p>
</div>
{reasonError ? (
<p
id="mute-rule-reason-error"
className="text-text-error-primary text-xs"
>
{reasonError}
</p>
) : null}
</div>
</div>
</div>
<FormButtons
@@ -85,6 +85,7 @@ export function getStandaloneFindingColumns({
isMuted={finding.attributes.muted}
mutedReason={finding.attributes.muted_reason}
showDeltaWhenMuted
reserveMutedSlot
/>
);
},
@@ -0,0 +1,106 @@
import { render } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
vi.mock("next/link", () => ({
default: ({
children,
href,
...props
}: {
children: ReactNode;
href: string;
}) => (
<a href={href} {...props}>
{children}
</a>
),
}));
vi.mock("@/components/icons", () => ({
MutedIcon: (props: Record<string, unknown>) => (
<svg data-testid="muted-icon" {...props} />
),
}));
vi.mock("@/components/shadcn", () => ({
Button: ({
children,
asChild,
...props
}: {
children: ReactNode;
asChild?: boolean;
}) =>
asChild ? (
children
) : (
<button type="button" {...props}>
{children}
</button>
),
}));
vi.mock("@/components/shadcn/popover", () => ({
Popover: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock("@/components/shadcn/tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
}));
import { NotificationIndicator } from "./notification-indicator";
describe("NotificationIndicator", () => {
it("reserves the muted slot for delta-only rows when requested", () => {
const { container } = render(
<NotificationIndicator delta="new" showDeltaWhenMuted reserveMutedSlot />,
);
const root = container.querySelector(
'[data-slot="notification-indicator"]',
);
const mutedSlot = container.querySelector(
'[data-slot="notification-muted-slot"]',
);
const deltaSlot = container.querySelector(
'[data-slot="notification-delta-slot"]',
);
expect(root).toBeInTheDocument();
expect(mutedSlot).toBeInTheDocument();
expect(deltaSlot).toBeInTheDocument();
expect(mutedSlot?.children).toHaveLength(0);
expect(deltaSlot?.children).toHaveLength(1);
});
it("keeps both reserved slots populated for muted rows with delta", () => {
const { container } = render(
<NotificationIndicator
delta="changed"
isMuted
mutedReason="False positive"
showDeltaWhenMuted
reserveMutedSlot
/>,
);
const mutedSlot = container.querySelector(
'[data-slot="notification-muted-slot"]',
);
const deltaSlot = container.querySelector(
'[data-slot="notification-delta-slot"]',
);
expect(mutedSlot?.children).toHaveLength(1);
expect(deltaSlot?.children).toHaveLength(1);
});
});
@@ -28,6 +28,7 @@ interface NotificationIndicatorProps {
isMuted?: boolean;
mutedReason?: string;
showDeltaWhenMuted?: boolean;
reserveMutedSlot?: boolean;
}
export const NotificationIndicator = ({
@@ -35,9 +36,32 @@ export const NotificationIndicator = ({
isMuted = false,
mutedReason,
showDeltaWhenMuted = false,
reserveMutedSlot = false,
}: NotificationIndicatorProps) => {
const hasDelta = delta === DeltaValues.NEW || delta === DeltaValues.CHANGED;
if (showDeltaWhenMuted && reserveMutedSlot) {
return (
<div
data-slot="notification-indicator"
className="flex shrink-0 items-center gap-1"
>
<div
data-slot="notification-muted-slot"
className="flex w-5 shrink-0 items-center justify-center"
>
{isMuted ? <MutedIndicator mutedReason={mutedReason} /> : null}
</div>
<div
data-slot="notification-delta-slot"
className="flex w-2 shrink-0 items-center justify-center"
>
{hasDelta ? <DeltaIndicator delta={delta} /> : null}
</div>
</div>
);
}
if (isMuted && hasDelta && showDeltaWhenMuted) {
return (
<div className="flex shrink-0 items-center gap-1">
@@ -970,7 +970,7 @@ export function ResourceDetailDrawerContent({
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10" />
<TableHead className="w-14" />
<TableHead>
<span className="text-text-neutral-secondary text-sm font-medium">
Status
@@ -1312,18 +1312,21 @@ function OtherFindingRow({
className="cursor-pointer"
onClick={() => window.open(findingUrl, "_blank", "noopener,noreferrer")}
>
<TableCell className="w-10">
<NotificationIndicator
isMuted={isMuted}
delta={
finding.delta === DeltaValues.NEW ||
finding.delta === DeltaValues.CHANGED
? finding.delta
: undefined
}
mutedReason={finding.mutedReason ?? undefined}
showDeltaWhenMuted
/>
<TableCell className="w-14">
<div className="flex items-center justify-center">
<NotificationIndicator
isMuted={isMuted}
delta={
finding.delta === DeltaValues.NEW ||
finding.delta === DeltaValues.CHANGED
? finding.delta
: undefined
}
mutedReason={finding.mutedReason ?? undefined}
showDeltaWhenMuted
reserveMutedSlot
/>
</div>
</TableCell>
<TableCell>
<StatusFindingBadge status={finding.status as FindingStatus} />
@@ -55,6 +55,7 @@ export const getResourceFindingsColumns = (
isMuted={row.original.attributes.muted}
mutedReason={row.original.attributes.muted_reason}
showDeltaWhenMuted
reserveMutedSlot
/>
</div>
),
+5
View File
@@ -22,6 +22,8 @@ const SIZE_CLASSES = {
type ModalSize = keyof typeof SIZE_CLASSES;
const preventInitialAutoFocus = (event: Event) => event.preventDefault();
interface ModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -30,6 +32,7 @@ interface ModalProps {
children: ReactNode;
size?: ModalSize;
className?: string;
onOpenAutoFocus?: (event: Event) => void;
}
export const Modal = ({
@@ -40,10 +43,12 @@ export const Modal = ({
children,
size = "xl",
className,
onOpenAutoFocus = preventInitialAutoFocus,
}: ModalProps) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
onOpenAutoFocus={onOpenAutoFocus}
className={cn(
"border-border-neutral-secondary bg-bg-neutral-secondary",
SIZE_CLASSES[size],
+116 -14
View File
@@ -1,24 +1,126 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
const { useFindingGroupResourcesMock, useResourceDetailDrawerMock } =
vi.hoisted(() => ({
useFindingGroupResourcesMock: vi.fn(),
useResourceDetailDrawerMock: vi.fn(),
}));
vi.mock("@/hooks/use-finding-group-resources", () => ({
useFindingGroupResources: useFindingGroupResourcesMock,
}));
vi.mock("@/lib", () => ({
applyDefaultMutedFilter: (
filters: Record<string, string | string[] | undefined>,
) => ({
"filter[muted]": "false",
...filters,
}),
}));
vi.mock("@/components/findings/table/resource-detail-drawer", () => ({
useResourceDetailDrawer: useResourceDetailDrawerMock,
}));
import { type FindingGroupRow, FINDINGS_ROW_TYPE } from "@/types";
import { useFindingGroupResourceState } from "./use-finding-group-resource-state";
const group: FindingGroupRow = {
id: "group-1",
rowType: FINDINGS_ROW_TYPE.GROUP,
checkId: "s3_bucket_public_access",
checkTitle: "S3 Bucket Public Access",
severity: "high",
status: "FAIL",
resourcesTotal: 3,
resourcesFail: 2,
newCount: 1,
changedCount: 0,
mutedCount: 0,
providers: ["aws"],
updatedAt: "2026-04-22T10:00:00Z",
};
describe("useFindingGroupResourceState", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "use-finding-group-resource-state.ts");
const source = readFileSync(filePath, "utf8");
beforeEach(() => {
vi.clearAllMocks();
it("defaults drill-down resource loading through the shared muted filter helper", () => {
expect(source).toContain("applyDefaultMutedFilter(filters)");
useFindingGroupResourcesMock.mockReturnValue({
sentinelRef: vi.fn(),
refresh: vi.fn(),
loadMore: vi.fn(),
totalCount: 3,
});
useResourceDetailDrawerMock.mockReturnValue({
isOpen: false,
isLoading: false,
isNavigating: false,
checkMeta: null,
currentIndex: 0,
totalResources: 3,
currentResource: null,
currentFinding: null,
otherFindings: [],
openDrawer: vi.fn(),
closeDrawer: vi.fn(),
navigatePrev: vi.fn(),
navigateNext: vi.fn(),
refetchCurrent: vi.fn(),
});
});
it("enables muted findings only for the finding-group resource drawer", () => {
expect(source).toContain("includeMutedInOtherFindings: true");
it("applies the shared default muted filter when the user has not opted in", () => {
renderHook(() =>
useFindingGroupResourceState({
group,
filters: {
"filter[provider_type__in]": "aws",
},
hasHistoricalData: false,
}),
);
expect(useFindingGroupResourcesMock).toHaveBeenCalledWith(
expect.objectContaining({
filters: {
"filter[provider_type__in]": "aws",
"filter[muted]": "false",
},
}),
);
expect(useResourceDetailDrawerMock).toHaveBeenCalledWith(
expect.objectContaining({
includeMutedInOtherFindings: false,
}),
);
});
it("applies the shared default muted filter before fetching group resources", () => {
expect(source).toContain("applyDefaultMutedFilter(filters)");
expect(source).toContain("filters: effectiveFilters");
it("includes muted findings in the drawer only when filter[muted]=include is active", () => {
renderHook(() =>
useFindingGroupResourceState({
group,
filters: {
"filter[muted]": "include",
},
hasHistoricalData: false,
}),
);
expect(useFindingGroupResourcesMock).toHaveBeenCalledWith(
expect.objectContaining({
filters: {
"filter[muted]": "include",
},
}),
);
expect(useResourceDetailDrawerMock).toHaveBeenCalledWith(
expect.objectContaining({
includeMutedInOtherFindings: true,
}),
);
});
});
+1 -1
View File
@@ -86,7 +86,7 @@ export function useFindingGroupResourceState({
totalResourceCount: totalCount ?? group.resourcesTotal,
onRequestMoreResources: loadMore,
canLoadOtherFindings: group.resourcesTotal !== 0,
includeMutedInOtherFindings: true,
includeMutedInOtherFindings: filters["filter[muted]"] === "include",
});
const handleDrawerMuteComplete = () => {
+114
View File
@@ -0,0 +1,114 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { toastMock } = vi.hoisted(() => ({
toastMock: vi.fn(),
}));
vi.mock("@/components/ui", () => ({
useToast: () => ({
toast: toastMock,
}),
}));
import { useMuteRuleAction } from "./use-mute-rule-action";
describe("useMuteRuleAction", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows a success toast and runs the success callback", async () => {
const onSuccess = vi.fn();
const setState = vi.fn();
const { result } = renderHook(() => useMuteRuleAction());
act(() => {
result.current.runAction(
async () => ({ success: "Mute rule updated successfully!" }),
{
setState,
onSuccess,
},
);
});
await waitFor(() => {
expect(setState).toHaveBeenCalledWith({
success: "Mute rule updated successfully!",
});
expect(toastMock).toHaveBeenCalledWith({
title: "Success",
description: "Mute rule updated successfully!",
});
expect(onSuccess).toHaveBeenCalledTimes(1);
});
});
it("allows overriding the success message", async () => {
const { result } = renderHook(() => useMuteRuleAction());
act(() => {
result.current.runAction(
async () => ({ success: "Server success message" }),
{
successMessage:
"Mute rule created. It may take a few minutes for all findings to update.",
},
);
});
await waitFor(() => {
expect(toastMock).toHaveBeenCalledWith({
title: "Success",
description:
"Mute rule created. It may take a few minutes for all findings to update.",
});
});
});
it("shows an error toast when the action returns a general error", async () => {
const onError = vi.fn();
const { result } = renderHook(() => useMuteRuleAction());
act(() => {
result.current.runAction(
async () => ({ errors: { general: "Delete failed" } }),
{
onError,
},
);
});
await waitFor(() => {
expect(toastMock).toHaveBeenCalledWith({
variant: "destructive",
title: "Error",
description: "Delete failed",
});
expect(onError).toHaveBeenCalledWith("Delete failed");
});
});
it("updates form state without showing a toast for field-level validation errors", async () => {
const setState = vi.fn();
const { result } = renderHook(() => useMuteRuleAction());
act(() => {
result.current.runAction(
async () => ({ errors: { name: "Name is required" } }),
{
setState,
},
);
});
await waitFor(() => {
expect(setState).toHaveBeenCalledWith({
errors: { name: "Name is required" },
});
});
expect(toastMock).not.toHaveBeenCalled();
});
});
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useTransition } from "react";
import { useToast } from "@/components/ui";
type MuteRuleActionResult = {
success?: string;
errors?: {
general?: string;
};
} | null;
interface RunMuteRuleActionOptions<T extends MuteRuleActionResult> {
setState?: (result: T) => void;
onSuccess?: () => void;
onError?: (message: string) => void;
successTitle?: string;
errorTitle?: string;
successMessage?: string;
}
export function useMuteRuleAction() {
const { toast } = useToast();
const [isPending, startTransition] = useTransition();
const runAction = <T extends MuteRuleActionResult>(
execute: () => Promise<T>,
options: RunMuteRuleActionOptions<T> = {},
) => {
startTransition(() => {
void (async () => {
const result = await execute();
options.setState?.(result);
if (!result) {
return;
}
if (result.success) {
toast({
title: options.successTitle ?? "Success",
description: options.successMessage ?? result.success,
});
options.onSuccess?.();
return;
}
const errorMessage = result.errors?.general;
if (errorMessage) {
toast({
variant: "destructive",
title: options.errorTitle ?? "Error",
description: errorMessage,
});
options.onError?.(errorMessage);
}
})();
});
};
return {
isPending,
runAction,
};
}
+1
View File
@@ -5,6 +5,7 @@ export * from "./findings-sort";
export * from "./helper";
export * from "./helper-filters";
export * from "./menu-list";
export * from "./mute-rules";
export * from "./permissions";
export * from "./provider-helpers";
export * from "./utils";
+21
View File
@@ -0,0 +1,21 @@
export const MAX_MUTE_RULE_REASON_LENGTH = 500;
export const MUTE_RULE_REASON_TOO_LONG_MESSAGE = `Reason must be ${MAX_MUTE_RULE_REASON_LENGTH} characters or fewer`;
export function getMuteRuleReasonCounterText(reason: string): string {
return `${reason.length}/${MAX_MUTE_RULE_REASON_LENGTH} characters`;
}
export function enforceMuteRuleReasonLimit(reason: string): {
value: string;
error?: string;
} {
if (reason.length <= MAX_MUTE_RULE_REASON_LENGTH) {
return { value: reason };
}
return {
value: reason.slice(0, MAX_MUTE_RULE_REASON_LENGTH),
error: MUTE_RULE_REASON_TOO_LONG_MESSAGE,
};
}