mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +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>
This commit is contained in:
@@ -12,6 +12,9 @@ interface ProjectDraft {
|
||||
leadType?: "member" | "agent";
|
||||
leadId?: string;
|
||||
icon?: string;
|
||||
// Calendar days ("YYYY-MM-DD"); empty/undefined means unset.
|
||||
startDate?: string;
|
||||
dueDate?: string;
|
||||
}
|
||||
|
||||
const EMPTY_DRAFT: ProjectDraft = {
|
||||
@@ -22,6 +25,8 @@ const EMPTY_DRAFT: ProjectDraft = {
|
||||
leadType: undefined,
|
||||
leadId: undefined,
|
||||
icon: undefined,
|
||||
startDate: undefined,
|
||||
dueDate: undefined,
|
||||
};
|
||||
|
||||
interface ProjectDraftStore {
|
||||
|
||||
@@ -68,6 +68,9 @@
|
||||
"section_properties": "Properties",
|
||||
"section_progress": "Progress",
|
||||
"section_description": "Description",
|
||||
"prop_start_date": "Start date",
|
||||
"prop_due_date": "Due date",
|
||||
"clear_date": "Clear date",
|
||||
"description_placeholder": "Add description...",
|
||||
"description_hint": "Shared with agents as context for every task in this project.",
|
||||
"empty_issues_title": "No issues linked",
|
||||
|
||||
@@ -67,6 +67,9 @@
|
||||
"section_properties": "プロパティ",
|
||||
"section_progress": "進捗",
|
||||
"section_description": "説明",
|
||||
"prop_start_date": "開始日",
|
||||
"prop_due_date": "期限",
|
||||
"clear_date": "日付をクリア",
|
||||
"description_placeholder": "説明を追加...",
|
||||
"description_hint": "このプロジェクトのすべてのタスクで、コンテキストとしてエージェントに共有されます。",
|
||||
"empty_issues_title": "リンクされたイシューはありません",
|
||||
|
||||
@@ -67,6 +67,9 @@
|
||||
"section_properties": "속성",
|
||||
"section_progress": "진행률",
|
||||
"section_description": "설명",
|
||||
"prop_start_date": "시작일",
|
||||
"prop_due_date": "마감일",
|
||||
"clear_date": "날짜 지우기",
|
||||
"description_placeholder": "설명 추가...",
|
||||
"description_hint": "이 프로젝트의 모든 작업에서 컨텍스트로 에이전트에게 공유됩니다.",
|
||||
"empty_issues_title": "연결된 이슈가 없습니다",
|
||||
|
||||
@@ -67,6 +67,9 @@
|
||||
"section_properties": "属性",
|
||||
"section_progress": "进度",
|
||||
"section_description": "描述",
|
||||
"prop_start_date": "开始日期",
|
||||
"prop_due_date": "截止日期",
|
||||
"clear_date": "清除日期",
|
||||
"description_placeholder": "添加描述...",
|
||||
"description_hint": "会作为项目上下文提供给智能体,应用于该项目下的每个任务。",
|
||||
"empty_issues_title": "还没有关联的 issue",
|
||||
|
||||
@@ -89,6 +89,17 @@ 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>,
|
||||
@@ -170,6 +181,13 @@ describe("CreateProjectModal", () => {
|
||||
expect(screen.getByRole("tooltip", { name: longRepoUrl })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the start-date and due-date pills", () => {
|
||||
renderWithI18n(<CreateProjectModal onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Start date" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Due date" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters workspace repositories by search text", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ import {
|
||||
useProjectStatusLabels,
|
||||
useProjectPriorityLabels,
|
||||
} from "../projects/components/labels";
|
||||
import { ProjectStartDatePicker } from "../projects/components/project-start-date-picker";
|
||||
import { ProjectDueDatePicker } from "../projects/components/project-due-date-picker";
|
||||
import {
|
||||
isDesktopShell,
|
||||
pickDirectory,
|
||||
@@ -134,6 +136,8 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
const [leadType, setLeadType] = useState<"member" | "agent" | undefined>(draft.leadType);
|
||||
const [leadId, setLeadId] = useState<string | undefined>(draft.leadId);
|
||||
const [icon, setIcon] = useState<string | undefined>(draft.icon);
|
||||
const [startDate, setStartDate] = useState<string>(draft.startDate ?? "");
|
||||
const [dueDate, setDueDate] = useState<string>(draft.dueDate ?? "");
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
@@ -212,6 +216,8 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
setDraft({ leadType: type, leadId: id });
|
||||
};
|
||||
const updateIcon = (v: string | undefined) => { setIcon(v); setDraft({ icon: v }); };
|
||||
const updateStartDate = (v: string) => { setStartDate(v); setDraft({ startDate: v || undefined }); };
|
||||
const updateDueDate = (v: string) => { setDueDate(v); setDraft({ dueDate: v || undefined }); };
|
||||
|
||||
const [leadOpen, setLeadOpen] = useState(false);
|
||||
const [leadFilter, setLeadFilter] = useState("");
|
||||
@@ -266,6 +272,8 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
priority,
|
||||
lead_type: leadType,
|
||||
lead_id: leadId,
|
||||
start_date: startDate || undefined,
|
||||
due_date: dueDate || undefined,
|
||||
// Server attaches these in the same transaction as the project.
|
||||
resources,
|
||||
});
|
||||
@@ -541,6 +549,18 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<ProjectStartDatePicker
|
||||
startDate={startDate || null}
|
||||
onUpdate={(u) => updateStartDate(u.start_date ?? "")}
|
||||
triggerRender={<PillButton />}
|
||||
/>
|
||||
|
||||
<ProjectDueDatePicker
|
||||
dueDate={dueDate || null}
|
||||
onUpdate={(u) => updateDueDate(u.due_date ?? "")}
|
||||
triggerRender={<PillButton />}
|
||||
/>
|
||||
|
||||
<Popover
|
||||
open={repoPopoverOpen}
|
||||
onOpenChange={(v) => {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithI18n } from "../../test/i18n";
|
||||
import { ProjectStartDatePicker } from "./project-start-date-picker";
|
||||
import { ProjectDueDatePicker } from "./project-due-date-picker";
|
||||
|
||||
describe("ProjectStartDatePicker", () => {
|
||||
it("shows the placeholder label when no date is set", () => {
|
||||
renderWithI18n(<ProjectStartDatePicker startDate={null} onUpdate={vi.fn()} />);
|
||||
expect(screen.getByText("Start date")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the formatted calendar day when a date is set", () => {
|
||||
renderWithI18n(
|
||||
<ProjectStartDatePicker startDate="2026-03-01" onUpdate={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByText("Mar 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("emits start_date: null when the date is cleared", async () => {
|
||||
const onUpdate = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithI18n(
|
||||
<ProjectStartDatePicker startDate="2026-03-01" onUpdate={onUpdate} />,
|
||||
);
|
||||
await user.click(screen.getByText("Mar 1")); // open popover
|
||||
await user.click(screen.getByRole("button", { name: "Clear date" }));
|
||||
expect(onUpdate).toHaveBeenCalledWith({ start_date: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectDueDatePicker", () => {
|
||||
it("shows the placeholder label when no date is set", () => {
|
||||
renderWithI18n(<ProjectDueDatePicker dueDate={null} onUpdate={vi.fn()} />);
|
||||
expect(screen.getByText("Due date")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the formatted calendar day when a date is set", () => {
|
||||
renderWithI18n(<ProjectDueDatePicker dueDate="2026-03-01" onUpdate={vi.fn()} />);
|
||||
expect(screen.getByText("Mar 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("emits due_date: null when the date is cleared", async () => {
|
||||
const onUpdate = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithI18n(
|
||||
<ProjectDueDatePicker dueDate="2026-03-01" onUpdate={onUpdate} />,
|
||||
);
|
||||
await user.click(screen.getByText("Mar 1")); // open popover
|
||||
await user.click(screen.getByRole("button", { name: "Clear date" }));
|
||||
expect(onUpdate).toHaveBeenCalledWith({ due_date: null });
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,8 @@ import { useNavigation } from "../../navigation";
|
||||
import { TitleEditor, ContentEditor, type ContentEditorRef } from "../../editor";
|
||||
import { PriorityIcon } from "../../issues/components/priority-icon";
|
||||
import { ProjectResourcesSection } from "./project-resources-section";
|
||||
import { ProjectStartDatePicker } from "./project-start-date-picker";
|
||||
import { ProjectDueDatePicker } from "./project-due-date-picker";
|
||||
import { IssueSurface } from "../../issues/surface/issue-surface";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
@@ -398,6 +400,12 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PropRow>
|
||||
<PropRow label={t(($) => $.detail.prop_start_date)}>
|
||||
<ProjectStartDatePicker startDate={project.start_date} onUpdate={handleUpdateField} />
|
||||
</PropRow>
|
||||
<PropRow label={t(($) => $.detail.prop_due_date)}>
|
||||
<ProjectDueDatePicker dueDate={project.due_date} onUpdate={handleUpdateField} />
|
||||
</PropRow>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
import type { UpdateProjectRequest } from "@multica/core/types";
|
||||
import {
|
||||
toDateOnly,
|
||||
dateOnlyToLocalDate,
|
||||
formatDateOnly,
|
||||
isPastDateOnly,
|
||||
} from "@multica/core/issues/date";
|
||||
import { Calendar } from "@multica/ui/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@multica/ui/components/ui/popover";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
/**
|
||||
* Project due-date picker. Mirrors the issue DueDatePicker
|
||||
* (packages/views/issues/components/pickers/due-date-picker.tsx), including the
|
||||
* overdue `text-destructive` styling, but typed to UpdateProjectRequest and
|
||||
* scoped to the "projects" i18n namespace. See ProjectStartDatePicker for the
|
||||
* shared-component rationale.
|
||||
*/
|
||||
export function ProjectDueDatePicker({
|
||||
dueDate,
|
||||
onUpdate,
|
||||
triggerRender,
|
||||
align = "start",
|
||||
}: {
|
||||
dueDate: string | null;
|
||||
onUpdate: (updates: Partial<UpdateProjectRequest>) => void;
|
||||
/** Custom trigger element (e.g. a pill button in the create modal). */
|
||||
triggerRender?: React.ReactElement;
|
||||
align?: "start" | "center" | "end";
|
||||
}) {
|
||||
const { t } = useT("projects");
|
||||
const [open, setOpen] = useState(false);
|
||||
const date = dateOnlyToLocalDate(dueDate);
|
||||
const isOverdue = isPastDateOnly(dueDate);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
className={triggerRender ? undefined : "flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors"}
|
||||
render={triggerRender}
|
||||
>
|
||||
<CalendarDays className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{date ? (
|
||||
<span className={isOverdue ? "text-destructive" : ""}>
|
||||
{formatDateOnly(dueDate, { month: "short", day: "numeric" }, "en-US")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t(($) => $.detail.prop_due_date)}</span>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align={align}>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={(d: Date | undefined) => {
|
||||
onUpdate({ due_date: d ? toDateOnly(d) : null });
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
{date && (
|
||||
<div className="border-t px-3 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
onUpdate({ due_date: null });
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t(($) => $.detail.clear_date)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CalendarClock } from "lucide-react";
|
||||
import type { UpdateProjectRequest } from "@multica/core/types";
|
||||
import {
|
||||
toDateOnly,
|
||||
dateOnlyToLocalDate,
|
||||
formatDateOnly,
|
||||
} from "@multica/core/issues/date";
|
||||
import { Calendar } from "@multica/ui/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@multica/ui/components/ui/popover";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
/**
|
||||
* Project start-date picker. Mirrors the issue StartDatePicker
|
||||
* (packages/views/issues/components/pickers/start-date-picker.tsx) — same
|
||||
* calendar-day contract and clear idiom, reusing @multica/core/issues/date —
|
||||
* but typed to UpdateProjectRequest and scoped to the "projects" i18n
|
||||
* namespace. The same component serves both the create-project modal (map the
|
||||
* emitted value into local draft state) and the project sidebar (pass the
|
||||
* update mutation straight through).
|
||||
*/
|
||||
export function ProjectStartDatePicker({
|
||||
startDate,
|
||||
onUpdate,
|
||||
triggerRender,
|
||||
align = "start",
|
||||
}: {
|
||||
startDate: string | null;
|
||||
onUpdate: (updates: Partial<UpdateProjectRequest>) => void;
|
||||
/** Custom trigger element (e.g. a pill button in the create modal). */
|
||||
triggerRender?: React.ReactElement;
|
||||
align?: "start" | "center" | "end";
|
||||
}) {
|
||||
const { t } = useT("projects");
|
||||
const [open, setOpen] = useState(false);
|
||||
const date = dateOnlyToLocalDate(startDate);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
className={triggerRender ? undefined : "flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors"}
|
||||
render={triggerRender}
|
||||
>
|
||||
<CalendarClock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{date ? (
|
||||
<span>{formatDateOnly(startDate, { month: "short", day: "numeric" }, "en-US")}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t(($) => $.detail.prop_start_date)}</span>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align={align}>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={(d: Date | undefined) => {
|
||||
onUpdate({ start_date: d ? toDateOnly(d) : null });
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
{date && (
|
||||
<div className="border-t px-3 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
onUpdate({ start_date: null });
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t(($) => $.detail.clear_date)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user