Files
multica/packages/views/modals/create-issue.test.tsx
Boynn 933f417dac fix(views): clear manual draft when packing into agent prompt (#2370)
When alternately switching between manual and agent modes in the create-issue
dialog, the title and description were being duplicated and accumulated on
every round-trip. Root cause: manual→agent packed title+description into the
agent prompt but left them in the shared useIssueDraftStore; the subsequent
agent→manual wrote the agent markdown into draft.description while the stale
draft.title persisted, so the remounted manual panel surfaced both.

Clear title/description from the shared draft at the moment they move into
the agent representation, so round-trips can't layer stale manual state on
top of prompt-as-description.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:50:08 +08:00

585 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { forwardRef, useImperativeHandle, useRef, useState, type ReactNode } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../locales/en/common.json";
import enModals from "../locales/en/modals.json";
const TEST_RESOURCES = {
en: { common: enCommon, modals: enModals },
};
function I18nWrapper({ children }: { children: ReactNode }) {
return (
<I18nProvider locale="en" resources={TEST_RESOURCES}>
{children}
</I18nProvider>
);
}
const mockPush = vi.hoisted(() => vi.fn());
const mockCreateIssue = vi.hoisted(() => vi.fn());
const mockSetDraft = vi.hoisted(() => vi.fn());
const mockClearDraft = vi.hoisted(() => vi.fn());
const mockSetLastAssignee = vi.hoisted(() => vi.fn());
const mockSetKeepOpen = vi.hoisted(() => vi.fn());
const mockToastCustom = vi.hoisted(() => vi.fn());
const mockToastDismiss = vi.hoisted(() => vi.fn());
const mockToastError = vi.hoisted(() => vi.fn());
const mockDraftStore = {
draft: {
title: "",
description: "",
status: "todo" as const,
priority: "none" as const,
assigneeType: undefined as "agent" | "squad" | "member" | undefined,
assigneeId: undefined as string | undefined,
startDate: null,
dueDate: null,
},
lastAssigneeType: undefined,
lastAssigneeId: undefined,
setDraft: mockSetDraft,
clearDraft: mockClearDraft,
setLastAssignee: mockSetLastAssignee,
};
const mockQuickCreateStore = {
keepOpen: false,
setKeepOpen: mockSetKeepOpen,
};
vi.mock("../navigation", () => ({
useNavigation: () => ({ push: mockPush }),
}));
vi.mock("@multica/core/paths", () => ({
useCurrentWorkspace: () => ({ name: "Test Workspace" }),
useWorkspacePaths: () => ({
issueDetail: (id: string) => `/ws-test/issues/${id}`,
}),
}));
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-test",
}));
vi.mock("@multica/core/issues/queries", () => ({
issueDetailOptions: (wsId: string, id: string) => ({
queryKey: ["issues", wsId, "detail", id],
queryFn: () => Promise.resolve(null),
}),
}));
vi.mock("@multica/core/issues/stores/draft-store", () => ({
useIssueDraftStore: Object.assign(
(selector?: (state: typeof mockDraftStore) => unknown) =>
(selector ? selector(mockDraftStore) : mockDraftStore),
{ getState: () => mockDraftStore },
),
}));
vi.mock("@multica/core/issues/stores/quick-create-store", () => ({
useQuickCreateStore: (selector?: (state: typeof mockQuickCreateStore) => unknown) =>
(selector ? selector(mockQuickCreateStore) : mockQuickCreateStore),
}));
vi.mock("@multica/core/issues/mutations", () => ({
useCreateIssue: () => ({ mutateAsync: mockCreateIssue }),
useUpdateIssue: () => ({ mutate: vi.fn() }),
}));
vi.mock("@multica/core/hooks/use-file-upload", () => ({
useFileUpload: () => ({ uploadWithToast: vi.fn() }),
}));
// Hoisted ApiError class so both the vi.mock factory and the tests below
// can construct/instanceof-check the same identity. vi.mock is hoisted, so
// a normal `class` declaration above it would still be in the TDZ at mock
// evaluation time.
const { ApiError } = vi.hoisted(() => {
class ApiErrorImpl extends Error {
readonly status: number;
readonly statusText: string;
readonly body?: unknown;
constructor(message: string, status: number, statusText: string, body?: unknown) {
super(message);
this.name = "ApiError";
this.status = status;
this.statusText = statusText;
this.body = body;
}
}
return { ApiError: ApiErrorImpl };
});
vi.mock("@multica/core/api", async () => {
// Pull real `parseWithFallback` + `DuplicateIssueErrorBodySchema` from the
// schema modules so the drift-fallback branch in create-issue.tsx runs the
// actual validation logic (not a stub). Only `ApiError` is local — the
// component imports it from this module and the cross-realm `instanceof`
// check requires a single class identity.
const { parseWithFallback } = await vi.importActual<typeof import("@multica/core/api/schema")>(
"@multica/core/api/schema",
);
const { DuplicateIssueErrorBodySchema } = await vi.importActual<
typeof import("@multica/core/api/schemas")
>("@multica/core/api/schemas");
return {
api: {},
ApiError,
parseWithFallback,
DuplicateIssueErrorBodySchema,
};
});
vi.mock("../editor", () => {
const ContentEditor = forwardRef(({ defaultValue, onUpdate, placeholder }: any, ref: any) => {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
getMarkdown: () => valueRef.current,
clearContent: () => {
valueRef.current = "";
setValue("");
},
uploadFile: vi.fn(),
}));
return (
<textarea
value={value}
placeholder={placeholder}
onChange={(e) => {
valueRef.current = e.target.value;
setValue(e.target.value);
onUpdate?.(e.target.value);
}}
/>
);
});
ContentEditor.displayName = "ContentEditor";
return {
useFileDropZone: () => ({ isDragOver: false, dropZoneProps: {} }),
FileDropOverlay: () => null,
ContentEditor,
TitleEditor: ({ defaultValue, placeholder, onChange, onSubmit }: any) => {
const [value, setValue] = useState(defaultValue || "");
return (
<input
value={value}
placeholder={placeholder}
onChange={(e) => {
setValue(e.target.value);
onChange?.(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") onSubmit?.();
}}
/>
);
},
};
});
vi.mock("../issues/components", () => ({
StatusIcon: ({ status }: { status: string }) => <span data-testid="status-icon">{status}</span>,
StatusPicker: () => <div data-testid="status-picker" />,
PriorityPicker: () => <div data-testid="priority-picker" />,
AssigneePicker: () => <div data-testid="assignee-picker" />,
StartDatePicker: () => <div data-testid="start-date-picker" />,
DueDatePicker: () => <div data-testid="due-date-picker" />,
}));
vi.mock("../projects/components/project-picker", () => ({
ProjectPicker: () => <div data-testid="project-picker" />,
}));
vi.mock("@multica/ui/components/ui/dialog", () => ({
Dialog: ({ children }: { children: React.ReactNode }) => <div data-testid="dialog-root">{children}</div>,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
DialogTitle: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}));
vi.mock("@multica/ui/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => (
<button type="button" onClick={onClick}>{children}</button>
),
DropdownMenuSeparator: () => null,
}));
vi.mock("./issue-picker-modal", () => ({
IssuePickerModal: () => null,
}));
vi.mock("@multica/ui/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@multica/ui/components/ui/button", () => ({
Button: ({
children,
disabled,
onClick,
type = "button",
}: {
children: React.ReactNode;
disabled?: boolean;
onClick?: () => void;
type?: "button" | "submit" | "reset";
}) => (
<button type={type} disabled={disabled} onClick={onClick}>
{children}
</button>
),
}));
vi.mock("@multica/ui/components/ui/switch", () => ({
Switch: ({
checked,
onCheckedChange,
}: {
checked: boolean;
onCheckedChange: (v: boolean) => void;
}) => (
<input
aria-label="Create another"
type="checkbox"
checked={checked}
onChange={(e) => onCheckedChange(e.target.checked)}
/>
),
}));
vi.mock("@multica/ui/components/common/file-upload-button", () => ({
FileUploadButton: ({ onSelect }: { onSelect: (file: File) => void }) => (
<button type="button" onClick={() => onSelect(new File(["test"], "test.txt"))}>
Upload file
</button>
),
}));
vi.mock("@multica/ui/lib/utils", () => ({
cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(" "),
}));
vi.mock("sonner", () => ({
toast: {
custom: mockToastCustom,
dismiss: mockToastDismiss,
error: mockToastError,
},
}));
import { CreateIssueModal, ManualCreatePanel } from "./create-issue";
function renderModal(element: React.ReactElement) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<I18nWrapper>
<QueryClientProvider client={qc}>{element}</QueryClientProvider>
</I18nWrapper>,
);
}
describe("CreateIssueModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockQuickCreateStore.keepOpen = false;
mockSetKeepOpen.mockImplementation((v: boolean) => {
mockQuickCreateStore.keepOpen = v;
});
// Reset the shared draft mock so per-test assignee seeding (squad / agent)
// doesn't leak into the next test in the suite.
mockDraftStore.draft.assigneeType = undefined;
mockDraftStore.draft.assigneeId = undefined;
mockCreateIssue.mockResolvedValue({
id: "issue-123",
identifier: "TES-123",
title: "Ship create issue regression coverage",
status: "todo",
});
});
it("shows success feedback with a direct path to the new issue", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderModal(<CreateIssueModal onClose={onClose} />);
fireEvent.change(screen.getByPlaceholderText("Issue title"), {
target: { value: " Ship create issue regression coverage " },
});
await user.click(screen.getByRole("button", { name: "Create Issue" }));
await waitFor(() => {
expect(mockCreateIssue).toHaveBeenCalledWith({
title: "Ship create issue regression coverage",
description: undefined,
status: "todo",
priority: "none",
assignee_type: undefined,
assignee_id: undefined,
start_date: undefined,
due_date: undefined,
attachment_ids: undefined,
parent_issue_id: undefined,
project_id: undefined,
});
});
expect(mockSetLastAssignee).toHaveBeenCalledWith(undefined, undefined);
expect(mockClearDraft).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
expect(mockToastCustom).toHaveBeenCalledTimes(1);
const renderToast = mockToastCustom.mock.calls[0]?.[0];
expect(typeof renderToast).toBe("function");
render(renderToast("toast-1"));
expect(screen.getByText("Issue created")).toBeInTheDocument();
expect(screen.getByText(/TES-123/)).toBeInTheDocument();
expect(screen.getByText(/Ship create issue regression coverage/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "View issue" }));
expect(mockPush).toHaveBeenCalledWith("/ws-test/issues/issue-123");
expect(mockToastDismiss).toHaveBeenCalledWith("toast-1");
});
it("keeps manual mode open and clears content when create another is enabled", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
mockQuickCreateStore.keepOpen = true;
renderModal(<CreateIssueModal onClose={onClose} />);
await user.type(screen.getByPlaceholderText("Issue title"), "First follow-up issue");
await user.type(screen.getByPlaceholderText("Add description..."), "Description to clear");
await user.click(screen.getByRole("button", { name: "Create Issue" }));
await waitFor(() => {
expect(mockCreateIssue).toHaveBeenCalledWith({
title: "First follow-up issue",
description: "Description to clear",
status: "todo",
priority: "none",
assignee_type: undefined,
assignee_id: undefined,
start_date: undefined,
due_date: undefined,
attachment_ids: undefined,
parent_issue_id: undefined,
project_id: undefined,
});
});
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByPlaceholderText("Issue title")).toHaveValue("");
expect(screen.getByPlaceholderText("Add description...")).toHaveValue("");
expect(mockSetDraft).toHaveBeenCalledWith({
title: "",
description: "",
status: "todo",
priority: "none",
assigneeType: undefined,
assigneeId: undefined,
startDate: null,
dueDate: null,
});
});
// Manual → agent must also forward the picked squad. Without this branch
// the agent panel silently falls back to the persisted actor / first
// visible agent and the user loses the squad they just chose in manual.
it("forwards the picked squad when switching to agent mode", async () => {
mockDraftStore.draft.assigneeType = "squad";
mockDraftStore.draft.assigneeId = "squad-1";
const user = userEvent.setup();
const onSwitchMode = vi.fn();
renderModal(
<ManualCreatePanel
onClose={vi.fn()}
onSwitchMode={onSwitchMode}
isExpanded={false}
setIsExpanded={vi.fn()}
backlogHintIssueId={null}
setBacklogHintIssueId={vi.fn()}
/>,
);
await user.type(screen.getByPlaceholderText("Issue title"), "Refactor auth");
await user.click(screen.getByRole("button", { name: /Switch to Agent/i }));
expect(onSwitchMode).toHaveBeenCalledTimes(1);
const carry = onSwitchMode.mock.calls[0]?.[0];
expect(carry).toEqual(
expect.objectContaining({ prompt: "Refactor auth", squad_id: "squad-1" }),
);
expect(carry).not.toHaveProperty("agent_id");
});
// Manual → agent must forward the picked project so the new modal pins to
// the same target. Without this the agent panel re-seeds from its own
// persisted `lastProjectId` and silently routes the issue to a stale one.
// Reporter scenario: backend rejects same-titled create with a 409 +
// structured duplicate body. The user should land on a duplicate toast
// pointing at the existing issue, not a generic "create failed" message.
it("shows duplicate-issue toast with a working view-existing link", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
mockCreateIssue.mockRejectedValue(
new ApiError("An active issue with this title already exists: MUL-7 Login bug", 409, "Conflict", {
code: "active_duplicate_issue",
error: "An active issue with this title already exists: MUL-7 Login bug",
issue: {
id: "issue-dup",
identifier: "MUL-7",
title: "Login bug",
},
}),
);
renderModal(<CreateIssueModal onClose={onClose} />);
await user.type(screen.getByPlaceholderText("Issue title"), "Login bug");
await user.click(screen.getByRole("button", { name: "Create Issue" }));
await waitFor(() => expect(mockToastCustom).toHaveBeenCalledTimes(1));
expect(mockToastError).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
const renderToast = mockToastCustom.mock.calls[0]?.[0];
expect(typeof renderToast).toBe("function");
render(renderToast("toast-dup"));
expect(screen.getByText("Duplicate issue")).toBeInTheDocument();
expect(screen.getByText(/MUL-7/)).toBeInTheDocument();
expect(screen.getByText(/Login bug/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "View existing issue" }));
expect(mockPush).toHaveBeenCalledWith("/ws-test/issues/issue-dup");
expect(mockToastDismiss).toHaveBeenCalledWith("toast-dup");
});
// Schema drift safety: server returns a 409 with a body that doesn't match
// the duplicate schema (renamed code, missing issue object, etc.). UI must
// not throw — it must fall back to a normal error toast carrying the
// backend message so the user still sees a useful reason.
it("falls back to a normal error toast when a 409 body does not match the duplicate schema", async () => {
const user = userEvent.setup();
mockCreateIssue.mockRejectedValue(
new ApiError("Backend says title is taken", 409, "Conflict", {
code: "renamed_duplicate_marker",
}),
);
renderModal(<CreateIssueModal onClose={vi.fn()} />);
await user.type(screen.getByPlaceholderText("Issue title"), "Login bug");
await user.click(screen.getByRole("button", { name: "Create Issue" }));
await waitFor(() => expect(mockToastError).toHaveBeenCalledTimes(1));
expect(mockToastError).toHaveBeenCalledWith("Backend says title is taken");
expect(mockToastCustom).not.toHaveBeenCalled();
});
// Non-409 errors with a real message: surface the backend reason rather
// than the generic i18n fallback. This is the whole point of the issue.
it("surfaces err.message verbatim for non-duplicate errors", async () => {
const user = userEvent.setup();
mockCreateIssue.mockRejectedValue(new Error("Server is overloaded, try again"));
renderModal(<CreateIssueModal onClose={vi.fn()} />);
await user.type(screen.getByPlaceholderText("Issue title"), "Anything");
await user.click(screen.getByRole("button", { name: "Create Issue" }));
await waitFor(() => expect(mockToastError).toHaveBeenCalledTimes(1));
expect(mockToastError).toHaveBeenCalledWith("Server is overloaded, try again");
});
// Non-Error throws (string, plain object) have no `.message`. Fall back to
// the i18n key so the user always sees something readable.
it("falls back to the generic toast when the thrown value is not an Error", async () => {
const user = userEvent.setup();
mockCreateIssue.mockRejectedValue("network exploded");
renderModal(<CreateIssueModal onClose={vi.fn()} />);
await user.type(screen.getByPlaceholderText("Issue title"), "Anything");
await user.click(screen.getByRole("button", { name: "Create Issue" }));
await waitFor(() => expect(mockToastError).toHaveBeenCalledTimes(1));
expect(mockToastError).toHaveBeenCalledWith("Failed to create issue");
});
it("forwards the picked project when switching to agent mode", async () => {
const user = userEvent.setup();
const onSwitchMode = vi.fn();
renderModal(
<ManualCreatePanel
onClose={vi.fn()}
onSwitchMode={onSwitchMode}
data={{ project_id: "proj-1" }}
isExpanded={false}
setIsExpanded={vi.fn()}
backlogHintIssueId={null}
setBacklogHintIssueId={vi.fn()}
/>,
);
await user.type(screen.getByPlaceholderText("Issue title"), "Refactor auth");
await user.click(screen.getByRole("button", { name: /Switch to Agent/i }));
expect(onSwitchMode).toHaveBeenCalledTimes(1);
expect(onSwitchMode.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
prompt: "Refactor auth",
project_id: "proj-1",
}),
);
});
// Title + description are packed into the agent prompt on switch; if we
// leave them in the shared draft store, the next agent→manual switch
// surfaces the stale manual draft on top of the prompt-as-description,
// duplicating the user's text on every round-trip.
it("clears the manual draft when packing title and description into the agent prompt", async () => {
const user = userEvent.setup();
renderModal(
<ManualCreatePanel
onClose={vi.fn()}
onSwitchMode={vi.fn()}
isExpanded={false}
setIsExpanded={vi.fn()}
backlogHintIssueId={null}
setBacklogHintIssueId={vi.fn()}
/>,
);
await user.type(screen.getByPlaceholderText("Issue title"), "Update");
await user.type(screen.getByPlaceholderText("Add description..."), "Some body");
mockSetDraft.mockClear();
await user.click(screen.getByRole("button", { name: /Switch to Agent/i }));
expect(mockSetDraft).toHaveBeenCalledWith({ title: "", description: "" });
});
});