Files
multica/packages/views/modals/create-issue.test.tsx
Bohan Jiang 0345285b86 feat(quick-create): searchable actor picker + squad support (#2552)
* feat(quick-create): searchable actor picker + squad support (MUL-2163)

- Replaces the flat agent dropdown in the "Create with agent" modal with a
  searchable PropertyPicker that lists Agents and Squads in separate
  sections, so users can filter by name and pick a squad as the creator.
- Persists the selection as (lastActorType, lastActorId), removing the
  agent-only lastAgentId field on the quick-create store.
- Adds squad_id to the quick-create API request and stamps it onto the
  task's QuickCreateContext. The handler resolves the squad to its leader
  agent (re-using validateAssigneePair) and the daemon claim path injects
  the squad-leader briefing when the task carries a squad hint, matching
  the behavior of issue-bound squad tasks.

Co-authored-by: multica-agent <github@multica.ai>

* fix(create-issue): forward squad picks across manual→agent switch

Manual mode → agent mode previously only carried `agent_id`, so picking
a squad and then flipping to agent silently fell back to the persisted
actor / first visible agent and lost the user's choice. Carry `squad_id`
on the same branch so the agent panel honors the squad pick.

Adds a sibling test alongside the existing project-carry case.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 22:31:17 +08:00

427 lines
14 KiB
TypeScript

import { forwardRef, useImperativeHandle, useRef, useState, type ReactNode } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { 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,
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() }),
}));
vi.mock("@multica/core/api", () => ({
api: {},
}));
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" />,
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} />);
await user.type(screen.getByPlaceholderText("Issue title"), " 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,
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,
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,
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.
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",
}),
);
});
});