onOpenChange?.(false)}
/>
),
// Labels can now be hidden via Settings → Issue and revealed from the
// overflow, so surface open/onOpenChange like the date pickers.
LabelPicker: ({ open, onOpenChange }: { open?: boolean; onOpenChange?: (v: boolean) => void }) => (
onOpenChange?.(false)}
/>
),
}));
vi.mock("../issues/components/pickers/custom-property-picker", () => ({
CustomPropertyValueInput: ({ property, onChange }: any) => (
),
CustomPropertyValueDisplay: ({ value }: any) =>
{String(value)},
}));
vi.mock("../projects/components/project-picker", () => ({
ProjectPicker: ({ projectId, onUpdate }: any) => (
),
}));
vi.mock("@multica/ui/components/ui/dialog", () => ({
Dialog: ({ children }: { children: React.ReactNode }) =>
{children}
,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
{children}
),
DialogTitle: ({ children, className }: { children: React.ReactNode; className?: string }) => (
{children}
),
}));
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 }) => (
),
DropdownMenuSeparator: () => null,
DropdownMenuSub: ({ children }: { children: React.ReactNode }) => <>{children}>,
DropdownMenuSubTrigger: ({ children }: { children: React.ReactNode }) => <>{children}>,
DropdownMenuSubContent: ({ children }: { children: React.ReactNode }) => <>{children}>,
}));
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}>,
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}>,
}));
vi.mock("@multica/ui/components/ui/button", () => ({
Button: ({
children,
disabled,
onClick,
type = "button",
...rest
}: {
children: React.ReactNode;
disabled?: boolean;
onClick?: () => void;
type?: "button" | "submit" | "reset";
// The real Button spreads the rest onto the element; forwarding them keeps
// accessibility props (aria-busy / aria-disabled) assertable here.
[key: string]: unknown;
}) => (
),
}));
vi.mock("@multica/ui/components/ui/switch", () => ({
Switch: ({
checked,
onCheckedChange,
}: {
checked: boolean;
onCheckedChange: (v: boolean) => void;
}) => (
onCheckedChange(e.target.checked)}
/>
),
}));
vi.mock("@multica/ui/components/common/file-upload-button", () => ({
FileUploadButton: ({ onSelect }: { onSelect: (file: File) => void }) => (
),
}));
vi.mock("@multica/ui/lib/utils", () => ({
cn: (...values: Array
) => 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(
{element}
,
);
}
describe("CreateIssueModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockQuickCreateStore.keepOpen = false;
mockCreateSettingsStore.manualCreateFields = DEFAULT_MANUAL_FIELDS;
mockSetKeepOpen.mockImplementation((v: boolean) => {
mockQuickCreateStore.keepOpen = v;
});
mockDraftStore.draft.title = "";
mockDraftStore.draft.description = "";
mockDraftStore.draft.status = "todo";
mockDraftStore.draft.priority = "none";
// 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;
mockDraftStore.draft.projectId = undefined;
mockDraftStore.draft.startDate = null;
mockDraftStore.draft.dueDate = null;
mockDraftStore.draft.labelIds = [];
mockDraftStore.draft.propertyValues = {};
mockDraftStore.draft.attachments = [];
mockSetDraft.mockImplementation((patch: Partial) => {
mockDraftStore.draft = { ...mockDraftStore.draft, ...patch };
});
mockClearDraft.mockImplementation(() => {
mockDraftStore.draft = {
title: "",
description: "",
status: "todo",
priority: "none",
assigneeType: mockDraftStore.lastAssigneeType,
assigneeId: mockDraftStore.lastAssigneeId,
projectId: undefined,
startDate: null,
dueDate: null,
labelIds: [],
propertyValues: {},
attachments: [],
};
});
mockUploadWithToast.mockResolvedValue({
id: "11111111-2222-3333-4444-555555555555",
workspace_id: "ws-test",
issue_id: null,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "member",
uploader_id: "user-1",
filename: "shot.png",
url: "https://cdn.example.test/shot.png",
download_url: "https://cdn.example.test/shot.png?Signature=fresh",
markdown_url: "https://multica-api.copilothub.ai/api/attachments/11111111-2222-3333-4444-555555555555/download",
content_type: "image/png",
size_bytes: 123,
created_at: "2026-06-12T00:00:00Z",
link: "https://cdn.example.test/shot.png",
markdownLink: "https://multica-api.copilothub.ai/api/attachments/11111111-2222-3333-4444-555555555555/download",
});
mockCreateIssue.mockResolvedValue({
id: "issue-123",
identifier: "TES-123",
title: "Ship create issue regression coverage",
status: "todo",
// Current backend echoes the attached labels, so the create flow skips
// the legacy per-label attach fallback. Empty is enough — what matters
// is that the field is present (not undefined).
labels: [],
});
mockAttachLabel.mockResolvedValue({ labels: [] });
mockListProperties.mockResolvedValue({
properties: [
{
id: "property-tier",
workspace_id: "ws-test",
name: "Customer tier",
type: "select",
config: {
options: [
{ id: "option-enterprise", name: "Enterprise", color: "#3b82f6" },
],
},
position: 0,
archived: false,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
],
total: 1,
});
mockSetIssueProperty.mockResolvedValue({
properties: { "property-tier": "option-enterprise" },
});
});
it("shows success feedback with a direct path to the new issue", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderModal();
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("forwards selected labels in the create payload so they attach in the same transaction", async () => {
const user = userEvent.setup();
mockDraftStore.draft.labelIds = [
"aaaaaaaa-1111-2222-3333-444444444444",
"bbbbbbbb-1111-2222-3333-444444444444",
];
renderModal();
fireEvent.change(screen.getByPlaceholderText("Issue title"), {
target: { value: "Labeled issue" },
});
await user.click(screen.getByRole("button", { name: "Create Issue" }));
await waitFor(() => {
expect(mockCreateIssue).toHaveBeenCalledWith(
expect.objectContaining({
title: "Labeled issue",
label_ids: [
"aaaaaaaa-1111-2222-3333-444444444444",
"bbbbbbbb-1111-2222-3333-444444444444",
],
}),
);
});
// Backend echoed `labels`, so the atomic path handled it — no legacy
// per-label attach fallback should run.
expect(mockAttachLabel).not.toHaveBeenCalled();
});
it("falls back to per-label attach when an older backend omits labels from the create response", async () => {
const user = userEvent.setup();
// Older backend: ignores label_ids and returns an issue with no `labels`
// field (the rolling-deploy window where web is ahead of the backend).
mockCreateIssue.mockResolvedValueOnce({
id: "issue-123",
identifier: "TES-123",
title: "Labeled issue",
status: "todo",
});
mockDraftStore.draft.labelIds = [
"aaaaaaaa-1111-2222-3333-444444444444",
"bbbbbbbb-1111-2222-3333-444444444444",
];
renderModal();
fireEvent.change(screen.getByPlaceholderText("Issue title"), {
target: { value: "Labeled issue" },
});
await user.click(screen.getByRole("button", { name: "Create Issue" }));
await waitFor(() => {
expect(mockAttachLabel).toHaveBeenCalledTimes(2);
});
expect(mockAttachLabel).toHaveBeenCalledWith({
issueId: "issue-123",
labelId: "aaaaaaaa-1111-2222-3333-444444444444",
});
expect(mockAttachLabel).toHaveBeenCalledWith({
issueId: "issue-123",
labelId: "bbbbbbbb-1111-2222-3333-444444444444",
});
});
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();
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,
labelIds: [],
propertyValues: {},
attachments: [],
});
});
it("sets configured custom property values after the issue is created", async () => {
const user = userEvent.setup();
renderModal();
await screen.findByText("Customer tier");
await user.click(screen.getByText("Customer tier"));
await user.click(screen.getByRole("button", { name: "Edit Customer tier" }));
await user.type(screen.getByPlaceholderText("Issue title"), "Enterprise follow-up");
await user.click(screen.getByRole("button", { name: "Create Issue" }));
await waitFor(() => {
expect(mockSetIssueProperty).toHaveBeenCalledWith(
"issue-123",
"property-tier",
"option-enterprise",
);
});
expect(mockClearDraft).toHaveBeenCalled();
});
it("persists manual-mode uploads in the issue draft", async () => {
const user = userEvent.setup();
renderModal();
await user.click(screen.getByRole("button", { name: "Upload file" }));
await waitFor(() => {
expect(mockSetDraft).toHaveBeenCalledWith({
attachments: [
expect.objectContaining({
id: "11111111-2222-3333-4444-555555555555",
filename: "shot.png",
download_url: "",
}),
],
});
});
const draftAttachmentsCall = mockSetDraft.mock.calls.find(
([patch]) => Array.isArray(patch.attachments),
)?.[0] as { attachments?: Array<{ download_url: string }> } | undefined;
expect(draftAttachmentsCall?.attachments?.[0]?.download_url).not.toContain(
"Signature=",
);
});
it("reuses draft attachments after reopening manual create so pasted images can render and bind", async () => {
const user = userEvent.setup();
const attachment = {
id: "11111111-2222-3333-4444-555555555555",
workspace_id: "ws-test",
issue_id: null,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "member",
uploader_id: "user-1",
filename: "shot.png",
url: "https://cdn.example.test/shot.png",
download_url: "",
markdown_url: "https://multica-api.copilothub.ai/api/attachments/11111111-2222-3333-4444-555555555555/download",
content_type: "image/png",
size_bytes: 123,
created_at: "2026-06-12T00:00:00Z",
};
mockDraftStore.draft.title = "Image draft";
mockDraftStore.draft.description = ``;
mockDraftStore.draft.attachments = [attachment];
renderModal();
expect(screen.getByPlaceholderText("Add description...")).toHaveAttribute(
"data-attachments-count",
"1",
);
await user.click(screen.getByRole("button", { name: "Create Issue" }));
await waitFor(() => {
expect(mockCreateIssue).toHaveBeenCalledWith(
expect.objectContaining({
description: ``,
attachment_ids: ["11111111-2222-3333-4444-555555555555"],
}),
);
});
});
it("prunes draft attachments the reopened description no longer references", async () => {
const referenced = {
id: "11111111-2222-3333-4444-555555555555",
workspace_id: "ws-test",
issue_id: null,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "member",
uploader_id: "user-1",
filename: "kept.png",
url: "https://cdn.example.test/kept.png",
download_url: "",
markdown_url: "https://multica-api.copilothub.ai/api/attachments/11111111-2222-3333-4444-555555555555/download",
content_type: "image/png",
size_bytes: 123,
created_at: "2026-06-12T00:00:00Z",
};
const deleted = {
...referenced,
id: "99999999-8888-7777-6666-555555555555",
filename: "deleted.png",
url: "https://cdn.example.test/deleted.png",
markdown_url: "https://multica-api.copilothub.ai/api/attachments/99999999-8888-7777-6666-555555555555/download",
};
mockDraftStore.draft.title = "Image draft";
mockDraftStore.draft.description = ``;
mockDraftStore.draft.attachments = [referenced, deleted];
renderModal();
await waitFor(() => {
expect(mockSetDraft).toHaveBeenCalledWith({ attachments: [referenced] });
});
});
// 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(
,
);
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();
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();
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();
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();
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(
,
);
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",
}),
);
});
it("restores an unfinished project selection after manual create remounts", async () => {
const user = userEvent.setup();
const firstOpen = renderModal();
expect(screen.getByTestId("project-picker")).toHaveAttribute("data-project-id", "none");
await user.click(screen.getByTestId("project-picker"));
expect(mockSetDraft).toHaveBeenCalledWith({ projectId: "proj-1" });
firstOpen.unmount();
renderModal();
expect(screen.getByTestId("project-picker")).toHaveAttribute("data-project-id", "proj-1");
});
// Manual → agent must forward parent_issue_id when the modal was opened
// from "Add sub issue". Before this, the agent panel received no parent
// context and the new issue was filed as a standalone — silently dropping
// the sub-issue intent set by openCreateSubIssue. The parent_issue_identifier
// tags along so the agent panel can render a "Sub-issue of MUL-XX" chip
// without an extra round-trip.
//
// The identifier fallback matters here: the mocked issueDetailOptions
// resolves to null (parent query not hydrated), so without the
// `data.parent_issue_identifier` fallback the agent chip would render as
// "Sub-issue of " with an empty tail. The UUID alone still wires the
// sub-issue relationship correctly, but the visible affordance breaks.
it("forwards parent_issue_id and falls back to seeded identifier when switching to agent mode", async () => {
const user = userEvent.setup();
const onSwitchMode = vi.fn();
renderModal(
,
);
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",
parent_issue_id: "parent-uuid-1",
parent_issue_identifier: "MUL-2534",
}),
);
});
// Start date is a low-frequency field — by default it lives behind the
// ⋯ overflow menu and is not rendered inline. Clicking the overflow
// entry opens it (and mounts the inline pill so the popover has an
// anchor); closing without picking returns it to the menu-only state.
it("hides start date behind the overflow menu and reveals it on demand", async () => {
const user = userEvent.setup();
renderModal();
expect(screen.queryByTestId("start-date-picker")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Set start date/i }));
const picker = await screen.findByTestId("start-date-picker");
expect(picker).toHaveAttribute("data-open", "true");
await user.click(picker);
expect(screen.queryByTestId("start-date-picker")).not.toBeInTheDocument();
});
it("exposes the label picker on the toolbar and keeps due date in the overflow menu", async () => {
renderModal();
// Label entry is now surfaced directly on the dialog...
expect(screen.getByTestId("label-picker")).toBeInTheDocument();
// ...while due date is collapsed into the ⋯ menu (no inline pill yet).
expect(screen.queryByTestId("due-date-picker")).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Set due date/i }),
).toBeInTheDocument();
});
it("hides due date behind the overflow menu and reveals it on demand", async () => {
const user = userEvent.setup();
renderModal();
expect(screen.queryByTestId("due-date-picker")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Set due date/i }));
const picker = await screen.findByTestId("due-date-picker");
expect(picker).toHaveAttribute("data-open", "true");
await user.click(picker);
expect(screen.queryByTestId("due-date-picker")).not.toBeInTheDocument();
});
it("hides toolbar fields turned off in Settings → Issue and re-reveals them from the overflow", async () => {
const user = userEvent.setup();
mockCreateSettingsStore.manualCreateFields = ["status", "priority", "assignee", "project"];
renderModal();
expect(screen.queryByTestId("label-picker")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Set labels/i }));
const picker = await screen.findByTestId("label-picker");
expect(picker).toHaveAttribute("data-open", "true");
await user.click(picker);
expect(screen.queryByTestId("label-picker")).not.toBeInTheDocument();
});
it("keeps a hidden field on the toolbar while it holds a value", () => {
mockCreateSettingsStore.manualCreateFields = ["status", "priority", "assignee", "project"];
mockDraftStore.draft.labelIds = ["label-1"];
renderModal();
expect(screen.getByTestId("label-picker")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Set labels/i })).not.toBeInTheDocument();
});
it("renders due date inline when enabled in Settings → Issue", () => {
mockCreateSettingsStore.manualCreateFields = [...DEFAULT_MANUAL_FIELDS, "due_date"];
renderModal();
expect(screen.getByTestId("due-date-picker")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Set due date/i })).not.toBeInTheDocument();
});
it("routes Customize fields to Settings → Issue and closes the dialog", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderModal();
await user.click(screen.getByRole("button", { name: /Customize fields/i }));
expect(onClose).toHaveBeenCalled();
expect(mockPush).toHaveBeenCalledWith("/ws-test/settings?tab=issue");
});
// 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(
,
);
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: "" });
});
// MUL-4808 — manual create had no upload gate at all: Create, Enter on the
// title, and Switch to Agent would each fix the draft while an image was
// still uploading, dropping it from the description with no warning.
describe("upload submit gate", () => {
/** Attach a file whose upload stays in flight until the caller releases it. */
function startPendingUpload() {
let release!: (result: unknown) => void;
mockUploadWithToast.mockImplementationOnce(
() => new Promise((resolve) => { release = resolve; }),
);
fireEvent.click(screen.getByRole("button", { name: "Upload file" }));
return { release: (result: unknown) => release(result) };
}
function renderManual(onSwitchMode = vi.fn()) {
const view = renderModal(
,
);
return { ...view, onSwitchMode };
}
it("disables Create and shows Uploading… while an upload is in flight", async () => {
const user = userEvent.setup();
renderManual();
await user.type(screen.getByPlaceholderText("Issue title"), "Has a screenshot");
const pending = startPendingUpload();
const createButton = await screen.findByRole("button", { name: "Uploading…" });
await waitFor(() => expect(createButton).toBeDisabled());
expect(createButton).toHaveAttribute("aria-busy", "true");
await act(async () => { pending.release({ id: "att-1", url: "https://cdn/x.png" }); });
await waitFor(() =>
expect(screen.getByRole("button", { name: "Create Issue" })).not.toBeDisabled(),
);
});
// Plain Enter in the title was removed as a create trigger in #5532 — it
// fired from a half-typed title. MUL-4931 adds the explicit `send` chord
// alongside it; plain Enter must stay inert.
it("never submits manual create from plain Enter in the title", async () => {
const user = userEvent.setup();
renderManual();
const title = screen.getByPlaceholderText("Issue title");
await user.type(title, "Has a screenshot");
fireEvent.keyDown(title, { key: "Enter" });
await Promise.resolve();
expect(mockCreateIssue).not.toHaveBeenCalled();
});
it("blocks the title send chord while an upload is in flight", async () => {
const user = userEvent.setup();
renderManual();
const title = screen.getByPlaceholderText("Issue title");
await user.type(title, "Has a screenshot");
startPendingUpload();
// The chord bypasses the button, so the handler's own gate is what stops
// this from serializing a description whose image hasn't landed yet.
fireEvent.keyDown(title, { key: "Enter", metaKey: true });
await Promise.resolve();
expect(mockCreateIssue).not.toHaveBeenCalled();
});
it("blocks Switch to Agent while an upload is in flight", async () => {
const user = userEvent.setup();
const onSwitchMode = vi.fn();
renderManual(onSwitchMode);
await user.type(screen.getByPlaceholderText("Issue title"), "Has a screenshot");
startPendingUpload();
// The switch packs the description into an agent prompt and clears the
// manual draft — doing that mid-upload loses the image for good.
const switchButton = screen.getByRole("button", { name: /Switch to Agent/i });
await waitFor(() => expect(switchButton).toBeDisabled());
fireEvent.click(switchButton);
expect(onSwitchMode).not.toHaveBeenCalled();
});
});
// MUL-4931 — manual create had no submit shortcut at all, while agent create
// has had one all along.
describe("send shortcut", () => {
function renderManual() {
return renderModal(
,
);
}
it("creates from the send chord in the title", async () => {
const user = userEvent.setup();
renderManual();
const title = screen.getByPlaceholderText("Issue title");
await user.type(title, "Shortcut from title");
fireEvent.keyDown(title, { key: "Enter", metaKey: true });
await waitFor(() => expect(mockCreateIssue).toHaveBeenCalledTimes(1));
expect(mockCreateIssue).toHaveBeenCalledWith(
expect.objectContaining({ title: "Shortcut from title" }),
);
});
it("creates from the send chord in the description", async () => {
const user = userEvent.setup();
renderManual();
await user.type(screen.getByPlaceholderText("Issue title"), "Shortcut from body");
const description = screen.getByPlaceholderText("Add description...");
await user.type(description, "Body text");
fireEvent.keyDown(description, { key: "Enter", ctrlKey: true });
await waitFor(() => expect(mockCreateIssue).toHaveBeenCalledTimes(1));
expect(mockCreateIssue).toHaveBeenCalledWith(
expect.objectContaining({
title: "Shortcut from body",
description: "Body text",
}),
);
});
it("leaves plain Enter in the description as a newline, not a create", async () => {
const user = userEvent.setup();
renderManual();
await user.type(screen.getByPlaceholderText("Issue title"), "Still typing");
fireEvent.keyDown(screen.getByPlaceholderText("Add description..."), { key: "Enter" });
await Promise.resolve();
expect(mockCreateIssue).not.toHaveBeenCalled();
});
it("focuses the title instead of silently doing nothing when it is empty", async () => {
const user = userEvent.setup();
renderManual();
const description = screen.getByPlaceholderText("Add description...");
await user.type(description, "Body but no title");
fireEvent.keyDown(description, { key: "Enter", metaKey: true });
await Promise.resolve();
expect(mockCreateIssue).not.toHaveBeenCalled();
// The shortcut path can't rely on the button's tooltip, so it has to say
// where the problem is some other way.
expect(screen.getByPlaceholderText("Issue title")).toHaveFocus();
});
it("creates once when the chord is pressed twice in the same tick", async () => {
const user = userEvent.setup();
// Hold the create open so both presses land inside the in-flight window.
let release!: (v: unknown) => void;
mockCreateIssue.mockImplementationOnce(
() => new Promise((resolve) => { release = resolve; }),
);
renderManual();
const title = screen.getByPlaceholderText("Issue title");
await user.type(title, "Double tap");
// Both presses are dispatched inside ONE act, so React cannot re-render
// between them and the second handler still closes over `submitting ===
// false`. `fireEvent` would flush in between and hide the race — only a
// ref that flips synchronously stops the second create here.
await act(async () => {
const press = () =>
title.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }),
);
press();
press();
});
await act(async () => {
release({ id: "issue-1", identifier: "MUL-1", title: "Double tap", status: "todo" });
});
expect(mockCreateIssue).toHaveBeenCalledTimes(1);
});
it("renders the send keycaps on Create without renaming the button", async () => {
const user = userEvent.setup();
renderManual();
// Accessible name must stay the label alone — the keycaps are decorative.
expect(screen.getByRole("button", { name: "Create Issue" })).toBeInTheDocument();
expect(document.querySelector("[data-slot='shortcut-keycaps']")).toBeInTheDocument();
// And the affordance survives the empty → filled transition.
await user.type(screen.getByPlaceholderText("Issue title"), "Now valid");
expect(screen.getByRole("button", { name: "Create Issue" })).toBeInTheDocument();
expect(document.querySelector("[data-slot='shortcut-keycaps']")).toBeInTheDocument();
});
it("keeps Create focusable via aria-disabled while the title is empty", () => {
renderManual();
const createButton = screen.getByRole("button", { name: "Create Issue" });
// Native `disabled` would drop it out of the tab order, hiding the
// "Enter a title to create" tooltip from keyboard and SR users.
expect(createButton).toHaveAttribute("aria-disabled", "true");
expect(createButton).not.toBeDisabled();
createButton.focus();
expect(createButton).toHaveFocus();
});
it("carries its own disabled visuals, since the Button base only styles native disabled", () => {
renderManual();
const createButton = screen.getByRole("button", { name: "Create Issue" });
// Without these the control reads as a live primary button while
// aria-disabled. `pointer-events-none` is deliberately absent: it would
// kill the tooltip hover and the click that focuses the title.
expect(createButton.className).toContain("aria-disabled:opacity-50");
expect(createButton.className).toContain("aria-disabled:cursor-not-allowed");
expect(createButton.className).toContain("aria-disabled:active:translate-y-0");
expect(createButton.className).not.toContain("aria-disabled:pointer-events-none");
});
});
});