mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 11:10:25 +02:00
* feat(projects): add start_date / due_date pickers to project create modal and sidebar #5313 landed the backend start_date/due_date fields + types but deliberately shipped no UI. Wire up the two editor surfaces users expect: - ProjectStartDatePicker / ProjectDueDatePicker mirror the issue pickers (same calendar-day contract, clear idiom, shared @multica/core/issues/date helpers) but are typed to UpdateProjectRequest and scoped to the "projects" i18n namespace. One component serves both surfaces via a custom trigger. - Create-project modal: two date pills; values flow into the create payload and the persisted draft (draft-store gains startDate/dueDate). - Project sidebar (project-detail): two PropRows after Lead, wired to the update mutation, with clear support (send null). - i18n: prop_start_date / prop_due_date / clear_date across en/zh-Hans/ja/ko, reusing the existing issue date wording. Tests: picker display + clear behavior (real popover), and the create modal renders both pills. typecheck + lint + i18n parity pass. Part of #5227 Co-authored-by: multica-agent <github@multica.ai> * refactor(projects): align create-project footer with create-issue Restructure the create-project modal footer to match the create-issue pattern (per design feedback): the primary action moves out of the cramped single justify-between row into its own border-t action strip, and the property pills sit in a dedicated wrapping toolbar above it. Low-frequency fields (start/due date) collapse into a ⋯ overflow via progressive disclosure — a pill only renders inline once its date is set or the user opens it from the menu — so the default toolbar stays a clean single row (Status · Priority · Lead · Repos · ⋯). - Use the shared PillButton (../common/pill-button) instead of the modal-local copy, gaining the data-popup-open styling create-issue uses. - ProjectStartDatePicker / ProjectDueDatePicker gain controlled open props so the overflow menu can reveal + open them (mirrors the issue pickers). - i18n: create_project.set_start_date / set_due_date / more_options_aria across en / zh-Hans / ja / ko, reusing the create-issue wording. Test updated to assert the dates are revealed from the overflow rather than shown inline by default. typecheck / lint / i18n parity pass. Part of #5227 Co-authored-by: multica-agent <github@multica.ai> * refactor(views): extract shared DateOnlyPicker base for date pills (Elon nit2) The issue and project start/due-date pickers were near-complete copies of the same Popover + Calendar + clear wiring, so they could drift in behaviour or formatting. Extract that into one entity-agnostic DateOnlyPicker (packages/views/common/date-only-picker.tsx); each of the four pills is now a thin wrapper supplying only its field name (via onChange), icon, overdue flag, and localized copy. -264 lines of duplication, single source of truth. Behaviour is unchanged: the issue pickers keep their full API (trigger / triggerRender / open / onOpenChange / align / defaultOpen — all still used by board-card, issue-detail, create-issue) and the calendar-day contract stays in @multica/core/issues/date. The en-US display format now lives in one place rather than being duplicated per entity. Full views test suite (1857 tests) + typecheck + lint pass. Part of #5227 Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
224 lines
7.2 KiB
TypeScript
224 lines
7.2 KiB
TypeScript
import React from "react";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { render, screen } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { renderWithI18n } from "../test/i18n";
|
|
|
|
const longRepoUrl =
|
|
"https://github.com/multica-ai/a-very-long-repository-name-that-needs-a-tooltip";
|
|
const apiRepoUrl = "https://github.com/multica-ai/api";
|
|
const webRepoUrl = "https://github.com/multica-ai/web";
|
|
|
|
vi.mock("@tanstack/react-query", () => ({
|
|
useQuery: () => ({ data: [] }),
|
|
}));
|
|
|
|
vi.mock("@multica/core/projects/mutations", () => ({
|
|
useCreateProject: () => ({ mutateAsync: vi.fn() }),
|
|
}));
|
|
|
|
vi.mock("@multica/core/projects", () => ({
|
|
useProjectDraftStore: (selector: (state: unknown) => unknown) =>
|
|
selector({
|
|
draft: {
|
|
title: "",
|
|
description: "",
|
|
status: "planned",
|
|
priority: "medium",
|
|
leadType: undefined,
|
|
leadId: undefined,
|
|
icon: undefined,
|
|
},
|
|
setDraft: vi.fn(),
|
|
clearDraft: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@multica/core/hooks", () => ({
|
|
useWorkspaceId: () => "workspace-1",
|
|
}));
|
|
|
|
vi.mock("@multica/core/paths", () => ({
|
|
useCurrentWorkspace: () => ({
|
|
id: "workspace-1",
|
|
name: "Test Workspace",
|
|
slug: "test-workspace",
|
|
repos: [{ url: longRepoUrl }, { url: apiRepoUrl }, { url: webRepoUrl }],
|
|
}),
|
|
useWorkspacePaths: () => ({
|
|
projectDetail: (id: string) => `/test-workspace/projects/${id}`,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@multica/core/workspace/queries", () => ({
|
|
memberListOptions: () => ({ queryKey: ["members"], queryFn: vi.fn() }),
|
|
agentListOptions: () => ({ queryKey: ["agents"], queryFn: vi.fn() }),
|
|
}));
|
|
|
|
vi.mock("@multica/core/workspace/hooks", () => ({
|
|
useActorName: () => ({ getActorName: vi.fn() }),
|
|
}));
|
|
|
|
vi.mock("../navigation", () => ({
|
|
useNavigation: () => ({ push: vi.fn() }),
|
|
}));
|
|
|
|
vi.mock("../editor", () => {
|
|
const ContentEditor = React.forwardRef<HTMLTextAreaElement, { placeholder?: string }>(
|
|
({ placeholder }, ref) => <textarea ref={ref} placeholder={placeholder} />,
|
|
);
|
|
ContentEditor.displayName = "ContentEditor";
|
|
|
|
return {
|
|
ContentEditor,
|
|
TitleEditor: ({
|
|
placeholder,
|
|
onChange,
|
|
}: {
|
|
placeholder?: string;
|
|
onChange?: (value: string) => void;
|
|
}) => <input placeholder={placeholder} onChange={(e) => onChange?.(e.target.value)} />,
|
|
};
|
|
});
|
|
|
|
vi.mock("../issues/components/priority-icon", () => ({
|
|
PriorityIcon: () => <span data-testid="priority-icon" />,
|
|
}));
|
|
|
|
vi.mock("../common/actor-avatar", () => ({
|
|
ActorAvatar: () => <span data-testid="actor-avatar" />,
|
|
}));
|
|
|
|
// Stub the date pickers so this test doesn't pull the real Calendar (and its
|
|
// buttonVariants import) into the modal's module graph; the pickers have their
|
|
// own test. The stubs render the placeholder label so the pills are assertable.
|
|
vi.mock("../projects/components/project-start-date-picker", () => ({
|
|
ProjectStartDatePicker: () => <button type="button">Start date</button>,
|
|
}));
|
|
|
|
vi.mock("../projects/components/project-due-date-picker", () => ({
|
|
ProjectDueDatePicker: () => <button type="button">Due date</button>,
|
|
}));
|
|
|
|
vi.mock("@multica/ui/components/ui/dialog", () => ({
|
|
Dialog: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{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>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@multica/ui/components/ui/popover", () => ({
|
|
Popover: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
PopoverTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
|
|
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
}));
|
|
|
|
vi.mock("@multica/ui/components/ui/tooltip", () => ({
|
|
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
|
|
TooltipContent: ({ children }: { children: React.ReactNode }) => (
|
|
<div role="tooltip">{children}</div>
|
|
),
|
|
}));
|
|
|
|
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/common/emoji-picker", () => ({
|
|
EmojiPicker: () => null,
|
|
}));
|
|
|
|
vi.mock("@multica/ui/lib/utils", () => ({
|
|
cn: (...values: Array<string | false | null | undefined>) =>
|
|
values.filter(Boolean).join(" "),
|
|
}));
|
|
|
|
vi.mock("sonner", () => ({
|
|
toast: {
|
|
success: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
import { CreateProjectModal } from "./create-project";
|
|
|
|
describe("CreateProjectModal", () => {
|
|
it("exposes full repository URLs in the repository picker", () => {
|
|
render(<CreateProjectModal onClose={vi.fn()} />);
|
|
|
|
expect(screen.getByTitle(longRepoUrl)).toHaveTextContent(longRepoUrl);
|
|
expect(screen.getByRole("tooltip", { name: longRepoUrl })).toBeInTheDocument();
|
|
});
|
|
|
|
it("reveals the start/due date pickers from the ⋯ overflow menu", async () => {
|
|
const user = userEvent.setup();
|
|
renderWithI18n(<CreateProjectModal onClose={vi.fn()} />);
|
|
|
|
// Dates are collapsed behind the overflow by default (progressive disclosure).
|
|
expect(screen.queryByRole("button", { name: "Start date" })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Due date" })).not.toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole("button", { name: /Set start date/ }));
|
|
expect(screen.getByRole("button", { name: "Start date" })).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole("button", { name: /Set due date/ }));
|
|
expect(screen.getByRole("button", { name: "Due date" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("filters workspace repositories by search text", async () => {
|
|
const user = userEvent.setup();
|
|
|
|
renderWithI18n(<CreateProjectModal onClose={vi.fn()} />);
|
|
|
|
const repoSearchInput = screen.getByRole("textbox", { name: "Search repositories..." });
|
|
|
|
await user.type(repoSearchInput, "api");
|
|
|
|
expect(
|
|
screen.getByRole("button", { name: (name) => name.includes(apiRepoUrl) }),
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.queryByRole("button", { name: (name) => name.includes(webRepoUrl) }),
|
|
).not.toBeInTheDocument();
|
|
expect(
|
|
screen.queryByRole("button", { name: (name) => name.includes(longRepoUrl) }),
|
|
).not.toBeInTheDocument();
|
|
|
|
await user.clear(repoSearchInput);
|
|
await user.type(repoSearchInput, "no-match");
|
|
|
|
expect(screen.getByText("No repositories match your search.")).toBeInTheDocument();
|
|
});
|
|
});
|