mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
feat(ui): improve Mutelist UX and mute modal (#10846)
This commit is contained in:
@@ -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'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 "
|
||||
{selectedMuteRule.attributes.name}"? 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 "Mute" 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 "Mute".
|
||||
</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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'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">
|
||||
|
||||
+16
-13
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user