mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
MUL-3254: fix pasted image draft rendering in desktop (#4066)
* fix: keep issue draft attachment records Co-authored-by: multica-agent <github@multica.ai> * fix: avoid persisting signed draft attachment urls Co-authored-by: multica-agent <github@multica.ai> * fix: reuse resolved media url for draft previews Co-authored-by: multica-agent <github@multica.ai> * fix: address draft attachment review nits - Backfill an empty caller download_url from the in-session upload on id collision so a just-pasted image first-paints from the signed URL instead of detouring through markdown_url. - Prune draft attachments no longer referenced by the persisted description when the create dialog reopens. - Backfill EMPTY_DRAFT defaults on draft-store rehydrate so drafts persisted before the attachments field existed get a stable shape. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -1,5 +1,28 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { useIssueDraftStore } from "./draft-store";
|
||||
import { setCurrentWorkspace } from "../../platform/workspace-storage";
|
||||
|
||||
const flush = () => new Promise((resolve) => queueMicrotask(() => resolve(null)));
|
||||
|
||||
// Node 25 ships a partial `localStorage` shim under jsdom that's missing
|
||||
// `clear`/`removeItem`; replace it with a real in-memory Storage so persist
|
||||
// can round-trip values.
|
||||
beforeAll(() => {
|
||||
if (typeof globalThis.localStorage?.clear !== "function") {
|
||||
const values = new Map<string, string>();
|
||||
const storage: Storage = {
|
||||
get length() { return values.size; },
|
||||
clear: () => values.clear(),
|
||||
getItem: (k) => values.get(k) ?? null,
|
||||
key: (i) => Array.from(values.keys())[i] ?? null,
|
||||
removeItem: (k) => { values.delete(k); },
|
||||
setItem: (k, v) => { values.set(k, v); },
|
||||
};
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage });
|
||||
Object.defineProperty(window, "localStorage", { configurable: true, value: storage });
|
||||
}
|
||||
});
|
||||
|
||||
const RESET_STATE = {
|
||||
draft: {
|
||||
@@ -11,6 +34,7 @@ const RESET_STATE = {
|
||||
assigneeId: undefined,
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
attachments: [],
|
||||
},
|
||||
lastAssigneeType: undefined,
|
||||
lastAssigneeId: undefined,
|
||||
@@ -46,6 +70,36 @@ describe("issue draft store — last assignee", () => {
|
||||
expect(draft.assigneeId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clearDraft removes persisted draft attachments", () => {
|
||||
const { setDraft, clearDraft } = useIssueDraftStore.getState();
|
||||
|
||||
setDraft({
|
||||
title: "first",
|
||||
attachments: [
|
||||
{
|
||||
id: "11111111-2222-3333-4444-555555555555",
|
||||
workspace_id: "ws-1",
|
||||
issue_id: null,
|
||||
comment_id: null,
|
||||
chat_session_id: null,
|
||||
chat_message_id: null,
|
||||
uploader_type: "member",
|
||||
uploader_id: "alice",
|
||||
filename: "shot.png",
|
||||
url: "https://cdn.example.test/shot.png",
|
||||
download_url: "https://cdn.example.test/shot.png",
|
||||
markdown_url: "https://app.example.test/api/attachments/11111111-2222-3333-4444-555555555555/download",
|
||||
content_type: "image/png",
|
||||
size_bytes: 123,
|
||||
created_at: "2026-06-12T00:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
clearDraft();
|
||||
|
||||
expect(useIssueDraftStore.getState().draft.attachments).toEqual([]);
|
||||
});
|
||||
|
||||
it("setLastAssignee(undefined) lets the user opt back out of a default", () => {
|
||||
const { setLastAssignee, clearDraft } = useIssueDraftStore.getState();
|
||||
|
||||
@@ -59,3 +113,42 @@ describe("issue draft store — last assignee", () => {
|
||||
expect(useIssueDraftStore.getState().draft.assigneeType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue draft store — legacy rehydrate", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
setCurrentWorkspace(null, null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setCurrentWorkspace(null, null);
|
||||
});
|
||||
|
||||
it("backfills attachments for drafts persisted before the field existed", async () => {
|
||||
localStorage.setItem(
|
||||
"multica_issue_draft:acme",
|
||||
JSON.stringify({
|
||||
state: {
|
||||
draft: {
|
||||
title: "legacy",
|
||||
description: "body",
|
||||
status: "todo",
|
||||
priority: "none",
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
// no `attachments` — written by a build that predates the field
|
||||
},
|
||||
},
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const { draft } = useIssueDraftStore.getState();
|
||||
expect(draft.title).toBe("legacy");
|
||||
expect(draft.attachments).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import type { IssueStatus, IssuePriority, IssueAssigneeType } from "../../types";
|
||||
import type { IssueStatus, IssuePriority, IssueAssigneeType, Attachment } from "../../types";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
@@ -13,6 +13,7 @@ interface IssueDraft {
|
||||
assigneeId?: string;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
attachments: Attachment[];
|
||||
}
|
||||
|
||||
const EMPTY_DRAFT: IssueDraft = {
|
||||
@@ -24,6 +25,7 @@ const EMPTY_DRAFT: IssueDraft = {
|
||||
assigneeId: undefined,
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
interface IssueDraftStore {
|
||||
@@ -65,6 +67,18 @@ export const useIssueDraftStore = create<IssueDraftStore>()(
|
||||
{
|
||||
name: "multica_issue_draft",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
// Drafts persisted by older builds predate fields added later (e.g.
|
||||
// `attachments`). Backfill EMPTY_DRAFT defaults on rehydrate so every
|
||||
// read site can rely on the declared IssueDraft shape instead of
|
||||
// re-defending with `?? fallback`.
|
||||
merge: (persistedState, currentState) => {
|
||||
const persisted = (persistedState ?? {}) as Partial<IssueDraftStore>;
|
||||
return {
|
||||
...currentState,
|
||||
...persisted,
|
||||
draft: { ...EMPTY_DRAFT, ...persisted.draft },
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -174,6 +174,26 @@ describe("AttachmentPreviewModal — dispatch", () => {
|
||||
expect(img?.getAttribute("alt")).toBe(att.filename);
|
||||
});
|
||||
|
||||
it("falls back to durable media URLs when a full attachment has no download_url", () => {
|
||||
const att = makeAttachment({
|
||||
filename: "shot.png",
|
||||
content_type: "image/png",
|
||||
download_url: "",
|
||||
markdown_url: "https://api.example.test/api/attachments/att-1/download",
|
||||
url: "https://cdn.example.test/att-1.png?Signature=old",
|
||||
});
|
||||
render(
|
||||
<AttachmentPreviewModal
|
||||
source={{ kind: "full", attachment: att }}
|
||||
open
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
const img = document.querySelector("img");
|
||||
expect(img?.getAttribute("src")).toBe(att.markdown_url);
|
||||
expect(img?.getAttribute("src")).not.toContain("Signature=");
|
||||
});
|
||||
|
||||
it("renders an <img> from a URL-only source for image filenames", () => {
|
||||
const url = "https://cdn.example.test/orphan.png?Signature=s";
|
||||
render(
|
||||
|
||||
@@ -92,6 +92,12 @@ interface PreviewState {
|
||||
attachmentId: string | null;
|
||||
}
|
||||
|
||||
function resolvePreviewMediaUrl(attachment: Attachment): string {
|
||||
const raw =
|
||||
attachment.download_url || attachment.markdown_url || attachment.url;
|
||||
return resolvePublicFileUrl(raw) ?? raw;
|
||||
}
|
||||
|
||||
function normalize(source: PreviewSource): PreviewState {
|
||||
// Resolve any server-relative URL (e.g. `/api/attachments/{id}/download`
|
||||
// returned by the unified-endpoint metadata path when no CloudFront
|
||||
@@ -102,13 +108,10 @@ function normalize(source: PreviewSource): PreviewState {
|
||||
// form so `<img src>` / `<iframe src>` / `<video src>` actually point at
|
||||
// the API server instead of the shell origin.
|
||||
if (source.kind === "full") {
|
||||
const mediaUrl =
|
||||
resolvePublicFileUrl(source.attachment.download_url) ??
|
||||
source.attachment.download_url;
|
||||
return {
|
||||
filename: source.attachment.filename,
|
||||
contentType: source.attachment.content_type,
|
||||
mediaUrl,
|
||||
mediaUrl: resolvePreviewMediaUrl(source.attachment),
|
||||
attachmentId: source.attachment.id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -259,6 +259,39 @@ describe("Attachment — image dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("opens preview with the same resolved media URL when a reopened draft record has no download_url", () => {
|
||||
configStore.setState({ cdnDomain: "cdn.example.test" });
|
||||
const id = "11111111-2222-3333-4444-555555555555";
|
||||
const markdownUrl = `https://multica-api.copilothub.ai/api/attachments/${id}/download`;
|
||||
const mediaUrl = "https://cdn.example.test/uploads/ws/shot.png";
|
||||
const att = makeRecord({
|
||||
id,
|
||||
url: mediaUrl,
|
||||
markdown_url: markdownUrl,
|
||||
download_url: "",
|
||||
});
|
||||
resolverState.attachments = [att];
|
||||
|
||||
renderWithQuery(
|
||||
<Attachment
|
||||
attachment={{
|
||||
kind: "url",
|
||||
url: markdownUrl,
|
||||
filename: "shot.png",
|
||||
forceKind: "image",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle("View"));
|
||||
|
||||
const imageSrcs = [...document.querySelectorAll("img")].map((img) =>
|
||||
img.getAttribute("src"),
|
||||
);
|
||||
expect(imageSrcs).toEqual([mediaUrl, mediaUrl]);
|
||||
expect(imageSrcs).not.toContain("");
|
||||
});
|
||||
|
||||
it("forceKind=image renders as image even when filename is empty (markdown  regression)", () => {
|
||||
renderWithQuery(
|
||||
<Attachment
|
||||
|
||||
@@ -312,7 +312,13 @@ export function Attachment({
|
||||
|
||||
const openPreview = () => {
|
||||
if (state.record) {
|
||||
preview.tryOpen({ kind: "full", attachment: state.record });
|
||||
preview.tryOpen({
|
||||
kind: "full",
|
||||
attachment: {
|
||||
...state.record,
|
||||
download_url: state.url || state.record.download_url,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (state.url) {
|
||||
|
||||
@@ -332,6 +332,44 @@ describe("ContentEditor — in-session attachment tracking (MUL-3192)", () => {
|
||||
expect(providerProps.attachments?.[0]?.download_url).toContain("Signature=fresh");
|
||||
});
|
||||
|
||||
it("backfills an empty caller download_url from the session upload on id collision", async () => {
|
||||
// The create-issue draft persists attachment records with download_url
|
||||
// stripped (the signed URL is response-scoped). While the upload session
|
||||
// is still alive, the provider should hand back the signed URL so the
|
||||
// just-pasted image first-paints from it instead of detouring through
|
||||
// markdown_url.
|
||||
const draftRecord = makeAttachment("draft-1", { download_url: "" });
|
||||
const uploaded = makeAttachment("draft-1", {
|
||||
download_url: "https://cdn.example/draft-1.png?Signature=fresh",
|
||||
});
|
||||
const onUploadFile = vi.fn(async () => asUploadResult(uploaded));
|
||||
uploadAndInsertFileMock.mockImplementation(
|
||||
async (_e: unknown, file: File, handler: (f: File) => Promise<unknown>) => {
|
||||
await handler(file);
|
||||
},
|
||||
);
|
||||
|
||||
let imperativeRef: { uploadFile: (file: File) => void } | null = null;
|
||||
render(
|
||||
<ContentEditor
|
||||
attachments={[draftRecord]}
|
||||
onUploadFile={onUploadFile}
|
||||
ref={(r) => {
|
||||
imperativeRef = r;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
imperativeRef?.uploadFile(new File(["x"], "draft-1.png", { type: "image/png" }));
|
||||
});
|
||||
|
||||
expect(providerProps.attachments).toHaveLength(1);
|
||||
expect(providerProps.attachments?.[0]?.download_url).toContain("Signature=fresh");
|
||||
// Everything except the backfilled field still comes from the caller copy.
|
||||
expect(providerProps.attachments?.[0]?.filename).toBe(draftRecord.filename);
|
||||
});
|
||||
|
||||
it("does not append a duplicate when the same upload result returns twice (paste-then-drop the same blob)", async () => {
|
||||
const result = asUploadResult(makeAttachment("dedup-1"));
|
||||
const onUploadFile = vi.fn(async () => result);
|
||||
|
||||
@@ -213,20 +213,26 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
// server) take precedence — we only append session uploads the caller
|
||||
// doesn't already have, so a parent re-render that includes the same record
|
||||
// doesn't end up with two copies.
|
||||
//
|
||||
// One exception on id collision: when the caller's copy has an EMPTY
|
||||
// `download_url` (the create-issue draft strips the short-lived signed URL
|
||||
// before persisting), backfill it from the session upload. The session copy
|
||||
// holds the this-response signed URL, so the just-pasted image first-paints
|
||||
// from it instead of taking an extra redirect hop through `markdown_url`.
|
||||
const providerAttachments = useMemo(() => {
|
||||
if (sessionUploads.length === 0) return attachments;
|
||||
const seen = new Set<string>();
|
||||
const sessionById = new Map(sessionUploads.map((a) => [a.id, a]));
|
||||
const merged: Attachment[] = [];
|
||||
for (const a of attachments ?? []) {
|
||||
if (a.id) seen.add(a.id);
|
||||
merged.push(a);
|
||||
}
|
||||
for (const a of sessionUploads) {
|
||||
if (!seen.has(a.id)) {
|
||||
seen.add(a.id);
|
||||
merged.push(a);
|
||||
}
|
||||
const session = a.id ? sessionById.get(a.id) : undefined;
|
||||
if (session) sessionById.delete(a.id);
|
||||
merged.push(
|
||||
session && !a.download_url
|
||||
? { ...a, download_url: session.download_url }
|
||||
: a,
|
||||
);
|
||||
}
|
||||
merged.push(...sessionById.values());
|
||||
return merged;
|
||||
}, [attachments, sessionUploads]);
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ const mockSetKeepOpen = vi.hoisted(() => vi.fn());
|
||||
const mockToastCustom = vi.hoisted(() => vi.fn());
|
||||
const mockToastDismiss = vi.hoisted(() => vi.fn());
|
||||
const mockToastError = vi.hoisted(() => vi.fn());
|
||||
const mockUploadWithToast = vi.hoisted(() => vi.fn());
|
||||
|
||||
const mockDraftStore = {
|
||||
draft: {
|
||||
@@ -39,6 +40,23 @@ const mockDraftStore = {
|
||||
assigneeId: undefined as string | undefined,
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
attachments: [] as Array<{
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
issue_id: string | null;
|
||||
comment_id: string | null;
|
||||
chat_session_id: string | null;
|
||||
chat_message_id: string | null;
|
||||
uploader_type: string;
|
||||
uploader_id: string;
|
||||
filename: string;
|
||||
url: string;
|
||||
download_url: string;
|
||||
markdown_url: string;
|
||||
content_type: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
}>,
|
||||
},
|
||||
lastAssigneeType: undefined,
|
||||
lastAssigneeId: undefined,
|
||||
@@ -93,7 +111,7 @@ vi.mock("@multica/core/issues/mutations", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/hooks/use-file-upload", () => ({
|
||||
useFileUpload: () => ({ uploadWithToast: vi.fn() }),
|
||||
useFileUpload: () => ({ uploadWithToast: mockUploadWithToast }),
|
||||
}));
|
||||
|
||||
// Hoisted ApiError class so both the vi.mock factory and the tests below
|
||||
@@ -137,7 +155,7 @@ vi.mock("@multica/core/api", async () => {
|
||||
});
|
||||
|
||||
vi.mock("../editor", () => {
|
||||
const ContentEditor = forwardRef(({ defaultValue, onUpdate, placeholder }: any, ref: any) => {
|
||||
const ContentEditor = forwardRef(({ defaultValue, onUpdate, onUploadFile, placeholder, attachments }: any, ref: any) => {
|
||||
const valueRef = useRef(defaultValue || "");
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -146,18 +164,21 @@ vi.mock("../editor", () => {
|
||||
valueRef.current = "";
|
||||
setValue("");
|
||||
},
|
||||
uploadFile: vi.fn(),
|
||||
uploadFile: (file: File) => onUploadFile?.(file),
|
||||
}));
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
valueRef.current = e.target.value;
|
||||
setValue(e.target.value);
|
||||
onUpdate?.(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<textarea
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
data-attachments-count={attachments?.length ?? 0}
|
||||
onChange={(e) => {
|
||||
valueRef.current = e.target.value;
|
||||
setValue(e.target.value);
|
||||
onUpdate?.(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
ContentEditor.displayName = "ContentEditor";
|
||||
@@ -312,10 +333,52 @@ describe("CreateIssueModal", () => {
|
||||
mockSetKeepOpen.mockImplementation((v: boolean) => {
|
||||
mockQuickCreateStore.keepOpen = v;
|
||||
});
|
||||
mockDraftStore.draft.title = "";
|
||||
mockDraftStore.draft.description = "";
|
||||
mockDraftStore.draft.status = "todo";
|
||||
mockDraftStore.draft.priority = "none";
|
||||
// Reset the shared draft mock so per-test assignee seeding (squad / agent)
|
||||
// doesn't leak into the next test in the suite.
|
||||
mockDraftStore.draft.assigneeType = undefined;
|
||||
mockDraftStore.draft.assigneeId = undefined;
|
||||
mockDraftStore.draft.startDate = null;
|
||||
mockDraftStore.draft.dueDate = null;
|
||||
mockDraftStore.draft.attachments = [];
|
||||
mockSetDraft.mockImplementation((patch: Partial<typeof mockDraftStore.draft>) => {
|
||||
mockDraftStore.draft = { ...mockDraftStore.draft, ...patch };
|
||||
});
|
||||
mockClearDraft.mockImplementation(() => {
|
||||
mockDraftStore.draft = {
|
||||
title: "",
|
||||
description: "",
|
||||
status: "todo",
|
||||
priority: "none",
|
||||
assigneeType: mockDraftStore.lastAssigneeType,
|
||||
assigneeId: mockDraftStore.lastAssigneeId,
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
attachments: [],
|
||||
};
|
||||
});
|
||||
mockUploadWithToast.mockResolvedValue({
|
||||
id: "11111111-2222-3333-4444-555555555555",
|
||||
workspace_id: "ws-test",
|
||||
issue_id: null,
|
||||
comment_id: null,
|
||||
chat_session_id: null,
|
||||
chat_message_id: null,
|
||||
uploader_type: "member",
|
||||
uploader_id: "user-1",
|
||||
filename: "shot.png",
|
||||
url: "https://cdn.example.test/shot.png",
|
||||
download_url: "https://cdn.example.test/shot.png?Signature=fresh",
|
||||
markdown_url: "https://multica-api.copilothub.ai/api/attachments/11111111-2222-3333-4444-555555555555/download",
|
||||
content_type: "image/png",
|
||||
size_bytes: 123,
|
||||
created_at: "2026-06-12T00:00:00Z",
|
||||
link: "https://cdn.example.test/shot.png",
|
||||
markdownLink: "https://multica-api.copilothub.ai/api/attachments/11111111-2222-3333-4444-555555555555/download",
|
||||
});
|
||||
mockCreateIssue.mockResolvedValue({
|
||||
id: "issue-123",
|
||||
identifier: "TES-123",
|
||||
@@ -410,6 +473,111 @@ describe("CreateIssueModal", () => {
|
||||
assigneeId: undefined,
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
attachments: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("persists manual-mode uploads in the issue draft", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderModal(<CreateIssueModal onClose={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Upload file" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetDraft).toHaveBeenCalledWith({
|
||||
attachments: [
|
||||
expect.objectContaining({
|
||||
id: "11111111-2222-3333-4444-555555555555",
|
||||
filename: "shot.png",
|
||||
download_url: "",
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
const draftAttachmentsCall = mockSetDraft.mock.calls.find(
|
||||
([patch]) => Array.isArray(patch.attachments),
|
||||
)?.[0] as { attachments?: Array<{ download_url: string }> } | undefined;
|
||||
expect(draftAttachmentsCall?.attachments?.[0]?.download_url).not.toContain(
|
||||
"Signature=",
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses draft attachments after reopening manual create so pasted images can render and bind", async () => {
|
||||
const user = userEvent.setup();
|
||||
const attachment = {
|
||||
id: "11111111-2222-3333-4444-555555555555",
|
||||
workspace_id: "ws-test",
|
||||
issue_id: null,
|
||||
comment_id: null,
|
||||
chat_session_id: null,
|
||||
chat_message_id: null,
|
||||
uploader_type: "member",
|
||||
uploader_id: "user-1",
|
||||
filename: "shot.png",
|
||||
url: "https://cdn.example.test/shot.png",
|
||||
download_url: "",
|
||||
markdown_url: "https://multica-api.copilothub.ai/api/attachments/11111111-2222-3333-4444-555555555555/download",
|
||||
content_type: "image/png",
|
||||
size_bytes: 123,
|
||||
created_at: "2026-06-12T00:00:00Z",
|
||||
};
|
||||
mockDraftStore.draft.title = "Image draft";
|
||||
mockDraftStore.draft.description = ``;
|
||||
mockDraftStore.draft.attachments = [attachment];
|
||||
|
||||
renderModal(<CreateIssueModal onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.getByPlaceholderText("Add description...")).toHaveAttribute(
|
||||
"data-attachments-count",
|
||||
"1",
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Create Issue" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateIssue).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: ``,
|
||||
attachment_ids: ["11111111-2222-3333-4444-555555555555"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("prunes draft attachments the reopened description no longer references", async () => {
|
||||
const referenced = {
|
||||
id: "11111111-2222-3333-4444-555555555555",
|
||||
workspace_id: "ws-test",
|
||||
issue_id: null,
|
||||
comment_id: null,
|
||||
chat_session_id: null,
|
||||
chat_message_id: null,
|
||||
uploader_type: "member",
|
||||
uploader_id: "user-1",
|
||||
filename: "kept.png",
|
||||
url: "https://cdn.example.test/kept.png",
|
||||
download_url: "",
|
||||
markdown_url: "https://multica-api.copilothub.ai/api/attachments/11111111-2222-3333-4444-555555555555/download",
|
||||
content_type: "image/png",
|
||||
size_bytes: 123,
|
||||
created_at: "2026-06-12T00:00:00Z",
|
||||
};
|
||||
const deleted = {
|
||||
...referenced,
|
||||
id: "99999999-8888-7777-6666-555555555555",
|
||||
filename: "deleted.png",
|
||||
url: "https://cdn.example.test/deleted.png",
|
||||
markdown_url: "https://multica-api.copilothub.ai/api/attachments/99999999-8888-7777-6666-555555555555/download",
|
||||
};
|
||||
mockDraftStore.draft.title = "Image draft";
|
||||
mockDraftStore.draft.description = ``;
|
||||
mockDraftStore.draft.attachments = [referenced, deleted];
|
||||
|
||||
renderModal(<CreateIssueModal onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetDraft).toHaveBeenCalledWith({ attachments: [referenced] });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigation } from "../navigation";
|
||||
import {
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "@multica/core/types";
|
||||
import type { Issue, IssueStatus, IssuePriority, IssueAssigneeType, Attachment } from "@multica/core/types";
|
||||
import { contentReferencesAttachment } from "@multica/core/types";
|
||||
import {
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
@@ -57,6 +58,17 @@ import { PillButton } from "../common/pill-button";
|
||||
import { IssuePickerModal } from "./issue-picker-modal";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
function toDraftAttachment(attachment: Attachment): Attachment {
|
||||
return {
|
||||
...attachment,
|
||||
// `download_url` is minted for the current API response and may be a
|
||||
// short-lived signed URL. Drafts survive across dialog closes and app
|
||||
// restarts, so persist only durable fields and let render/download paths
|
||||
// re-resolve through id/markdown_url when needed.
|
||||
download_url: "",
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ManualCreatePanel — manual-mode body of the create-issue dialog. Renders
|
||||
// DialogContent + everything inside; the surrounding `<Dialog>` is owned by
|
||||
@@ -146,13 +158,34 @@ export function ManualCreatePanel({
|
||||
enabled: !!parentIssueId,
|
||||
});
|
||||
|
||||
// File upload — collect attachment IDs so we can link them after issue creation.
|
||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
|
||||
const draftAttachments = draft.attachments ?? [];
|
||||
|
||||
// Prune draft attachments whose markdown reference was deleted in an
|
||||
// earlier editing session. Runs once on mount: at that point the persisted
|
||||
// description IS the draft body (no editor edits have happened yet), so
|
||||
// dropping unreferenced records is safe. Don't prune on description updates
|
||||
// — an onUpdate flush can race a just-finished upload whose markdown link
|
||||
// hasn't been inserted yet, and pruning there would drop a live attachment.
|
||||
useEffect(() => {
|
||||
const { draft: current } = useIssueDraftStore.getState();
|
||||
const attachments = current.attachments ?? [];
|
||||
const kept = attachments.filter((a) =>
|
||||
contentReferencesAttachment(current.description, a),
|
||||
);
|
||||
if (kept.length !== attachments.length) setDraft({ attachments: kept });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const { uploadWithToast } = useFileUpload(api);
|
||||
const handleUpload = async (file: File) => {
|
||||
const result = await uploadWithToast(file);
|
||||
if (result) {
|
||||
setAttachmentIds((prev) => [...prev, result.id]);
|
||||
const currentAttachments =
|
||||
useIssueDraftStore.getState().draft.attachments ?? [];
|
||||
const attachments = currentAttachments.some((a) => a.id === result.id)
|
||||
? currentAttachments
|
||||
: [...currentAttachments, toDraftAttachment(result)];
|
||||
setDraft({ attachments });
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -179,7 +212,6 @@ export function ManualCreatePanel({
|
||||
setProjectId(undefined);
|
||||
setParentIssueId(undefined);
|
||||
setChildIssues([]);
|
||||
setAttachmentIds([]);
|
||||
setDraft({
|
||||
title: "",
|
||||
description: "",
|
||||
@@ -189,6 +221,7 @@ export function ManualCreatePanel({
|
||||
assigneeId,
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
attachments: [],
|
||||
});
|
||||
descEditorRef.current?.clearContent();
|
||||
setFormResetKey((key) => key + 1);
|
||||
@@ -198,16 +231,20 @@ export function ManualCreatePanel({
|
||||
if (!title.trim() || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const description = descEditorRef.current?.getMarkdown()?.trim() || undefined;
|
||||
const activeAttachmentIds = draftAttachments
|
||||
.filter((a) => contentReferencesAttachment(description ?? "", a))
|
||||
.map((a) => a.id);
|
||||
const issue = await createIssueMutation.mutateAsync({
|
||||
title: title.trim(),
|
||||
description: descEditorRef.current?.getMarkdown()?.trim() || undefined,
|
||||
description,
|
||||
status,
|
||||
priority,
|
||||
assignee_type: assigneeType,
|
||||
assignee_id: assigneeId,
|
||||
start_date: startDate || undefined,
|
||||
due_date: dueDate || undefined,
|
||||
attachment_ids: attachmentIds.length > 0 ? attachmentIds : undefined,
|
||||
attachment_ids: activeAttachmentIds.length > 0 ? activeAttachmentIds : undefined,
|
||||
parent_issue_id: parentIssueId,
|
||||
project_id: projectId,
|
||||
});
|
||||
@@ -482,6 +519,7 @@ export function ManualCreatePanel({
|
||||
onUpdate={(md) => setDraft({ description: md })}
|
||||
onUploadFile={handleUpload}
|
||||
debounceMs={500}
|
||||
attachments={draftAttachments}
|
||||
/>
|
||||
{descDragOver && <FileDropOverlay />}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user