-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+ |
+
+
+
+ {Array.from({ length: 8 }).map((_, index) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
- {[...Array(5)].map((_, i) => (
-
-
-
-
-
-
-
-
- ))}
);
diff --git a/ui/components/findings/mute-findings-modal.test.tsx b/ui/components/findings/mute-findings-modal.test.tsx
index dbcec60517..9127d8a1a1 100644
--- a/ui/components/findings/mute-findings-modal.test.tsx
+++ b/ui/components/findings/mute-findings-modal.test.tsx
@@ -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(
+
,
+ );
+
+ 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();
+ });
});
diff --git a/ui/components/findings/mute-findings-modal.tsx b/ui/components/findings/mute-findings-modal.tsx
index 9ada1aca53..dd339613e2 100644
--- a/ui/components/findings/mute-findings-modal.tsx
+++ b/ui/components/findings/mute-findings-modal.tsx
@@ -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
(null);
- const [isPending, startTransition] = useTransition();
+ const [reason, setReason] = useState("");
+ const [reasonLengthError, setReasonLengthError] = useState();
+ 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,
+ ) => {
+ const nextReason = enforceMuteRuleReasonLimit(event.target.value);
+
+ setReason(nextReason.value);
+ setReasonLengthError(nextReason.error);
+ };
return (