mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-04 12:59:24 +02:00
Compare commits
1 Commits
main
...
agent/j/17
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dc83e8140 |
@@ -34,6 +34,7 @@ const RESET_STATE = {
|
||||
assigneeId: undefined,
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
labelIds: [],
|
||||
attachments: [],
|
||||
},
|
||||
lastAssigneeType: undefined,
|
||||
|
||||
@@ -13,6 +13,10 @@ interface IssueDraft {
|
||||
assigneeId?: string;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
/** Label IDs chosen in the create dialog. Attached to the issue right
|
||||
* after it is created (the create endpoint takes no labels), so they are
|
||||
* kept as a plain id list rather than full Label objects. */
|
||||
labelIds: string[];
|
||||
attachments: Attachment[];
|
||||
}
|
||||
|
||||
@@ -25,6 +29,7 @@ const EMPTY_DRAFT: IssueDraft = {
|
||||
assigneeId: undefined,
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
labelIds: [],
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -4,5 +4,6 @@ export {
|
||||
useUpdateLabel,
|
||||
useDeleteLabel,
|
||||
useAttachLabel,
|
||||
useAttachLabelToIssue,
|
||||
useDetachLabel,
|
||||
} from "./mutations";
|
||||
|
||||
@@ -141,6 +141,30 @@ export function useAttachLabel(issueId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a label to an issue identified by mutation variables rather than a
|
||||
* closed-over `issueId`. `useAttachLabel` binds one issueId at hook-call time
|
||||
* for the optimistic issue-detail flow; this variant defers the id to call
|
||||
* time so a caller can attach labels to an issue that doesn't exist yet at
|
||||
* render — e.g. labels chosen in the create-issue dialog, attached right after
|
||||
* the issue is created. No optimistic patch: the create flow closes the dialog
|
||||
* and relies on the settle-time invalidation to surface the new labels.
|
||||
*/
|
||||
export function useAttachLabelToIssue() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({ issueId, labelId }: { issueId: string; labelId: string }) =>
|
||||
api.attachLabel(issueId, labelId),
|
||||
onSettled: (_data, _err, { issueId }) => {
|
||||
qc.invalidateQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
|
||||
// Issues embed a denormalized labels snapshot, so refresh the issues
|
||||
// caches that hold it (list / board / detail) once the attach settles.
|
||||
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDetachLabel(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
@@ -23,6 +23,8 @@ export function DueDatePicker({
|
||||
onUpdate,
|
||||
trigger: customTrigger,
|
||||
triggerRender,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
align = "start",
|
||||
defaultOpen = false,
|
||||
}: {
|
||||
@@ -30,13 +32,17 @@ export function DueDatePicker({
|
||||
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
|
||||
trigger?: React.ReactNode;
|
||||
triggerRender?: React.ReactElement;
|
||||
open?: boolean;
|
||||
onOpenChange?: (v: boolean) => void;
|
||||
align?: "start" | "center" | "end";
|
||||
/** Open the popover on first mount. Used by progressive-disclosure
|
||||
* sidebars so a newly-added field immediately enters edit state. */
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const { t } = useT("issues");
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||
const open = controlledOpen ?? internalOpen;
|
||||
const setOpen = controlledOnOpenChange ?? setInternalOpen;
|
||||
const date = dateOnlyToLocalDate(dueDate);
|
||||
const isOverdue = isPastDateOnly(dueDate);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Tag, Plus, Settings2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { Label } from "@multica/core/types";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import {
|
||||
@@ -23,7 +24,17 @@ import {
|
||||
import { useT } from "../../../i18n";
|
||||
|
||||
interface LabelPickerProps {
|
||||
issueId: string;
|
||||
/**
|
||||
* The issue whose labels are edited. Omit for **draft mode** (e.g. the
|
||||
* create-issue dialog, where the issue doesn't exist yet): pass
|
||||
* `selectedIds` + `onSelectedIdsChange` instead and attach the labels to the
|
||||
* issue once it's created.
|
||||
*/
|
||||
issueId?: string;
|
||||
/** Draft-mode selection. Ignored when `issueId` is set. */
|
||||
selectedIds?: string[];
|
||||
/** Draft-mode change handler. Ignored when `issueId` is set. */
|
||||
onSelectedIdsChange?: (ids: string[]) => void;
|
||||
/** Optional controlled open state (for tests / cmd+k integration). */
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
@@ -31,6 +42,11 @@ interface LabelPickerProps {
|
||||
/** Open the picker on first mount. Used by progressive-disclosure
|
||||
* sidebars so a newly-added field immediately enters edit state. */
|
||||
defaultOpen?: boolean;
|
||||
/** Custom trigger element (e.g. a `PillButton` for the create toolbar).
|
||||
* When set, the attached-label chips render inside it without their own
|
||||
* × affordance — a remove <button> can't nest inside a trigger <button>,
|
||||
* so removal happens by toggling the label off in the open picker. */
|
||||
triggerRender?: React.ReactElement;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,14 +68,20 @@ function pickInlineColor(name: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-select label picker for an issue. Shows currently-attached labels
|
||||
* as inline chips above the trigger and lets the user toggle any label in
|
||||
* the workspace. Attach/detach are optimistic — the UI updates before the
|
||||
* server confirms.
|
||||
* Multi-select label picker. Shows currently-selected labels as inline chips
|
||||
* on the trigger and lets the user toggle any label in the workspace.
|
||||
*
|
||||
* Two modes:
|
||||
* - **Attached mode** (`issueId` set): attach/detach hit the server
|
||||
* optimistically — the UI updates before the server confirms.
|
||||
* - **Draft mode** (`issueId` omitted): selection is held by the caller via
|
||||
* `selectedIds` / `onSelectedIdsChange`; nothing is persisted until the
|
||||
* caller attaches the labels itself. Used by the create-issue dialog.
|
||||
*
|
||||
* When the search term has no matches, offers inline creation: typing a
|
||||
* new name and pressing Enter (or clicking the "Create X" row) creates the
|
||||
* label with a hash-derived color and attaches it in one motion.
|
||||
* label with a hash-derived color and selects it in one motion. The created
|
||||
* label is a real workspace label in both modes; only the attach step differs.
|
||||
*
|
||||
* A "Manage labels" item at the bottom opens a dialog with the full
|
||||
* workspace label management panel (rename, recolor, delete) — keeping
|
||||
@@ -67,10 +89,13 @@ function pickInlineColor(name: string): string {
|
||||
*/
|
||||
export function LabelPicker({
|
||||
issueId,
|
||||
selectedIds = [],
|
||||
onSelectedIdsChange,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
align = "start",
|
||||
defaultOpen = false,
|
||||
triggerRender,
|
||||
}: LabelPickerProps) {
|
||||
const { t } = useT("issues");
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||
@@ -86,17 +111,35 @@ export function LabelPicker({
|
||||
// for an error the user didn't cause. A ref closes the window cleanly.
|
||||
const creatingRef = useRef(false);
|
||||
|
||||
// Draft mode when no issue exists yet: hold selection in the caller instead
|
||||
// of hitting the attach/detach endpoints.
|
||||
const isDraft = issueId === undefined;
|
||||
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: allLabels = [] } = useQuery(labelListOptions(wsId));
|
||||
const { data: attachedLabels = [] } = useQuery(issueLabelsOptions(wsId, issueId));
|
||||
// `issueLabelsOptions` disables itself for an empty id, so the draft path
|
||||
// never fires the by-issue read.
|
||||
const { data: attachedLabels = [] } = useQuery(issueLabelsOptions(wsId, issueId ?? ""));
|
||||
|
||||
const attach = useAttachLabel(issueId);
|
||||
const detach = useDetachLabel(issueId);
|
||||
// Hooks must run unconditionally; in draft mode the empty id is never used
|
||||
// because toggle/create route through onSelectedIdsChange instead.
|
||||
const attach = useAttachLabel(issueId ?? "");
|
||||
const detach = useDetachLabel(issueId ?? "");
|
||||
const create = useCreateLabel();
|
||||
|
||||
const attachedIds = useMemo(
|
||||
() => new Set(attachedLabels.map((l) => l.id)),
|
||||
[attachedLabels],
|
||||
// The selected set drives both the trigger chips and the list checkmarks.
|
||||
// Draft mode resolves ids against the workspace list (dropping any id whose
|
||||
// label was deleted meanwhile) and preserves the user's selection order.
|
||||
const selectedLabels = useMemo<Label[]>(() => {
|
||||
if (!isDraft) return attachedLabels;
|
||||
return selectedIds
|
||||
.map((id) => allLabels.find((l) => l.id === id))
|
||||
.filter((l): l is Label => Boolean(l));
|
||||
}, [isDraft, attachedLabels, selectedIds, allLabels]);
|
||||
|
||||
const selectedIdSet = useMemo(
|
||||
() => new Set(selectedLabels.map((l) => l.id)),
|
||||
[selectedLabels],
|
||||
);
|
||||
|
||||
const query = filter.trim();
|
||||
@@ -105,8 +148,22 @@ export function LabelPicker({
|
||||
const exactMatch = allLabels.some((l) => l.name.toLowerCase() === queryLower);
|
||||
const canCreate = query.length > 0 && !exactMatch && !create.isPending;
|
||||
|
||||
const removeLabel = (labelId: string) => {
|
||||
if (isDraft) {
|
||||
onSelectedIdsChange?.(selectedIds.filter((id) => id !== labelId));
|
||||
} else {
|
||||
detach.mutate(labelId);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (labelId: string) => {
|
||||
if (attachedIds.has(labelId)) {
|
||||
if (isDraft) {
|
||||
onSelectedIdsChange?.(
|
||||
selectedIdSet.has(labelId)
|
||||
? selectedIds.filter((id) => id !== labelId)
|
||||
: [...selectedIds, labelId],
|
||||
);
|
||||
} else if (selectedIdSet.has(labelId)) {
|
||||
detach.mutate(labelId);
|
||||
} else {
|
||||
attach.mutate(labelId);
|
||||
@@ -121,7 +178,11 @@ export function LabelPicker({
|
||||
{ name, color: pickInlineColor(name) },
|
||||
{
|
||||
onSuccess: (label) => {
|
||||
attach.mutate(label.id);
|
||||
if (isDraft) {
|
||||
onSelectedIdsChange?.([...selectedIds, label.id]);
|
||||
} else {
|
||||
attach.mutate(label.id);
|
||||
}
|
||||
setFilter("");
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
@@ -139,7 +200,16 @@ export function LabelPicker({
|
||||
setManageOpen(true);
|
||||
};
|
||||
|
||||
const hasLabels = attachedLabels.length > 0;
|
||||
const hasLabels = selectedLabels.length > 0;
|
||||
|
||||
// In a custom trigger (PillButton) the trigger is itself a button, so the
|
||||
// chips can't carry their own remove button. Otherwise fall back to the
|
||||
// chip-wrap div used by the issue-detail sidebar.
|
||||
const resolvedTriggerRender =
|
||||
triggerRender ??
|
||||
(hasLabels ? (
|
||||
<div className="flex flex-wrap items-center gap-1 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors" />
|
||||
) : undefined);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -154,19 +224,15 @@ export function LabelPicker({
|
||||
searchable
|
||||
searchPlaceholder={t(($) => $.pickers.label.search_placeholder)}
|
||||
onSearchChange={setFilter}
|
||||
triggerRender={
|
||||
hasLabels ? (
|
||||
<div className="flex flex-wrap items-center gap-1 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors" />
|
||||
) : undefined
|
||||
}
|
||||
triggerRender={resolvedTriggerRender}
|
||||
trigger={
|
||||
hasLabels ? (
|
||||
<>
|
||||
{attachedLabels.map((l) => (
|
||||
{selectedLabels.map((l) => (
|
||||
<LabelChip
|
||||
key={l.id}
|
||||
label={l}
|
||||
onRemove={() => detach.mutate(l.id)}
|
||||
onRemove={triggerRender ? undefined : () => removeLabel(l.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -191,7 +257,7 @@ export function LabelPicker({
|
||||
}
|
||||
>
|
||||
{filtered.map((label) => {
|
||||
const selected = attachedIds.has(label.id);
|
||||
const selected = selectedIdSet.has(label.id);
|
||||
return (
|
||||
<PickerItem
|
||||
key={label.id}
|
||||
|
||||
@@ -162,6 +162,7 @@
|
||||
"toast_duplicate_view": "View existing issue",
|
||||
"toast_link_subissues_all_failed": "Failed to link sub-issues",
|
||||
"toast_link_subissues_partial": "Failed to link {{failed}} of {{total}} sub-issues",
|
||||
"toast_link_labels_failed": "Failed to attach labels",
|
||||
"switch_to_agent": "Switch to Agent",
|
||||
"switch_to_agent_tooltip": "Switch to create with agent — describe in one line and let the agent file it",
|
||||
"switch_to_manual": "Switch to Manual",
|
||||
@@ -172,6 +173,7 @@
|
||||
"subissue_of": "Sub-issue of {{identifier}}",
|
||||
"subissue_chip": "Sub-issue: {{identifier}}",
|
||||
"parent_with_id": "Parent: {{identifier}}",
|
||||
"set_due_date": "Set due date...",
|
||||
"set_start_date": "Set start date...",
|
||||
"set_parent": "Set parent issue...",
|
||||
"add_subissue": "Add sub-issue...",
|
||||
|
||||
@@ -160,6 +160,7 @@
|
||||
"toast_duplicate_view": "既存のイシューを表示",
|
||||
"toast_link_subissues_all_failed": "サブイシューをリンクできませんでした",
|
||||
"toast_link_subissues_partial": "サブイシュー {{total}} 件中 {{failed}} 件をリンクできませんでした",
|
||||
"toast_link_labels_failed": "ラベルを付けられませんでした",
|
||||
"switch_to_agent": "エージェントに切り替え",
|
||||
"switch_to_agent_tooltip": "エージェントで作成に切り替えます。一行で説明すれば、エージェントがイシューを作成します",
|
||||
"switch_to_manual": "手動に切り替え",
|
||||
@@ -170,6 +171,7 @@
|
||||
"subissue_of": "{{identifier}} のサブイシュー",
|
||||
"subissue_chip": "サブイシュー: {{identifier}}",
|
||||
"parent_with_id": "親: {{identifier}}",
|
||||
"set_due_date": "期限を設定...",
|
||||
"set_start_date": "開始日を設定...",
|
||||
"set_parent": "親イシューを設定...",
|
||||
"add_subissue": "サブイシューを追加...",
|
||||
|
||||
@@ -160,6 +160,7 @@
|
||||
"toast_duplicate_view": "기존 이슈 보기",
|
||||
"toast_link_subissues_all_failed": "하위 이슈를 연결하지 못했습니다",
|
||||
"toast_link_subissues_partial": "하위 이슈 {{total}}개 중 {{failed}}개를 연결하지 못했습니다",
|
||||
"toast_link_labels_failed": "라벨을 연결하지 못했습니다",
|
||||
"switch_to_agent": "에이전트로 전환",
|
||||
"switch_to_agent_tooltip": "에이전트로 만들기로 전환합니다. 한 줄로 설명하면 에이전트가 이슈를 작성합니다.",
|
||||
"switch_to_manual": "직접 만들기로 전환",
|
||||
@@ -170,6 +171,7 @@
|
||||
"subissue_of": "{{identifier}}의 하위 이슈",
|
||||
"subissue_chip": "하위 이슈: {{identifier}}",
|
||||
"parent_with_id": "상위 이슈: {{identifier}}",
|
||||
"set_due_date": "마감일 설정...",
|
||||
"set_start_date": "시작일 설정...",
|
||||
"set_parent": "상위 이슈 설정...",
|
||||
"add_subissue": "하위 이슈 추가...",
|
||||
|
||||
@@ -160,6 +160,7 @@
|
||||
"toast_duplicate_view": "查看已有 issue",
|
||||
"toast_link_subissues_all_failed": "关联子 issue 失败",
|
||||
"toast_link_subissues_partial": "{{total}} 个子 issue 中有 {{failed}} 个关联失败",
|
||||
"toast_link_labels_failed": "关联标签失败",
|
||||
"switch_to_agent": "切换到智能体",
|
||||
"switch_to_agent_tooltip": "切换到通过智能体创建——一句话描述,让它替你建",
|
||||
"switch_to_manual": "切换到手动",
|
||||
@@ -170,6 +171,7 @@
|
||||
"subissue_of": "{{identifier}} 的子 issue",
|
||||
"subissue_chip": "子 issue:{{identifier}}",
|
||||
"parent_with_id": "父:{{identifier}}",
|
||||
"set_due_date": "设置截止日期...",
|
||||
"set_start_date": "设置开始日期...",
|
||||
"set_parent": "设置父 issue...",
|
||||
"add_subissue": "添加子 issue...",
|
||||
|
||||
@@ -40,6 +40,7 @@ const mockDraftStore = {
|
||||
assigneeId: undefined as string | undefined,
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
labelIds: [] as string[],
|
||||
attachments: [] as Array<{
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
@@ -248,7 +249,16 @@ vi.mock("../issues/components", () => ({
|
||||
onClick={() => onOpenChange?.(false)}
|
||||
/>
|
||||
),
|
||||
DueDatePicker: () => <div data-testid="due-date-picker" />,
|
||||
// Due date now shares the start-date overflow pattern, so surface
|
||||
// open/onOpenChange to assert it too.
|
||||
DueDatePicker: ({ open, onOpenChange }: { open?: boolean; onOpenChange?: (v: boolean) => void }) => (
|
||||
<div
|
||||
data-testid="due-date-picker"
|
||||
data-open={open ? "true" : "false"}
|
||||
onClick={() => onOpenChange?.(false)}
|
||||
/>
|
||||
),
|
||||
LabelPicker: () => <div data-testid="label-picker" />,
|
||||
}));
|
||||
|
||||
vi.mock("../projects/components/project-picker", () => ({
|
||||
@@ -371,6 +381,7 @@ describe("CreateIssueModal", () => {
|
||||
mockDraftStore.draft.assigneeId = undefined;
|
||||
mockDraftStore.draft.startDate = null;
|
||||
mockDraftStore.draft.dueDate = null;
|
||||
mockDraftStore.draft.labelIds = [];
|
||||
mockDraftStore.draft.attachments = [];
|
||||
mockSetDraft.mockImplementation((patch: Partial<typeof mockDraftStore.draft>) => {
|
||||
mockDraftStore.draft = { ...mockDraftStore.draft, ...patch };
|
||||
@@ -385,6 +396,7 @@ describe("CreateIssueModal", () => {
|
||||
assigneeId: mockDraftStore.lastAssigneeId,
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
labelIds: [],
|
||||
attachments: [],
|
||||
};
|
||||
});
|
||||
@@ -501,6 +513,7 @@ describe("CreateIssueModal", () => {
|
||||
assigneeId: undefined,
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
labelIds: [],
|
||||
attachments: [],
|
||||
});
|
||||
});
|
||||
@@ -819,6 +832,35 @@ describe("CreateIssueModal", () => {
|
||||
expect(screen.queryByTestId("start-date-picker")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("exposes the label picker on the toolbar and keeps due date in the overflow menu", async () => {
|
||||
renderModal(<CreateIssueModal onClose={vi.fn()} />);
|
||||
|
||||
// Label entry is now surfaced directly on the dialog...
|
||||
expect(screen.getByTestId("label-picker")).toBeInTheDocument();
|
||||
// ...while due date is collapsed into the ⋯ menu (no inline pill yet).
|
||||
expect(screen.queryByTestId("due-date-picker")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Set due date/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides due date behind the overflow menu and reveals it on demand", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderModal(<CreateIssueModal onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByTestId("due-date-picker")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Set due date/i }));
|
||||
|
||||
const picker = await screen.findByTestId("due-date-picker");
|
||||
expect(picker).toHaveAttribute("data-open", "true");
|
||||
|
||||
await user.click(picker);
|
||||
|
||||
expect(screen.queryByTestId("due-date-picker")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Title + description are packed into the agent prompt on switch; if we
|
||||
// leave them in the shared draft store, the next agent→manual switch
|
||||
// surfaces the stale manual draft on top of the prompt-as-description,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ArrowLeftRight,
|
||||
ArrowUp,
|
||||
CalendarClock,
|
||||
CalendarDays,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Maximize2,
|
||||
@@ -35,7 +36,7 @@ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@multi
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import { ContentEditor, type ContentEditorRef, TitleEditor, useFileDropZone, FileDropOverlay } from "../editor";
|
||||
import { StatusIcon, StatusPicker, PriorityPicker, StagePicker, AssigneePicker, StartDatePicker, DueDatePicker } from "../issues/components";
|
||||
import { StatusIcon, StatusPicker, PriorityPicker, StagePicker, AssigneePicker, StartDatePicker, DueDatePicker, LabelPicker } from "../issues/components";
|
||||
import { maxSiblingStage } from "../issues/components/pickers/stage-picker";
|
||||
import { ProjectPicker } from "../projects/components/project-picker";
|
||||
import { useIssueTriggerPreview } from "../issues/hooks/use-issue-trigger-preview";
|
||||
@@ -47,6 +48,7 @@ import { useCreateModeStore } from "@multica/core/issues/stores/create-mode-stor
|
||||
import { useQuickCreateStore } from "@multica/core/issues/stores/quick-create-store";
|
||||
import { issueDetailOptions, childIssuesOptions } from "@multica/core/issues/queries";
|
||||
import { useCreateIssue, useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { useAttachLabelToIssue } from "@multica/core/labels";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import {
|
||||
api,
|
||||
@@ -223,6 +225,7 @@ export function ManualCreatePanel({
|
||||
});
|
||||
const [startDate, setStartDate] = useState<string | null>(draft.startDate);
|
||||
const [dueDate, setDueDate] = useState<string | null>(draft.dueDate);
|
||||
const [labelIds, setLabelIds] = useState<string[]>(draft.labelIds);
|
||||
const [projectId, setProjectId] = useState<string | undefined>(
|
||||
(data?.project_id as string) || undefined,
|
||||
);
|
||||
@@ -240,6 +243,10 @@ export function ManualCreatePanel({
|
||||
// mounts the inline pill (the popover's anchor) AND opens the calendar.
|
||||
// When the popover closes without a value set, the pill unmounts again.
|
||||
const [startDatePickerOpen, setStartDatePickerOpen] = useState(false);
|
||||
// Due date follows the same overflow pattern as start date: collapsed into
|
||||
// the ⋯ menu by default, mounted inline (as the popover anchor) only when it
|
||||
// has a value or the user just opened it from the menu.
|
||||
const [dueDatePickerOpen, setDueDatePickerOpen] = useState(false);
|
||||
// Children live as full Issue objects — the picker always returns the whole
|
||||
// object, and we never need to hydrate from an ID the way we do for parent.
|
||||
const [childIssues, setChildIssues] = useState<Issue[]>([]);
|
||||
@@ -300,15 +307,18 @@ export function ManualCreatePanel({
|
||||
};
|
||||
const updateStartDate = (v: string | null) => { setStartDate(v); setDraft({ startDate: v }); };
|
||||
const updateDueDate = (v: string | null) => { setDueDate(v); setDraft({ dueDate: v }); };
|
||||
const updateLabelIds = (ids: string[]) => { setLabelIds(ids); setDraft({ labelIds: ids }); };
|
||||
|
||||
const createIssueMutation = useCreateIssue();
|
||||
const updateIssueMutation = useUpdateIssue();
|
||||
const attachLabelMutation = useAttachLabelToIssue();
|
||||
const resetForNextIssue = () => {
|
||||
setTitle("");
|
||||
setStatus("todo");
|
||||
setPriority("none");
|
||||
setStartDate(null);
|
||||
setDueDate(null);
|
||||
setLabelIds([]);
|
||||
setProjectId(undefined);
|
||||
setParentIssueId(undefined);
|
||||
setStage(null);
|
||||
@@ -322,6 +332,7 @@ export function ManualCreatePanel({
|
||||
assigneeId,
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
labelIds: [],
|
||||
attachments: [],
|
||||
});
|
||||
descEditorRef.current?.clearContent();
|
||||
@@ -386,6 +397,28 @@ export function ManualCreatePanel({
|
||||
}
|
||||
}
|
||||
|
||||
// Attach the labels chosen in the dialog. Like the sub-issue links
|
||||
// above, this is deferred to after create because the new issue's ID
|
||||
// doesn't exist yet, and the create endpoint takes no labels. Partial
|
||||
// failures don't roll back the committed issue.
|
||||
if (labelIds.length > 0) {
|
||||
const results = await Promise.allSettled(
|
||||
labelIds.map((labelId) =>
|
||||
attachLabelMutation.mutateAsync({ issueId: issue.id, labelId }),
|
||||
),
|
||||
);
|
||||
let labelsFailed = 0;
|
||||
for (const result of results) {
|
||||
if (result.status === "rejected") {
|
||||
labelsFailed += 1;
|
||||
console.error("[create-issue] label attach failed", result.reason);
|
||||
}
|
||||
}
|
||||
if (labelsFailed > 0) {
|
||||
toast.error(t(($) => $.create_issue.toast_link_labels_failed));
|
||||
}
|
||||
}
|
||||
|
||||
setLastAssignee(assigneeType, assigneeId);
|
||||
setLastMode("manual");
|
||||
clearDraft();
|
||||
@@ -631,10 +664,13 @@ export function ManualCreatePanel({
|
||||
align="start"
|
||||
/>
|
||||
|
||||
{/* Due date */}
|
||||
<DueDatePicker
|
||||
dueDate={dueDate}
|
||||
onUpdate={(u) => updateDueDate(u.due_date ?? null)}
|
||||
{/* Labels — occupies the slot that used to hold Due date so the
|
||||
add-label entry is exposed directly on the dialog. Draft mode:
|
||||
selection is local until the issue is created (handleSubmit
|
||||
attaches the labels afterward). */}
|
||||
<LabelPicker
|
||||
selectedIds={labelIds}
|
||||
onSelectedIdsChange={updateLabelIds}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
/>
|
||||
@@ -674,6 +710,21 @@ export function ManualCreatePanel({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Due date — collapsed into the ⋯ menu by default (moved off
|
||||
the toolbar to make room for Labels). Same reveal rule as
|
||||
start date: inline only when it has a value or the user just
|
||||
opened it from the overflow menu. */}
|
||||
{(dueDate || dueDatePickerOpen) && (
|
||||
<DueDatePicker
|
||||
dueDate={dueDate}
|
||||
onUpdate={(u) => updateDueDate(u.due_date ?? null)}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
open={dueDatePickerOpen}
|
||||
onOpenChange={setDueDatePickerOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Parent chip — appears when parent is set.
|
||||
Placed before the ⋯ so it wraps to a new line with ⋯ if
|
||||
space is tight, but ⋯ always stays last in DOM order. */}
|
||||
@@ -735,6 +786,12 @@ export function ManualCreatePanel({
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-auto">
|
||||
{!dueDate && (
|
||||
<DropdownMenuItem onClick={() => setDueDatePickerOpen(true)}>
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
{t(($) => $.create_issue.set_due_date)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!startDate && (
|
||||
<DropdownMenuItem onClick={() => setStartDatePickerOpen(true)}>
|
||||
<CalendarClock className="h-3.5 w-3.5" />
|
||||
|
||||
Reference in New Issue
Block a user