mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-30 16:20:35 +02:00
* feat(quick-create): add project picker that remembers last pick Quick-create users targeting one project repeatedly had to restate "in project X" in every prompt. The modal now exposes a project picker beside the agent picker, persists the selection per-workspace, and pins the agent's `multica issue create` invocation to that project so the prompt text doesn't have to. The picked project also flows to the daemon as ProjectID/ProjectTitle and its github_repo resources override the workspace repo fallback — same treatment issue-bound tasks already get. Co-authored-by: multica-agent <github@multica.ai> * fix(quick-create): move project picker into property pill row Reviewer feedback: the picker felt out of place wedged next to the agent header. Move it into a property toolbar row above the footer, reusing the shared `ProjectPicker` + `PillButton` so its placement and styling line up exactly with the manual create panel. This also drops the bespoke dropdown / aria / label strings that were only needed while the picker rendered inline beside "Created by". Co-authored-by: multica-agent <github@multica.ai> * fix(quick-create): clear stale persisted project + carry across mode switch Two review-blocking bugs in PR #2321: 1. The stale-id sweep in AgentCreatePanel only fired when projects.length > 0 and only cleared local state, leaving lastProjectId pointing at a deleted project. The next open re-seeded the dead UUID and submit hit the server's `project not found` rejection. Gate on the query's `isSuccess` so we can tell "loading" apart from "loaded as empty", and clear both local state and the persisted preference when the selection isn't in the resolved list. 2. ManualCreatePanel's switchToAgent dropped the picked project from the carry payload, so flipping manual → agent silently fell back to the agent panel's own lastProjectId — potentially routing the issue to a different project than the one shown in manual mode. Forward project_id alongside prompt / agent_id, and add a regression test. Co-authored-by: multica-agent <github@multica.ai> * test(quick-create): pass new isExpanded props in stale-project tests Main got an expand button on AgentCreatePanel via #2320 while this branch was open, adding `isExpanded` / `setIsExpanded` to the panel's required props. The two new stale-project tests still passed `{ onClose }` only, which CI's typecheck (run on the main+branch merge) caught while my local run did not. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
392 lines
12 KiB
TypeScript
392 lines
12 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,
|
|
assigneeId: 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;
|
|
});
|
|
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 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",
|
|
}),
|
|
);
|
|
});
|
|
});
|