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>
This commit is contained in:
J
2026-07-13 16:29:52 +08:00
parent 60416bbe01
commit 05d4c7696e
8 changed files with 101 additions and 41 deletions

View File

@@ -109,6 +109,9 @@
"description_hint": "Shared with agents as context for every task in this project.",
"lead": "Lead",
"no_lead": "No lead",
"set_start_date": "Set start date...",
"set_due_date": "Set due date...",
"more_options_aria": "More options",
"lead_placeholder": "Assign lead...",
"members_group": "Members",
"agents_group": "Agents",

View File

@@ -108,6 +108,9 @@
"description_hint": "このプロジェクトのすべてのタスクで、コンテキストとしてエージェントに共有されます。",
"lead": "リード",
"no_lead": "リードなし",
"set_start_date": "開始日を設定...",
"set_due_date": "期限を設定...",
"more_options_aria": "その他のオプション",
"lead_placeholder": "リードを指定...",
"members_group": "メンバー",
"agents_group": "エージェント",

View File

@@ -108,6 +108,9 @@
"description_hint": "이 프로젝트의 모든 작업에서 컨텍스트로 에이전트에게 공유됩니다.",
"lead": "리드",
"no_lead": "리드 없음",
"set_start_date": "시작일 설정...",
"set_due_date": "마감일 설정...",
"more_options_aria": "추가 옵션",
"lead_placeholder": "리드 지정...",
"members_group": "멤버",
"agents_group": "에이전트",

View File

@@ -108,6 +108,9 @@
"description_hint": "会作为项目上下文提供给智能体,应用于该项目下的每个任务。",
"lead": "负责人",
"no_lead": "无负责人",
"set_start_date": "设置开始日期...",
"set_due_date": "设置截止日期...",
"more_options_aria": "更多选项",
"lead_placeholder": "指派负责人...",
"members_group": "成员",
"agents_group": "智能体",

View File

@@ -181,10 +181,18 @@ describe("CreateProjectModal", () => {
expect(screen.getByRole("tooltip", { name: longRepoUrl })).toBeInTheDocument();
});
it("renders the start-date and due-date pills", () => {
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();
});

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useRef } from "react";
import { ChevronRight, FolderOpen, Maximize2, Minimize2, Search, X as XIcon, UserMinus } from "lucide-react";
import { CalendarClock, CalendarDays, ChevronRight, FolderOpen, Maximize2, Minimize2, MoreHorizontal, Search, X as XIcon, UserMinus } from "lucide-react";
/**
* GitHub mark — lucide-react v1 dropped brand icons, so we inline the
@@ -59,6 +59,7 @@ import {
} from "../projects/components/labels";
import { ProjectStartDatePicker } from "../projects/components/project-start-date-picker";
import { ProjectDueDatePicker } from "../projects/components/project-due-date-picker";
import { PillButton } from "../common/pill-button";
import {
isDesktopShell,
pickDirectory,
@@ -66,26 +67,6 @@ import {
} from "../platform/local-directory";
import { useLocalDaemonStatus } from "../platform/use-local-daemon-status";
function PillButton({
children,
className,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
type="button"
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs",
"hover:bg-accent/60 transition-colors cursor-pointer",
className,
)}
{...props}
>
{children}
</button>
);
}
function RepoUrlText({
url,
className,
@@ -138,6 +119,10 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
const [icon, setIcon] = useState<string | undefined>(draft.icon);
const [startDate, setStartDate] = useState<string>(draft.startDate ?? "");
const [dueDate, setDueDate] = useState<string>(draft.dueDate ?? "");
// Dates are collapsed into the ⋯ overflow by default (progressive
// disclosure, mirroring create-issue); these flip a pill inline + open.
const [startDatePickerOpen, setStartDatePickerOpen] = useState(false);
const [dueDatePickerOpen, setDueDatePickerOpen] = useState(false);
const [iconPickerOpen, setIconPickerOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
@@ -407,14 +392,13 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
</p>
</div>
{/* Footer: properties (left, wrap) + Create button (right). Single row
so the modal stays compact — Linear-style.
{/* Property toolbar — mirrors the create-issue footer: a wrapping pill
row whose low-frequency fields (start/due date) collapse into a ⋯
overflow, with the primary action in a separate bar below.
Repos lives here alongside the property pills for now. Once we
support more resource types (Linear / Notion / Figma / Slack), pull
them out into a dedicated Resources strip above this footer — a
single Repos pill on its own row looked too sparse. */}
<div className="flex items-center justify-between gap-2 px-4 py-3 border-t shrink-0">
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
them out into a dedicated Resources strip above this footer. */}
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
<DropdownMenu>
<DropdownMenuTrigger
render={
@@ -549,17 +533,27 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
</PopoverContent>
</Popover>
<ProjectStartDatePicker
startDate={startDate || null}
onUpdate={(u) => updateStartDate(u.start_date ?? "")}
triggerRender={<PillButton />}
/>
{/* Start date — collapsed into ⋯ unless it has a value or was just
opened from the overflow (the calendar anchors on the inline pill). */}
{(startDate || startDatePickerOpen) && (
<ProjectStartDatePicker
startDate={startDate || null}
onUpdate={(u) => updateStartDate(u.start_date ?? "")}
triggerRender={<PillButton />}
open={startDatePickerOpen}
onOpenChange={setStartDatePickerOpen}
/>
)}
<ProjectDueDatePicker
dueDate={dueDate || null}
onUpdate={(u) => updateDueDate(u.due_date ?? "")}
triggerRender={<PillButton />}
/>
{(dueDate || dueDatePickerOpen) && (
<ProjectDueDatePicker
dueDate={dueDate || null}
onUpdate={(u) => updateDueDate(u.due_date ?? "")}
triggerRender={<PillButton />}
open={dueDatePickerOpen}
onOpenChange={setDueDatePickerOpen}
/>
)}
<Popover
open={repoPopoverOpen}
@@ -809,8 +803,40 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
)}
</PopoverContent>
</Popover>
</div>
{/* Overflow — always the last child so it stays at the end of the
wrap flow. Only rendered while a date is still collapsible; when
both are set there is nothing left to add. */}
{(!startDate || !dueDate) && (
<DropdownMenu>
<DropdownMenuTrigger
render={
<PillButton aria-label={t(($) => $.create_project.more_options_aria)}>
<MoreHorizontal className="size-3.5" />
</PillButton>
}
/>
<DropdownMenuContent align="start" className="w-auto">
{!dueDate && (
<DropdownMenuItem onClick={() => setDueDatePickerOpen(true)}>
<CalendarDays className="h-3.5 w-3.5" />
{t(($) => $.create_project.set_due_date)}
</DropdownMenuItem>
)}
{!startDate && (
<DropdownMenuItem onClick={() => setStartDatePickerOpen(true)}>
<CalendarClock className="h-3.5 w-3.5" />
{t(($) => $.create_project.set_start_date)}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* Footer action bar — primary action in its own strip, matching
create-issue. */}
<div className="flex items-center justify-end border-t px-4 py-3 shrink-0">
<Button
size="sm"
onClick={handleSubmit}

View File

@@ -30,15 +30,22 @@ export function ProjectDueDatePicker({
onUpdate,
triggerRender,
align = "start",
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
}: {
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";
/** Controlled open state — lets a ⋯ overflow menu reveal + open the pill. */
open?: boolean;
onOpenChange?: (v: boolean) => void;
}) {
const { t } = useT("projects");
const [open, setOpen] = useState(false);
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = controlledOnOpenChange ?? setInternalOpen;
const date = dateOnlyToLocalDate(dueDate);
const isOverdue = isPastDateOnly(dueDate);

View File

@@ -31,15 +31,22 @@ export function ProjectStartDatePicker({
onUpdate,
triggerRender,
align = "start",
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
}: {
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";
/** Controlled open state — lets a ⋯ overflow menu reveal + open the pill. */
open?: boolean;
onOpenChange?: (v: boolean) => void;
}) {
const { t } = useT("projects");
const [open, setOpen] = useState(false);
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = controlledOnOpenChange ?? setInternalOpen;
const date = dateOnlyToLocalDate(startDate);
return (