refactor(views): consolidate all picker components across the codebase

Enhance shared pickers (StatusPicker, PriorityPicker, DueDatePicker,
ProjectPicker) with triggerRender, controlled open/onOpenChange, and
align props — matching the AssigneePicker API.

Replace inline implementations in:
- create-issue.tsx: Status, Priority, DueDate, Project (4 pickers)
- issue-detail.tsx sidebar: Status, Priority (2 pickers)
- batch-action-toolbar.tsx: Status, Priority (2 pickers)

StatusPicker now has its first consumer (was defined but unused).
Removes ~200 lines of duplicated picker code.
This commit is contained in:
Jiayuan Zhang
2026-04-10 02:10:06 +08:00
parent 23bf369748
commit 3e82cda2e5
7 changed files with 109 additions and 253 deletions

View File

@@ -14,18 +14,10 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@multica/ui/components/ui/alert-dialog";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
import type { UpdateIssueRequest } from "@multica/core/types";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@multica/core/issues/config";
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
import { useBatchUpdateIssues, useBatchDeleteIssues } from "@multica/core/issues/mutations";
import { StatusIcon } from "./status-icon";
import { PriorityIcon } from "./priority-icon";
import { AssigneePicker } from "./pickers";
import { StatusPicker, PriorityPicker, AssigneePicker } from "./pickers";
export function BatchActionToolbar() {
const selectedIds = useIssueSelectionStore((s) => s.selectedIds);
@@ -80,68 +72,26 @@ export function BatchActionToolbar() {
</div>
{/* Status */}
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
<PopoverTrigger
render={
<Button variant="ghost" size="sm" disabled={loading} />
}
>
<StatusIcon status="todo" className="h-3.5 w-3.5 mr-1" />
Status
</PopoverTrigger>
<PopoverContent align="center" className="w-44 p-1">
{ALL_STATUSES.map((s) => {
const cfg = STATUS_CONFIG[s];
return (
<button
key={s}
type="button"
onClick={() => {
handleBatchUpdate({ status: s });
setStatusOpen(false);
}}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm ${cfg.hoverBg} transition-colors`}
>
<StatusIcon status={s} className="h-3.5 w-3.5" />
<span>{cfg.label}</span>
</button>
);
})}
</PopoverContent>
</Popover>
<StatusPicker
status="todo"
onUpdate={handleBatchUpdate}
open={statusOpen}
onOpenChange={setStatusOpen}
triggerRender={<Button variant="ghost" size="sm" disabled={loading} />}
trigger="Status"
align="center"
/>
{/* Priority */}
<Popover open={priorityOpen} onOpenChange={setPriorityOpen}>
<PopoverTrigger
render={
<Button variant="ghost" size="sm" disabled={loading} />
}
>
<PriorityIcon priority="high" className="mr-1" />
Priority
</PopoverTrigger>
<PopoverContent align="center" className="w-44 p-1">
{PRIORITY_ORDER.map((p) => {
const cfg = PRIORITY_CONFIG[p];
return (
<button
key={p}
type="button"
onClick={() => {
handleBatchUpdate({ priority: p });
setPriorityOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<span className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium ${cfg.badgeBg} ${cfg.badgeText}`}>
<PriorityIcon priority={p} className="h-3 w-3" inheritColor />
{cfg.label}
</span>
</button>
);
})}
</PopoverContent>
</Popover>
<PriorityPicker
priority="none"
onUpdate={handleBatchUpdate}
open={priorityOpen}
onOpenChange={setPriorityOpen}
triggerRender={<Button variant="ghost" size="sm" disabled={loading} />}
trigger="Priority"
align="center"
/>
{/* Assignee */}
<AssigneePicker

View File

@@ -60,7 +60,7 @@ import { AvatarGroup, AvatarGroupCount } from "@multica/ui/components/ui/avatar"
import { ActorAvatar } from "../../common/actor-avatar";
import type { Issue, UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@multica/core/types";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@multica/core/issues/config";
import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent } from ".";
import { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, DueDatePicker, AssigneePicker, canAssignAgent } from ".";
import { ProjectPicker } from "../../projects/components/project-picker";
import { CommentCard } from "./comment-card";
import { CommentInput } from "./comment-input";
@@ -1113,42 +1113,20 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{propertiesOpen && <div className="space-y-0.5 pl-2">
{/* Status */}
<PropRow label="Status">
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
<StatusIcon status={issue.status} className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{STATUS_CONFIG[issue.status].label}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
{ALL_STATUSES.map((s) => (
<DropdownMenuItem key={s} onClick={() => handleUpdateField({ status: s })}>
<StatusIcon status={s} className="h-3.5 w-3.5" />
{STATUS_CONFIG[s].label}
{s === issue.status && <Check className="ml-auto h-3.5 w-3.5" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<StatusPicker
status={issue.status}
onUpdate={handleUpdateField}
align="start"
/>
</PropRow>
{/* Priority */}
<PropRow label="Priority">
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
<PriorityIcon priority={issue.priority} className="shrink-0" />
<span className="truncate">{PRIORITY_CONFIG[issue.priority].label}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
{PRIORITY_ORDER.map((p) => (
<DropdownMenuItem key={p} onClick={() => handleUpdateField({ priority: p })}>
<span className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium ${PRIORITY_CONFIG[p].badgeBg} ${PRIORITY_CONFIG[p].badgeText}`}>
<PriorityIcon priority={p} className="h-3 w-3" inheritColor />
{PRIORITY_CONFIG[p].label}
</span>
{p === issue.priority && <Check className="ml-auto h-3.5 w-3.5" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<PriorityPicker
priority={issue.priority}
onUpdate={handleUpdateField}
align="start"
/>
</PropRow>
{/* Assignee */}

View File

@@ -15,10 +15,14 @@ export function DueDatePicker({
dueDate,
onUpdate,
trigger: customTrigger,
triggerRender,
align = "start",
}: {
dueDate: string | null;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
trigger?: React.ReactNode;
triggerRender?: React.ReactElement;
align?: "start" | "center" | "end";
}) {
const [open, setOpen] = useState(false);
const date = dueDate ? new Date(dueDate) : undefined;
@@ -26,7 +30,10 @@ export function DueDatePicker({
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors">
<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}
>
{customTrigger ?? (
<>
<CalendarDays className="h-3.5 w-3.5 text-muted-foreground" />
@@ -40,7 +47,7 @@ export function DueDatePicker({
</>
)}
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<PopoverContent className="w-auto p-0" align={align}>
<Calendar
mode="single"
selected={date}

View File

@@ -10,12 +10,22 @@ export function PriorityPicker({
priority,
onUpdate,
trigger: customTrigger,
triggerRender,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
align,
}: {
priority: IssuePriority;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
trigger?: React.ReactNode;
triggerRender?: React.ReactElement;
open?: boolean;
onOpenChange?: (v: boolean) => void;
align?: "start" | "center" | "end";
}) {
const [open, setOpen] = useState(false);
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = controlledOnOpenChange ?? setInternalOpen;
const cfg = PRIORITY_CONFIG[priority];
return (
@@ -23,6 +33,8 @@ export function PriorityPicker({
open={open}
onOpenChange={setOpen}
width="w-44"
align={align}
triggerRender={triggerRender}
trigger={
customTrigger ?? (
<>

View File

@@ -9,11 +9,23 @@ import { PropertyPicker, PickerItem } from "./property-picker";
export function StatusPicker({
status,
onUpdate,
trigger: customTrigger,
triggerRender,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
align,
}: {
status: IssueStatus;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
trigger?: React.ReactNode;
triggerRender?: React.ReactElement;
open?: boolean;
onOpenChange?: (v: boolean) => void;
align?: "start" | "center" | "end";
}) {
const [open, setOpen] = useState(false);
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = controlledOnOpenChange ?? setInternalOpen;
const cfg = STATUS_CONFIG[status];
return (
@@ -21,11 +33,15 @@ export function StatusPicker({
open={open}
onOpenChange={setOpen}
width="w-44"
align={align}
triggerRender={triggerRender}
trigger={
<>
<StatusIcon status={status} className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{cfg.label}</span>
</>
customTrigger ?? (
<>
<StatusIcon status={status} className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{cfg.label}</span>
</>
)
}
>
{ALL_STATUSES.map((s) => {

View File

@@ -2,7 +2,7 @@
import { useState, useRef } from "react";
import { useNavigation } from "../navigation";
import { CalendarDays, Check, ChevronRight, FolderKanban, Maximize2, Minimize2, X as XIcon } from "lucide-react";
import { Check, ChevronRight, Maximize2, Minimize2, X as XIcon } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { toast } from "sonner";
import type { IssueStatus, IssuePriority, IssueAssigneeType } from "@multica/core/types";
@@ -11,29 +11,13 @@ import {
DialogContent,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
import { Calendar } from "@multica/ui/components/ui/calendar";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { Button } from "@multica/ui/components/ui/button";
import { ContentEditor, type ContentEditorRef } from "../editor";
import { TitleEditor } from "../editor";
import { StatusIcon, PriorityIcon, AssigneePicker } from "../issues/components";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@multica/core/issues/config";
import { StatusIcon, StatusPicker, PriorityPicker, AssigneePicker, DueDatePicker } from "../issues/components";
import { ProjectPicker } from "../projects/components/project-picker";
import { useWorkspaceStore } from "@multica/core/workspace";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { projectListOptions } from "@multica/core/projects/queries";
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
import { useCreateIssue } from "@multica/core/issues/mutations";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
@@ -71,8 +55,6 @@ function PillButton({
export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?: Record<string, unknown> | null }) {
const router = useNavigation();
const workspaceName = useWorkspaceStore((s) => s.workspace?.name);
const wsId = useWorkspaceId();
const { data: projects = [] } = useQuery(projectListOptions(wsId));
const draft = useIssueDraftStore((s) => s.draft);
const setDraft = useIssueDraftStore((s) => s.setDraft);
@@ -91,9 +73,6 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
);
const [isExpanded, setIsExpanded] = useState(false);
// Due date popover
const [dueDateOpen, setDueDateOpen] = useState(false);
// File upload — collect attachment IDs so we can link them after issue creation.
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
const { uploadWithToast } = useFileUpload(api);
@@ -105,8 +84,6 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
return result;
};
const dueDateObj = dueDate ? new Date(dueDate) : undefined;
// Sync field changes to draft store
const updateTitle = (v: string) => { setTitle(v); setDraft({ title: v }); };
const updateStatus = (v: IssueStatus) => { setStatus(v); setDraft({ status: v }); };
@@ -252,46 +229,20 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
{/* Property toolbar */}
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
{/* Status */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<PillButton>
<StatusIcon status={status} className="size-3.5" />
<span>{STATUS_CONFIG[status].label}</span>
</PillButton>
}
/>
<DropdownMenuContent align="start" className="w-44">
{ALL_STATUSES.map((s) => (
<DropdownMenuItem key={s} onClick={() => updateStatus(s)}>
<StatusIcon status={s} className="size-3.5" />
<span>{STATUS_CONFIG[s].label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<StatusPicker
status={status}
onUpdate={(u) => { if (u.status) updateStatus(u.status); }}
triggerRender={<PillButton />}
align="start"
/>
{/* Priority */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<PillButton>
<PriorityIcon priority={priority} />
<span>{PRIORITY_CONFIG[priority].label}</span>
</PillButton>
}
/>
<DropdownMenuContent align="start" className="w-44">
{PRIORITY_ORDER.map((p) => (
<DropdownMenuItem key={p} onClick={() => updatePriority(p)}>
<span className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium ${PRIORITY_CONFIG[p].badgeBg} ${PRIORITY_CONFIG[p].badgeText}`}>
<PriorityIcon priority={p} className="h-3 w-3" inheritColor />
{PRIORITY_CONFIG[p].label}
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<PriorityPicker
priority={priority}
onUpdate={(u) => { if (u.priority) updatePriority(u.priority); }}
triggerRender={<PillButton />}
align="start"
/>
{/* Assignee */}
<AssigneePicker
@@ -306,85 +257,20 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
/>
{/* Due date */}
<Popover open={dueDateOpen} onOpenChange={setDueDateOpen}>
<PopoverTrigger
render={
<PillButton>
<CalendarDays className="size-3.5 text-muted-foreground" />
{dueDateObj ? (
<span>{dueDateObj.toLocaleDateString("en-US", { month: "short", day: "numeric" })}</span>
) : (
<span className="text-muted-foreground">Due date</span>
)}
</PillButton>
}
/>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dueDateObj}
onSelect={(d: Date | undefined) => {
updateDueDate(d ? d.toISOString() : null);
setDueDateOpen(false);
}}
/>
{dueDateObj && (
<div className="border-t px-3 py-2">
<Button
variant="ghost"
size="xs"
onClick={() => {
updateDueDate(null);
setDueDateOpen(false);
}}
className="text-muted-foreground hover:text-foreground"
>
Clear date
</Button>
</div>
)}
</PopoverContent>
</Popover>
<DueDatePicker
dueDate={dueDate}
onUpdate={(u) => updateDueDate(u.due_date ?? null)}
triggerRender={<PillButton />}
align="start"
/>
{/* Project */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<PillButton>
<FolderKanban className="size-3.5 text-muted-foreground" />
{projectId ? (
<span>{projects.find((p) => p.id === projectId)?.title ?? "Project"}</span>
) : (
<span className="text-muted-foreground">Project</span>
)}
</PillButton>
}
/>
<DropdownMenuContent align="start" className="w-52">
{projects.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">No projects yet</div>
) : (
<>
{projects.map((p) => (
<DropdownMenuItem key={p.id} onClick={() => setProjectId(p.id)}>
<span className="mr-1">{p.icon || "📁"}</span>
<span className="truncate">{p.title}</span>
{p.id === projectId && <Check className="ml-auto h-3.5 w-3.5 shrink-0" />}
</DropdownMenuItem>
))}
{projectId && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setProjectId(undefined)}>
<XIcon className="h-3.5 w-3.5 text-muted-foreground" />
No project
</DropdownMenuItem>
</>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<ProjectPicker
projectId={projectId ?? null}
onUpdate={(u) => setProjectId(u.project_id ?? undefined)}
triggerRender={<PillButton />}
align="start"
/>
</div>
{/* Footer */}

View File

@@ -16,9 +16,13 @@ import {
export function ProjectPicker({
projectId,
onUpdate,
triggerRender,
align = "start",
}: {
projectId: string | null;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
triggerRender?: React.ReactElement;
align?: "start" | "center" | "end";
}) {
const wsId = useWorkspaceId();
const { data: projects = [] } = useQuery(projectListOptions(wsId));
@@ -26,11 +30,14 @@ export function ProjectPicker({
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
<DropdownMenuTrigger
className={triggerRender ? undefined : "flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden"}
render={triggerRender}
>
<FolderKanban className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{current ? current.title : "No project"}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuContent align={align} className="w-52">
{projects.map((p) => (
<DropdownMenuItem key={p.id} onClick={() => onUpdate({ project_id: p.id })}>
<span className="mr-1">{p.icon || "📁"}</span>