mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-28 18:09:14 +02:00
Compare commits
1 Commits
agent/lamb
...
feat/compo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
755d8fad98 |
@@ -592,10 +592,10 @@ export class ApiClient {
|
||||
return this.fetch("/api/assignee-frequency");
|
||||
}
|
||||
|
||||
async updateComment(commentId: string, content: string): Promise<Comment> {
|
||||
async updateComment(commentId: string, content: string, attachmentIds?: string[]): Promise<Comment> {
|
||||
return this.fetch(`/api/comments/${commentId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ content }),
|
||||
body: JSON.stringify({ content, attachment_ids: attachmentIds }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ import type { ApiClient } from "../api/client";
|
||||
import type { Attachment } from "../types";
|
||||
import { MAX_FILE_SIZE } from "../constants/upload";
|
||||
|
||||
export interface UploadResult {
|
||||
id: string;
|
||||
filename: string;
|
||||
link: string;
|
||||
}
|
||||
// Carries the full Attachment so editors that need preview metadata
|
||||
// (`content_type`, `download_url`) get it directly; `link` is kept as an
|
||||
// alias for `url` because many callers persist it into Markdown / avatar
|
||||
// fields by that name.
|
||||
export type UploadResult = Attachment & { link: string };
|
||||
|
||||
export interface UploadContext {
|
||||
issueId?: string;
|
||||
@@ -36,7 +36,7 @@ export function useFileUpload(
|
||||
commentId: ctx?.commentId,
|
||||
chatSessionId: ctx?.chatSessionId,
|
||||
});
|
||||
return { id: att.id, filename: att.filename, link: att.url };
|
||||
return { ...att, link: att.url };
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
@@ -200,6 +200,13 @@ export function useUpdateIssue() {
|
||||
onSettled: (_data, _err, vars, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
// Refresh the issue's attachments cache when the description editor
|
||||
// bound new uploads — the description editor reads `issueAttachments`
|
||||
// to resolve text-preview Eye gates, and unlike other mutations this
|
||||
// payload mutates the attachment join table.
|
||||
if (vars.attachment_ids?.length) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.attachments(vars.id) });
|
||||
}
|
||||
// Invalidate old parent's children cache
|
||||
if (ctx?.parentId) {
|
||||
qc.invalidateQueries({
|
||||
@@ -496,8 +503,8 @@ export function useCreateComment(issueId: string) {
|
||||
export function useUpdateComment(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ commentId, content }: { commentId: string; content: string }) =>
|
||||
api.updateComment(commentId, content),
|
||||
mutationFn: ({ commentId, content, attachmentIds }: { commentId: string; content: string; attachmentIds?: string[] }) =>
|
||||
api.updateComment(commentId, content, attachmentIds),
|
||||
onMutate: async ({ commentId, content }) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));
|
||||
|
||||
@@ -27,6 +27,10 @@ export interface UpdateIssueRequest {
|
||||
due_date?: string | null;
|
||||
parent_issue_id?: string | null;
|
||||
project_id?: string | null;
|
||||
/** Attachment IDs to bind to this issue alongside the description update.
|
||||
* Used by the description editor to register newly uploaded files so they
|
||||
* surface in `issueAttachments` and keep their preview Eye on refresh. */
|
||||
attachment_ids?: string[];
|
||||
}
|
||||
|
||||
export interface ListIssuesParams {
|
||||
|
||||
@@ -2,9 +2,28 @@ import { forwardRef, useRef, useImperativeHandle } from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
|
||||
import enCommon from "../../locales/en/common.json";
|
||||
import enChat from "../../locales/en/chat.json";
|
||||
|
||||
function makeUpload(overrides: Partial<UploadResult> & { id: string; link: string; filename: string }): UploadResult {
|
||||
return {
|
||||
workspace_id: "ws-1",
|
||||
issue_id: null,
|
||||
comment_id: null,
|
||||
chat_session_id: null,
|
||||
chat_message_id: null,
|
||||
uploader_type: "member",
|
||||
uploader_id: "user-1",
|
||||
url: overrides.link,
|
||||
download_url: overrides.link,
|
||||
content_type: "image/png",
|
||||
size_bytes: 1,
|
||||
created_at: new Date(0).toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const TEST_RESOURCES = { en: { common: enCommon, chat: enChat } };
|
||||
|
||||
// Track drop-zone callbacks so the test can simulate a real drop.
|
||||
@@ -28,7 +47,7 @@ vi.mock("../../editor", () => ({
|
||||
defaultValue?: string;
|
||||
onUpdate?: (md: string) => void;
|
||||
placeholder?: string;
|
||||
onUploadFile?: (file: File) => Promise<{ id: string; link: string; filename: string } | null>;
|
||||
onUploadFile?: (file: File) => Promise<UploadResult | null>;
|
||||
},
|
||||
ref: React.Ref<unknown>,
|
||||
) {
|
||||
@@ -95,11 +114,9 @@ function renderInput(props: Partial<React.ComponentProps<typeof ChatInput>> = {}
|
||||
const onSend = props.onSend ?? vi.fn();
|
||||
const onUploadFile =
|
||||
props.onUploadFile ??
|
||||
vi.fn(async (_file: File) => ({
|
||||
id: "att-1",
|
||||
link: "https://cdn.example/att-1.png",
|
||||
filename: "img.png",
|
||||
}));
|
||||
vi.fn(async (_file: File) =>
|
||||
makeUpload({ id: "att-1", link: "https://cdn.example/att-1.png", filename: "img.png" }),
|
||||
);
|
||||
render(
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<ChatInput onSend={onSend} onUploadFile={onUploadFile} agentName="Multica" {...props} />
|
||||
@@ -122,11 +139,9 @@ describe("ChatInput attachment wiring", () => {
|
||||
|
||||
it("passes attachment_ids to onSend for uploads still referenced in the content", async () => {
|
||||
const onSend = vi.fn();
|
||||
const onUploadFile = vi.fn(async (_file: File) => ({
|
||||
id: "att-42",
|
||||
link: "https://cdn.example/att-42.png",
|
||||
filename: "x.png",
|
||||
}));
|
||||
const onUploadFile = vi.fn(async (_file: File) =>
|
||||
makeUpload({ id: "att-42", link: "https://cdn.example/att-42.png", filename: "x.png" }),
|
||||
);
|
||||
renderInput({ onSend, onUploadFile });
|
||||
|
||||
// Simulate the drop → editor.uploadFile → onUploadFile happy path. The
|
||||
@@ -152,8 +167,8 @@ describe("ChatInput attachment wiring", () => {
|
||||
});
|
||||
|
||||
it("disables send while an upload is in flight, re-enables after it resolves", async () => {
|
||||
let resolveUpload: (v: { id: string; link: string; filename: string }) => void;
|
||||
const uploadPromise = new Promise<{ id: string; link: string; filename: string }>((res) => {
|
||||
let resolveUpload: (v: UploadResult) => void;
|
||||
const uploadPromise = new Promise<UploadResult>((res) => {
|
||||
resolveUpload = res;
|
||||
});
|
||||
const onSend = vi.fn();
|
||||
@@ -177,11 +192,7 @@ describe("ChatInput attachment wiring", () => {
|
||||
expect(sendButton).toBeDisabled();
|
||||
});
|
||||
|
||||
resolveUpload!({
|
||||
id: "att-slow",
|
||||
link: "https://cdn.example/att-slow.png",
|
||||
filename: "slow.png",
|
||||
});
|
||||
resolveUpload!(makeUpload({ id: "att-slow", link: "https://cdn.example/att-slow.png", filename: "slow.png" }));
|
||||
|
||||
let sendButton: HTMLElement;
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -64,7 +64,7 @@ interface CommentCardProps {
|
||||
*/
|
||||
canModerate?: boolean;
|
||||
onReply: (parentId: string, content: string, attachmentIds?: string[]) => Promise<void>;
|
||||
onEdit: (commentId: string, content: string) => Promise<void>;
|
||||
onEdit: (commentId: string, content: string, attachmentIds?: string[]) => Promise<void>;
|
||||
onDelete: (commentId: string) => void;
|
||||
onToggleReaction: (commentId: string, emoji: string) => void;
|
||||
/** Toggle the resolved state on the thread root. Only invoked for root entries. */
|
||||
@@ -202,7 +202,7 @@ function CommentRow({
|
||||
entry: TimelineEntry;
|
||||
currentUserId?: string;
|
||||
canModerate?: boolean;
|
||||
onEdit: (commentId: string, content: string) => Promise<void>;
|
||||
onEdit: (commentId: string, content: string, attachmentIds?: string[]) => Promise<void>;
|
||||
onDelete: (commentId: string) => void;
|
||||
onToggleReaction: (commentId: string, emoji: string) => void;
|
||||
}) {
|
||||
@@ -212,6 +212,20 @@ function CommentRow({
|
||||
const editEditorRef = useRef<ContentEditorRef>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
const { uploadWithToast } = useFileUpload(api);
|
||||
// Pending uploads from this edit pass. Merged with `entry.attachments` so
|
||||
// newly uploaded text/code files get an Eye button in the edit-mode editor;
|
||||
// the active subset is sent as `attachmentIds` on save so the server binds
|
||||
// them to the comment (otherwise they'd remain orphaned at the issue level
|
||||
// and disappear after refresh).
|
||||
const [pendingAttachments, setPendingAttachments] = useState<Attachment[]>([]);
|
||||
const editorAttachments = pendingAttachments.length > 0
|
||||
? [...(entry.attachments ?? []), ...pendingAttachments]
|
||||
: entry.attachments;
|
||||
const handleEditUpload = useCallback(async (file: File) => {
|
||||
const result = await uploadWithToast(file, { issueId });
|
||||
if (result) setPendingAttachments((prev) => [...prev, result]);
|
||||
return result;
|
||||
}, [uploadWithToast, issueId]);
|
||||
const { isDragOver, dropZoneProps } = useFileDropZone({
|
||||
onDrop: (files) => files.forEach((f) => editEditorRef.current?.uploadFile(f)),
|
||||
enabled: editing,
|
||||
@@ -247,6 +261,7 @@ function CommentRow({
|
||||
const cancelEdit = () => {
|
||||
cancelledRef.current = true;
|
||||
setEditing(false);
|
||||
setPendingAttachments([]);
|
||||
clearEditDraft(editDraftKey);
|
||||
};
|
||||
|
||||
@@ -258,12 +273,17 @@ function CommentRow({
|
||||
.trim();
|
||||
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
|
||||
setEditing(false);
|
||||
setPendingAttachments([]);
|
||||
clearEditDraft(editDraftKey);
|
||||
return;
|
||||
}
|
||||
const activeIds = pendingAttachments
|
||||
.filter((a) => trimmed.includes(a.url))
|
||||
.map((a) => a.id);
|
||||
try {
|
||||
await onEdit(entry.id, trimmed);
|
||||
await onEdit(entry.id, trimmed, activeIds.length > 0 ? activeIds : undefined);
|
||||
setEditing(false);
|
||||
setPendingAttachments([]);
|
||||
clearEditDraft(editDraftKey);
|
||||
} catch {
|
||||
toast.error(t(($) => $.comment.update_failed));
|
||||
@@ -361,10 +381,10 @@ function CommentRow({
|
||||
else clearEditDraft(editDraftKey);
|
||||
}}
|
||||
onSubmit={saveEdit}
|
||||
onUploadFile={(file) => uploadWithToast(file, { issueId })}
|
||||
onUploadFile={handleEditUpload}
|
||||
debounceMs={100}
|
||||
currentIssueId={issueId}
|
||||
attachments={entry.attachments}
|
||||
attachments={editorAttachments}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
@@ -429,6 +449,16 @@ function CommentCardImpl({
|
||||
const [editing, setEditing] = useState(false);
|
||||
const editEditorRef = useRef<ContentEditorRef>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
// Pending uploads from the root-comment edit pass — same rationale as CommentRow.
|
||||
const [parentPendingAttachments, setParentPendingAttachments] = useState<Attachment[]>([]);
|
||||
const parentEditorAttachments = parentPendingAttachments.length > 0
|
||||
? [...(entry.attachments ?? []), ...parentPendingAttachments]
|
||||
: entry.attachments;
|
||||
const handleParentEditUpload = useCallback(async (file: File) => {
|
||||
const result = await uploadWithToast(file, { issueId });
|
||||
if (result) setParentPendingAttachments((prev) => [...prev, result]);
|
||||
return result;
|
||||
}, [uploadWithToast, issueId]);
|
||||
const { isDragOver: parentDragOver, dropZoneProps: parentDropZoneProps } = useFileDropZone({
|
||||
onDrop: (files) => files.forEach((f) => editEditorRef.current?.uploadFile(f)),
|
||||
enabled: editing,
|
||||
@@ -461,6 +491,7 @@ function CommentCardImpl({
|
||||
const cancelEdit = () => {
|
||||
cancelledRef.current = true;
|
||||
setEditing(false);
|
||||
setParentPendingAttachments([]);
|
||||
clearParentEditDraft(parentEditDraftKey);
|
||||
};
|
||||
|
||||
@@ -472,12 +503,17 @@ function CommentCardImpl({
|
||||
.trim();
|
||||
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
|
||||
setEditing(false);
|
||||
setParentPendingAttachments([]);
|
||||
clearParentEditDraft(parentEditDraftKey);
|
||||
return;
|
||||
}
|
||||
const activeIds = parentPendingAttachments
|
||||
.filter((a) => trimmed.includes(a.url))
|
||||
.map((a) => a.id);
|
||||
try {
|
||||
await onEdit(entry.id, trimmed);
|
||||
await onEdit(entry.id, trimmed, activeIds.length > 0 ? activeIds : undefined);
|
||||
setEditing(false);
|
||||
setParentPendingAttachments([]);
|
||||
clearParentEditDraft(parentEditDraftKey);
|
||||
} catch {
|
||||
toast.error(t(($) => $.comment.update_failed));
|
||||
@@ -639,10 +675,10 @@ function CommentCardImpl({
|
||||
else clearParentEditDraft(parentEditDraftKey);
|
||||
}}
|
||||
onSubmit={saveEdit}
|
||||
onUploadFile={(file) => uploadWithToast(file, { issueId })}
|
||||
onUploadFile={handleParentEditUpload}
|
||||
debounceMs={100}
|
||||
currentIssueId={issueId}
|
||||
attachments={entry.attachments}
|
||||
attachments={parentEditorAttachments}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FileUploadButton } from "@multica/ui/components/common/file-upload-butt
|
||||
import { SubmitButton } from "@multica/ui/components/common/submit-button";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { api } from "@multica/core/api";
|
||||
import type { Attachment } from "@multica/core/types";
|
||||
import { enterKey, formatShortcut, modKey } from "@multica/core/platform";
|
||||
import { useCommentDraftStore } from "@multica/core/issues/stores";
|
||||
import { useT } from "../../i18n";
|
||||
@@ -30,7 +31,11 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
const [isEmpty, setIsEmpty] = useState(() => !initialDraft?.trim());
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const uploadMapRef = useRef<Map<string, string>>(new Map());
|
||||
// Attachments uploaded in this composer session. Drives both:
|
||||
// - submit-time `attachment_ids` payload (filtered to URLs still in markdown)
|
||||
// - the editor's AttachmentDownloadProvider, so file-card Eye buttons can
|
||||
// resolve text/code/markdown previews that require the attachment id.
|
||||
const [pendingAttachments, setPendingAttachments] = useState<Attachment[]>([]);
|
||||
const { uploadWithToast } = useFileUpload(api);
|
||||
const { isDragOver, dropZoneProps } = useFileDropZone({
|
||||
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
|
||||
@@ -59,7 +64,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
const handleUpload = useCallback(async (file: File) => {
|
||||
const result = await uploadWithToast(file, { issueId });
|
||||
if (result) {
|
||||
uploadMapRef.current.set(result.link, result.id);
|
||||
setPendingAttachments((prev) => [...prev, result]);
|
||||
}
|
||||
return result;
|
||||
}, [uploadWithToast, issueId]);
|
||||
@@ -68,16 +73,15 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
||||
if (!content || submitting) return;
|
||||
// Only send attachment IDs for uploads still present in the content.
|
||||
const activeIds: string[] = [];
|
||||
for (const [url, id] of uploadMapRef.current) {
|
||||
if (content.includes(url)) activeIds.push(id);
|
||||
}
|
||||
const activeIds = pendingAttachments
|
||||
.filter((a) => content.includes(a.url))
|
||||
.map((a) => a.id);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(content, activeIds.length > 0 ? activeIds : undefined);
|
||||
editorRef.current?.clearContent();
|
||||
setIsEmpty(true);
|
||||
uploadMapRef.current.clear();
|
||||
setPendingAttachments([]);
|
||||
clearDraft(draftKey);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -108,6 +112,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
onUploadFile={handleUpload}
|
||||
debounceMs={100}
|
||||
currentIssueId={issueId}
|
||||
attachments={pendingAttachments}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
|
||||
|
||||
@@ -38,7 +38,7 @@ import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, Command
|
||||
import { AvatarGroup, AvatarGroupCount } from "@multica/ui/components/ui/avatar";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { PropRow } from "../../common/prop-row";
|
||||
import type { Issue, IssueStatus, IssuePriority, TimelineEntry, UpdateIssueRequest } from "@multica/core/types";
|
||||
import type { Attachment, Issue, IssueStatus, IssuePriority, TimelineEntry, UpdateIssueRequest } from "@multica/core/types";
|
||||
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@multica/core/issues/config";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { toast } from "sonner";
|
||||
@@ -816,10 +816,21 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
const { isDragOver: descDragOver, dropZoneProps: descDropZoneProps } = useFileDropZone({
|
||||
onDrop: (files) => files.forEach((f) => descEditorRef.current?.uploadFile(f)),
|
||||
});
|
||||
// Description uploads don't pass issueId — the URL lives in the markdown.
|
||||
// This avoids stale attachment records when users delete images from the editor.
|
||||
// Pending uploads in the description editor. We don't pass `issueId` on
|
||||
// upload (to avoid orphaning attachments when the user deletes the file
|
||||
// from the markdown), so they start unattached and we re-bind them via
|
||||
// `attachment_ids` on the next description save. Drives editor previews
|
||||
// so text/code attachments show an Eye before the bind round-trips.
|
||||
const [descPendingAttachments, setDescPendingAttachments] = useState<Attachment[]>([]);
|
||||
const descEditorAttachments = descPendingAttachments.length > 0
|
||||
? [...(issueAttachments ?? []), ...descPendingAttachments]
|
||||
: issueAttachments;
|
||||
const handleDescriptionUpload = useCallback(
|
||||
(file: File) => uploadWithToast(file),
|
||||
async (file: File) => {
|
||||
const result = await uploadWithToast(file);
|
||||
if (result) setDescPendingAttachments((prev) => [...prev, result]);
|
||||
return result;
|
||||
},
|
||||
[uploadWithToast],
|
||||
);
|
||||
|
||||
@@ -1287,11 +1298,19 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
key={id}
|
||||
defaultValue={issue.description || ""}
|
||||
placeholder={t(($) => $.detail.desc_placeholder)}
|
||||
onUpdate={(md) => handleUpdateField({ description: md })}
|
||||
onUpdate={(md) => {
|
||||
// Bind any pending uploads still referenced in the markdown
|
||||
// so they appear in `issueAttachments` after refresh and the
|
||||
// editor's text/code preview keeps working past reload.
|
||||
const ids = descPendingAttachments
|
||||
.filter((a) => md.includes(a.url))
|
||||
.map((a) => a.id);
|
||||
handleUpdateField({ description: md, attachment_ids: ids.length > 0 ? ids : undefined });
|
||||
}}
|
||||
onUploadFile={handleDescriptionUpload}
|
||||
debounceMs={1500}
|
||||
currentIssueId={id}
|
||||
attachments={issueAttachments}
|
||||
attachments={descEditorAttachments}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1 mt-3">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { api } from "@multica/core/api";
|
||||
import type { Attachment } from "@multica/core/types";
|
||||
import { useCommentDraftStore, type CommentDraftKey } from "@multica/core/issues/stores";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } from "../../i18n";
|
||||
@@ -55,7 +56,9 @@ function ReplyInput({
|
||||
const [isEmpty, setIsEmpty] = useState(!initialDraft?.trim());
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const uploadMapRef = useRef<Map<string, string>>(new Map());
|
||||
// Attachments uploaded in this composer session — see CommentInput for the
|
||||
// rationale (drives both submit-time attachment_ids and editor previews).
|
||||
const [pendingAttachments, setPendingAttachments] = useState<Attachment[]>([]);
|
||||
const { uploadWithToast } = useFileUpload(api);
|
||||
const { isDragOver, dropZoneProps } = useFileDropZone({
|
||||
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
|
||||
@@ -80,7 +83,7 @@ function ReplyInput({
|
||||
const handleUpload = useCallback(async (file: File) => {
|
||||
const result = await uploadWithToast(file, { issueId });
|
||||
if (result) {
|
||||
uploadMapRef.current.set(result.link, result.id);
|
||||
setPendingAttachments((prev) => [...prev, result]);
|
||||
}
|
||||
return result;
|
||||
}, [uploadWithToast, issueId]);
|
||||
@@ -89,16 +92,15 @@ function ReplyInput({
|
||||
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
||||
if (!content || submitting) return;
|
||||
// Only send attachment IDs for uploads still present in the content.
|
||||
const activeIds: string[] = [];
|
||||
for (const [url, id] of uploadMapRef.current) {
|
||||
if (content.includes(url)) activeIds.push(id);
|
||||
}
|
||||
const activeIds = pendingAttachments
|
||||
.filter((a) => content.includes(a.url))
|
||||
.map((a) => a.id);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(content, activeIds.length > 0 ? activeIds : undefined);
|
||||
editorRef.current?.clearContent();
|
||||
setIsEmpty(true);
|
||||
uploadMapRef.current.clear();
|
||||
setPendingAttachments([]);
|
||||
if (draftKey) clearDraft(draftKey);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -141,6 +143,7 @@ function ReplyInput({
|
||||
onUploadFile={handleUpload}
|
||||
debounceMs={100}
|
||||
currentIssueId={issueId}
|
||||
attachments={pendingAttachments}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-0 right-0 flex items-center gap-1">
|
||||
|
||||
@@ -292,9 +292,9 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
);
|
||||
|
||||
const editComment = useCallback(
|
||||
async (commentId: string, content: string) => {
|
||||
async (commentId: string, content: string, attachmentIds?: string[]) => {
|
||||
try {
|
||||
await updateComment({ commentId, content });
|
||||
await updateComment({ commentId, content, attachmentIds });
|
||||
} catch {
|
||||
toast.error(t(($) => $.comment.update_failed));
|
||||
}
|
||||
|
||||
@@ -509,7 +509,8 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
Content string `json:"content"`
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
@@ -520,6 +521,11 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
attachmentIDs, ok := parseUUIDSliceOrBadRequest(w, req.AttachmentIDs, "attachment_ids")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// NOTE: See CreateComment — Markdown is sanitized at render/edit time, not here.
|
||||
|
||||
comment, err := h.Queries.UpdateComment(r.Context(), db.UpdateCommentParams{
|
||||
@@ -532,6 +538,14 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bind any newly uploaded attachments referenced in the edited content so
|
||||
// they appear in the timeline's comment.attachments after refresh. Existing
|
||||
// attachments already point at this comment via the upload flow; passing
|
||||
// them again is a no-op at the SQL level.
|
||||
if len(attachmentIDs) > 0 {
|
||||
h.linkAttachmentsByIDs(r.Context(), comment.ID, existing.IssueID, attachmentIDs)
|
||||
}
|
||||
|
||||
// Fetch reactions and attachments for the updated comment.
|
||||
grouped := h.groupReactions(r, []pgtype.UUID{comment.ID})
|
||||
groupedAtt := h.groupAttachments(r, []pgtype.UUID{comment.ID})
|
||||
|
||||
@@ -1416,6 +1416,11 @@ type UpdateIssueRequest struct {
|
||||
DueDate *string `json:"due_date"`
|
||||
ParentIssueID *string `json:"parent_issue_id"`
|
||||
ProjectID *string `json:"project_id"`
|
||||
// AttachmentIDs lets the description editor bind newly uploaded files to
|
||||
// this issue so they surface in `GET /api/issues/:id/attachments` and the
|
||||
// editor's preview Eye keeps working past a refresh. Existing bindings
|
||||
// are idempotent — re-sending the same id is a no-op.
|
||||
AttachmentIDs []string `json:"attachment_ids"`
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1564,6 +1569,11 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
attachmentIDs, ok := parseUUIDSliceOrBadRequest(w, req.AttachmentIDs, "attachment_ids")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
issue, err := h.Queries.UpdateIssue(r.Context(), params)
|
||||
if err != nil {
|
||||
slog.Warn("update issue failed", append(logger.RequestAttrs(r), "error", err, "issue_id", id, "workspace_id", workspaceID)...)
|
||||
@@ -1571,6 +1581,10 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(attachmentIDs) > 0 {
|
||||
h.linkAttachmentsByIssueIDs(r.Context(), issue.ID, issue.WorkspaceID, attachmentIDs)
|
||||
}
|
||||
|
||||
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
||||
resp := issueToResponse(issue, prefix)
|
||||
slog.Info("issue updated", append(logger.RequestAttrs(r), "issue_id", id, "workspace_id", workspaceID)...)
|
||||
|
||||
Reference in New Issue
Block a user