Files
fusion/packages/dashboard/app/components/__tests__/TaskDetailModal.refine.test.tsx
gsxdsm 30d2e3660d FN-7927: fix Refine feedback modal self-dismissing immediately after opening
The task-detail Refine overlay used a raw onClick backdrop handler with a stopPropagation-wrapped inner modal, so the same click/touch sequence that opened Refine could bubble into the backdrop handler and close it right away; route it through the shared useOverlayDismiss contract instead so it behaves like every other dashboard modal.

- Compute refineOverlayDismissProps via useOverlayDismiss(handleCloseRefineModal) and spread it onto the refine overlay instead of a plain onClick handler
- Drop the redundant stopPropagation-only onClick from the inner .detail-refine-modal div now that the overlay itself no longer misfires on the opening interaction
- Update docs/dashboard-guide.md to document that the Refine modal (Board and List entry points) stays open until an explicit close or an enabled backdrop dismissal
- Add TaskDetailModal.refine.test.tsx regression coverage for the modal staying open across the opening interaction and honoring the dismiss-preference gate
- Add a patch changeset summarizing the fix for @runfusion/fusion release notes

Files changed:
 .changeset/fn-7927-refine-modal.md                 |   7 +
 docs/dashboard-guide.md                            |   8 +-
 .../dashboard/app/components/TaskDetailModal.tsx   |  12 +-
 .../__tests__/TaskDetailModal.refine.test.tsx      | 182 +++++++++++++++++++++
 4 files changed, 201 insertions(+), 8 deletions(-)

Fusion-Task-Id: FN-7927

Fusion-Task-Lineage: c7e7cd4a-d103-47e6-93ce-6577147b4795

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-12 23:37:54 -07:00

183 lines
6.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
makeTask,
noop,
noopDelete,
noopMerge,
noopMove,
noopOpenDetail,
setupTaskDetailModalHooks,
} from "./TaskDetailModal.test-helpers";
import { TaskDetailModal } from "../TaskDetailModal";
import { ModalDismissPreferenceProvider } from "../../hooks/useOverlayDismiss";
import { refineTask } from "../../api";
setupTaskDetailModalHooks();
const renderDoneTaskDetail = (options: {
column?: "done" | "in-review";
initialAction?: { action: "refine"; requestId: number };
addToast?: (message: string, type?: any) => void;
onClose?: () => void;
dismissPreferenceEnabled?: boolean;
} = {}) => {
const modal = (
<TaskDetailModal
task={makeTask({ id: "FN-001", column: options.column ?? "done", status: options.column === "in-review" ? "review" as any : "done" as any })}
initialTab="definition"
initialAction={options.initialAction}
onClose={options.onClose ?? noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={options.addToast ?? noop}
/>
);
return render(
options.dismissPreferenceEnabled === undefined
? modal
: <ModalDismissPreferenceProvider enabled={options.dismissPreferenceEnabled}>{modal}</ModalDismissPreferenceProvider>,
);
};
const openRefineFromActionsMenu = () => {
fireEvent.click(screen.getByRole("button", { name: "Actions" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Refine" }));
};
const openRefineFromActionsMenuTouch = () => {
fireEvent.click(screen.getByRole("button", { name: "Actions" }));
const refineItem = screen.getByRole("menuitem", { name: "Refine" });
fireEvent.pointerUp(refineItem, { pointerType: "touch" });
};
const expectRefineComposerOpen = () => {
expect(screen.getByText("Refine", { selector: "h3" })).toBeInTheDocument();
expect(screen.getByPlaceholderText("Enter your feedback here...")).toBeInTheDocument();
};
const refineOverlay = () => {
const overlay = document.querySelector(".detail-refine-overlay");
expect(overlay).toBeInstanceOf(HTMLElement);
return overlay as HTMLElement;
};
/*
FNXC:TaskDetailRefine 2026-07-12-00:00:
The refine dialog must stay open across desktop menu clicks, mobile pointer activation, initialAction deep links, and Android compatibility mouse events; only explicit controls, Escape, or preference-enabled real backdrop presses may close it.
*/
describe("TaskDetailModal refine modal dismissal invariant", () => {
beforeEach(() => {
vi.mocked(refineTask).mockClear();
vi.mocked(refineTask).mockResolvedValue({ id: "FN-002", column: "todo" } as any);
});
it("keeps the in-modal actions-menu refine dialog open through the desktop opening click", () => {
renderDoneTaskDetail({ column: "done" });
openRefineFromActionsMenu();
expectRefineComposerOpen();
});
it("keeps the in-review refine dialog open through mobile touch activation and Android compatibility mouse events", () => {
renderDoneTaskDetail({ column: "in-review" });
openRefineFromActionsMenuTouch();
expectRefineComposerOpen();
const overlay = refineOverlay();
fireEvent.touchEnd(document);
fireEvent.mouseDown(overlay);
fireEvent.mouseUp(overlay);
fireEvent.click(overlay);
expectRefineComposerOpen();
});
it("keeps the initialAction refine dialog open through Android compatibility mouse events after touchend", () => {
renderDoneTaskDetail({ initialAction: { action: "refine", requestId: 1 } });
expectRefineComposerOpen();
const overlay = refineOverlay();
fireEvent.touchEnd(document);
fireEvent.mouseDown(overlay);
fireEvent.mouseUp(overlay);
fireEvent.click(overlay);
expectRefineComposerOpen();
});
it("matches global backdrop-dismiss preference semantics", () => {
const { unmount } = renderDoneTaskDetail({ dismissPreferenceEnabled: false });
openRefineFromActionsMenu();
const disabledOverlay = refineOverlay();
fireEvent.mouseDown(disabledOverlay);
fireEvent.mouseUp(disabledOverlay);
fireEvent.click(disabledOverlay);
expectRefineComposerOpen();
unmount();
renderDoneTaskDetail({ dismissPreferenceEnabled: true });
openRefineFromActionsMenu();
const enabledOverlay = refineOverlay();
fireEvent.mouseDown(enabledOverlay);
fireEvent.mouseUp(enabledOverlay);
expect(screen.queryByPlaceholderText("Enter your feedback here...")).not.toBeInTheDocument();
});
it("preserves explicit close, cancel, and Escape close paths", () => {
const firstRender = renderDoneTaskDetail();
openRefineFromActionsMenu();
const modal = document.querySelector(".detail-refine-modal");
expect(modal).toBeInstanceOf(HTMLElement);
fireEvent.click(within(modal as HTMLElement).getByRole("button", { name: "Close" }));
expect(screen.queryByPlaceholderText("Enter your feedback here...")).not.toBeInTheDocument();
openRefineFromActionsMenu();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByPlaceholderText("Enter your feedback here...")).not.toBeInTheDocument();
firstRender.unmount();
const onClose = vi.fn();
renderDoneTaskDetail({ onClose });
openRefineFromActionsMenu();
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
});
it("preserves refinement validation and submit behavior", async () => {
const user = userEvent.setup();
const addToast = vi.fn();
const onClose = vi.fn();
renderDoneTaskDetail({ addToast, onClose });
openRefineFromActionsMenu();
expect(screen.getByRole("button", { name: "Create Refinement Task" })).toBeDisabled();
expect(refineTask).not.toHaveBeenCalled();
fireEvent.change(screen.getByPlaceholderText("Enter your feedback here..."), { target: { value: "x".repeat(2001) } });
fireEvent.click(screen.getByRole("button", { name: "Create Refinement Task" }));
expect(addToast).toHaveBeenCalledWith("Feedback must be 2000 characters or less", "error");
expect(refineTask).not.toHaveBeenCalled();
fireEvent.change(screen.getByPlaceholderText("Enter your feedback here..."), { target: { value: "Please add the missing regression coverage" } });
await user.click(screen.getByRole("button", { name: "Create Refinement Task" }));
await waitFor(() => {
expect(refineTask).toHaveBeenCalledWith("FN-001", "Please add the missing regression coverage", undefined);
expect(addToast).toHaveBeenCalledWith("Refinement task created: FN-002", "success");
expect(onClose).toHaveBeenCalledTimes(1);
});
});
});