mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-04 12:59:24 +02:00
Compare commits
4 Commits
agent/lamb
...
agent/matt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12fd6b69eb | ||
|
|
bd889e6c74 | ||
|
|
3ab7a4ee94 | ||
|
|
1e9314bfdb |
@@ -611,13 +611,18 @@ export function useCreateComment(issueId: string) {
|
||||
export function useUpdateComment(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ commentId, content, attachmentIds }: { commentId: string; content: string; attachmentIds?: string[] }) =>
|
||||
mutationFn: ({ commentId, content, attachmentIds }: { commentId: string; content: string; attachmentIds: string[] }) =>
|
||||
api.updateComment(commentId, content, attachmentIds),
|
||||
onMutate: async ({ commentId, content }) => {
|
||||
onMutate: async ({ commentId, content, attachmentIds }) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));
|
||||
const kept = new Set(attachmentIds);
|
||||
qc.setQueryData<TimelineCache>(issueKeys.timeline(issueId), (old) =>
|
||||
old?.map((e) => (e.id === commentId ? { ...e, content } : e)),
|
||||
old?.map((e) =>
|
||||
e.id === commentId
|
||||
? { ...e, content, attachments: e.attachments?.filter((a) => kept.has(a.id)) }
|
||||
: e,
|
||||
),
|
||||
);
|
||||
return { prev };
|
||||
},
|
||||
|
||||
@@ -64,7 +64,7 @@ interface CommentCardProps {
|
||||
*/
|
||||
canModerate?: boolean;
|
||||
onReply: (parentId: string, content: string, attachmentIds?: string[]) => Promise<void>;
|
||||
onEdit: (commentId: string, content: string, attachmentIds?: 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. */
|
||||
@@ -198,6 +198,118 @@ function initialStandaloneAttachmentIds(entry: TimelineEntry): Set<string> {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared edit-attachment state hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useEditAttachmentState(
|
||||
issueId: string,
|
||||
entry: TimelineEntry,
|
||||
onEdit: (commentId: string, content: string, attachmentIds: string[]) => Promise<void>,
|
||||
) {
|
||||
const { t } = useT("issues");
|
||||
const { uploadWithToast } = useFileUpload(api);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
const [pendingAttachments, setPendingAttachments] = useState<Attachment[]>([]);
|
||||
const [retainedStandaloneIds, setRetainedStandaloneIds] = useState<Set<string> | null>(null);
|
||||
|
||||
const editorAttachments = pendingAttachments.length > 0
|
||||
? [...(entry.attachments ?? []), ...pendingAttachments]
|
||||
: entry.attachments;
|
||||
|
||||
const handleUpload = 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) => editorRef.current?.uploadFile(f)),
|
||||
enabled: editing,
|
||||
});
|
||||
|
||||
const draftKey = `edit:${issueId}:${entry.id}` as const;
|
||||
const getDraft = useCommentDraftStore.getState().getDraft;
|
||||
const setDraft = useCommentDraftStore((s) => s.setDraft);
|
||||
const clearDraft = useCommentDraftStore((s) => s.clearDraft);
|
||||
|
||||
const initialValue = editing
|
||||
? (getDraft(draftKey) ?? entry.content ?? "")
|
||||
: (entry.content ?? "");
|
||||
|
||||
const standaloneEditAttachments = (entry.attachments ?? []).filter((a) =>
|
||||
retainedStandaloneIds?.has(a.id),
|
||||
);
|
||||
|
||||
const resetState = () => {
|
||||
setEditing(false);
|
||||
setPendingAttachments([]);
|
||||
setRetainedStandaloneIds(null);
|
||||
clearDraft(draftKey);
|
||||
};
|
||||
|
||||
const startEdit = () => {
|
||||
cancelledRef.current = false;
|
||||
setRetainedStandaloneIds(initialStandaloneAttachmentIds(entry));
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
cancelledRef.current = true;
|
||||
resetState();
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (cancelledRef.current) return;
|
||||
const trimmed = editorRef.current
|
||||
?.getMarkdown()
|
||||
?.replace(/(\n\s*)+$/, "")
|
||||
.trim();
|
||||
if (!trimmed) return;
|
||||
const activeIds = collectActiveAttachmentIds(
|
||||
trimmed,
|
||||
[...(entry.attachments ?? []), ...pendingAttachments],
|
||||
retainedStandaloneIds,
|
||||
);
|
||||
const attachmentsChanged = !sameIdSet(activeIds, (entry.attachments ?? []).map((a) => a.id));
|
||||
if (trimmed === (entry.content ?? "").trim() && !attachmentsChanged) {
|
||||
resetState();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onEdit(entry.id, trimmed, activeIds);
|
||||
resetState();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.comment.update_failed),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
editing,
|
||||
editorRef,
|
||||
editorAttachments,
|
||||
handleUpload,
|
||||
isDragOver,
|
||||
dropZoneProps,
|
||||
draftKey,
|
||||
setDraft,
|
||||
clearDraft,
|
||||
initialValue,
|
||||
standaloneEditAttachments,
|
||||
retainedStandaloneIds,
|
||||
setRetainedStandaloneIds,
|
||||
startEdit,
|
||||
cancelEdit,
|
||||
saveEdit,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single comment row (used for both parent and replies within the same Card)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -215,113 +327,24 @@ function CommentRow({
|
||||
entry: TimelineEntry;
|
||||
currentUserId?: string;
|
||||
canModerate?: boolean;
|
||||
onEdit: (commentId: string, content: string, attachmentIds?: string[]) => Promise<void>;
|
||||
onEdit: (commentId: string, content: string, attachmentIds: string[]) => Promise<void>;
|
||||
onDelete: (commentId: string) => void;
|
||||
onToggleReaction: (commentId: string, emoji: string) => void;
|
||||
}) {
|
||||
const { t } = useT("issues");
|
||||
const timeAgo = useTimeAgo();
|
||||
const { getActorName } = useActorName();
|
||||
const [editing, setEditing] = useState(false);
|
||||
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 [retainedStandaloneIds, setRetainedStandaloneIds] = useState<Set<string> | null>(null);
|
||||
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,
|
||||
});
|
||||
|
||||
// Edit-mode draft: virtualization unmounts the card when it scrolls out
|
||||
// of viewport, taking the in-progress edit with it. Persist via store
|
||||
// so a scroll-away + scroll-back round-trip restores the user's edits.
|
||||
// Key includes issueId so two issues with the same comment id (impossible
|
||||
// but defensive) don't collide; cleared on cancel and on save.
|
||||
const editDraftKey = `edit:${issueId}:${entry.id}` as const;
|
||||
const getEditDraft = useCommentDraftStore.getState().getDraft;
|
||||
const setEditDraft = useCommentDraftStore((s) => s.setDraft);
|
||||
const clearEditDraft = useCommentDraftStore((s) => s.clearDraft);
|
||||
// Read the snapshot once when the edit pass mounts; ContentEditor only
|
||||
// honors `defaultValue` on mount, so a live store subscription here would
|
||||
// cause an extra unmount/remount on every keystroke.
|
||||
const editInitialValue = editing
|
||||
? (getEditDraft(editDraftKey) ?? entry.content ?? "")
|
||||
: (entry.content ?? "");
|
||||
const edit = useEditAttachmentState(issueId, entry, onEdit);
|
||||
|
||||
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
|
||||
const canEditEntry = isOwn || (canModerate && entry.actor_type === "member");
|
||||
const canDeleteEntry = isOwn || canModerate;
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const startEdit = () => {
|
||||
cancelledRef.current = false;
|
||||
setRetainedStandaloneIds(initialStandaloneAttachmentIds(entry));
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
cancelledRef.current = true;
|
||||
setEditing(false);
|
||||
setPendingAttachments([]);
|
||||
setRetainedStandaloneIds(null);
|
||||
clearEditDraft(editDraftKey);
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (cancelledRef.current) return;
|
||||
const trimmed = editEditorRef.current
|
||||
?.getMarkdown()
|
||||
?.replace(/(\n\s*)+$/, "")
|
||||
.trim();
|
||||
if (!trimmed) return;
|
||||
const activeIds = collectActiveAttachmentIds(
|
||||
trimmed,
|
||||
[...(entry.attachments ?? []), ...pendingAttachments],
|
||||
retainedStandaloneIds,
|
||||
);
|
||||
const attachmentsChanged = !sameIdSet(activeIds, (entry.attachments ?? []).map((a) => a.id));
|
||||
if (trimmed === (entry.content ?? "").trim() && !attachmentsChanged) {
|
||||
setEditing(false);
|
||||
setPendingAttachments([]);
|
||||
setRetainedStandaloneIds(null);
|
||||
clearEditDraft(editDraftKey);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onEdit(entry.id, trimmed, activeIds);
|
||||
setEditing(false);
|
||||
setPendingAttachments([]);
|
||||
setRetainedStandaloneIds(null);
|
||||
clearEditDraft(editDraftKey);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.comment.update_failed),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const reactions = entry.reactions ?? [];
|
||||
const contentText = entry.content ?? "";
|
||||
const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8;
|
||||
const standaloneEditAttachments = (entry.attachments ?? []).filter((attachment) =>
|
||||
retainedStandaloneIds?.has(attachment.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="py-3">
|
||||
@@ -368,7 +391,7 @@ function CommentRow({
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{canEditEntry && (
|
||||
<DropdownMenuItem onClick={startEdit}>
|
||||
<DropdownMenuItem onClick={edit.startEdit}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
{t(($) => $.comment.edit_action)}
|
||||
</DropdownMenuItem>
|
||||
@@ -392,36 +415,36 @@ function CommentRow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
{edit.editing ? (
|
||||
<div
|
||||
{...dropZoneProps}
|
||||
{...edit.dropZoneProps}
|
||||
className="relative mt-1.5 pl-8"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") edit.cancelEdit(); }}
|
||||
>
|
||||
<div className="text-sm leading-relaxed">
|
||||
<ContentEditor
|
||||
ref={editEditorRef}
|
||||
defaultValue={editInitialValue}
|
||||
ref={edit.editorRef}
|
||||
defaultValue={edit.initialValue}
|
||||
placeholder={t(($) => $.comment.edit_placeholder)}
|
||||
onUpdate={(md) => {
|
||||
if (md.trim().length > 0) setEditDraft(editDraftKey, md);
|
||||
else clearEditDraft(editDraftKey);
|
||||
if (md.trim().length > 0) edit.setDraft(edit.draftKey, md);
|
||||
else edit.clearDraft(edit.draftKey);
|
||||
}}
|
||||
onSubmit={saveEdit}
|
||||
onUploadFile={handleEditUpload}
|
||||
onSubmit={edit.saveEdit}
|
||||
onUploadFile={edit.handleUpload}
|
||||
debounceMs={100}
|
||||
currentIssueId={issueId}
|
||||
attachments={editorAttachments}
|
||||
attachments={edit.editorAttachments}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
{standaloneEditAttachments.length > 0 && (
|
||||
{edit.standaloneEditAttachments.length > 0 && (
|
||||
<AttachmentList
|
||||
attachments={standaloneEditAttachments}
|
||||
attachments={edit.standaloneEditAttachments}
|
||||
className="max-w-full"
|
||||
onRemove={(attachmentId) =>
|
||||
setRetainedStandaloneIds((ids) => {
|
||||
edit.setRetainedStandaloneIds((ids) => {
|
||||
const next = new Set(ids ?? []);
|
||||
next.delete(attachmentId);
|
||||
return next;
|
||||
@@ -432,15 +455,15 @@ function CommentRow({
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
multiple
|
||||
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
|
||||
onSelect={(file) => edit.editorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={cancelEdit}>{t(($) => $.comment.cancel_edit)}</Button>
|
||||
<Button size="sm" variant="outline" onClick={saveEdit}>{t(($) => $.comment.save_action)}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={edit.cancelEdit}>{t(($) => $.comment.cancel_edit)}</Button>
|
||||
<Button size="sm" variant="outline" onClick={edit.saveEdit}>{t(($) => $.comment.save_action)}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{isDragOver && <FileDropOverlay />}
|
||||
{edit.isDragOver && <FileDropOverlay />}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -483,100 +506,18 @@ function CommentCardImpl({
|
||||
const { t } = useT("issues");
|
||||
const timeAgo = useTimeAgo();
|
||||
const { getActorName } = useActorName();
|
||||
const { uploadWithToast } = useFileUpload(api);
|
||||
const isCollapsed = useCommentCollapseStore((s) => s.isCollapsed(issueId, entry.id));
|
||||
const toggleCollapse = useCommentCollapseStore((s) => s.toggle);
|
||||
const open = !isCollapsed;
|
||||
const handleOpenChange = useCallback((_open: boolean) => toggleCollapse(issueId, entry.id), [toggleCollapse, issueId, entry.id]);
|
||||
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 [parentRetainedStandaloneIds, setParentRetainedStandaloneIds] = useState<Set<string> | null>(null);
|
||||
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,
|
||||
});
|
||||
|
||||
// Edit-mode draft (root comment). Same rationale as CommentRow's draft.
|
||||
const parentEditDraftKey = `edit:${issueId}:${entry.id}` as const;
|
||||
const getParentEditDraft = useCommentDraftStore.getState().getDraft;
|
||||
const setParentEditDraft = useCommentDraftStore((s) => s.setDraft);
|
||||
const clearParentEditDraft = useCommentDraftStore((s) => s.clearDraft);
|
||||
const parentEditInitialValue = editing
|
||||
? (getParentEditDraft(parentEditDraftKey) ?? entry.content ?? "")
|
||||
: (entry.content ?? "");
|
||||
const edit = useEditAttachmentState(issueId, entry, onEdit);
|
||||
|
||||
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
|
||||
// Author-only edit is the same as before; admins additionally get edit
|
||||
// *and* delete on member-authored comments, plus delete on agent-authored
|
||||
// ones. Edit on agent comments is intentionally never offered — agents
|
||||
// own their own outputs.
|
||||
const canEditEntry = isOwn || (canModerate && entry.actor_type === "member");
|
||||
const canDeleteEntry = isOwn || canModerate;
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const startEdit = () => {
|
||||
cancelledRef.current = false;
|
||||
setParentRetainedStandaloneIds(initialStandaloneAttachmentIds(entry));
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
cancelledRef.current = true;
|
||||
setEditing(false);
|
||||
setParentPendingAttachments([]);
|
||||
setParentRetainedStandaloneIds(null);
|
||||
clearParentEditDraft(parentEditDraftKey);
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (cancelledRef.current) return;
|
||||
const trimmed = editEditorRef.current
|
||||
?.getMarkdown()
|
||||
?.replace(/(\n\s*)+$/, "")
|
||||
.trim();
|
||||
if (!trimmed) return;
|
||||
const activeIds = collectActiveAttachmentIds(
|
||||
trimmed,
|
||||
[...(entry.attachments ?? []), ...parentPendingAttachments],
|
||||
parentRetainedStandaloneIds,
|
||||
);
|
||||
const attachmentsChanged = !sameIdSet(activeIds, (entry.attachments ?? []).map((a) => a.id));
|
||||
if (trimmed === (entry.content ?? "").trim() && !attachmentsChanged) {
|
||||
setEditing(false);
|
||||
setParentPendingAttachments([]);
|
||||
setParentRetainedStandaloneIds(null);
|
||||
clearParentEditDraft(parentEditDraftKey);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onEdit(entry.id, trimmed, activeIds);
|
||||
setEditing(false);
|
||||
setParentPendingAttachments([]);
|
||||
setParentRetainedStandaloneIds(null);
|
||||
clearParentEditDraft(parentEditDraftKey);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.comment.update_failed),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// The parent precomputes the flat thread (using collectThreadReplies),
|
||||
// memoizes by thread, and stabilizes the array reference, so we render
|
||||
// straight from `replies` instead of re-walking the graph on every render.
|
||||
const allNestedReplies = replies;
|
||||
|
||||
const replyCount = allNestedReplies.length;
|
||||
@@ -584,9 +525,6 @@ function CommentCardImpl({
|
||||
const reactions = entry.reactions ?? [];
|
||||
const contentText = entry.content ?? "";
|
||||
const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8;
|
||||
const parentStandaloneEditAttachments = (entry.attachments ?? []).filter((attachment) =>
|
||||
parentRetainedStandaloneIds?.has(attachment.id),
|
||||
);
|
||||
|
||||
const isHighlighted = highlightedCommentId === entry.id;
|
||||
|
||||
@@ -685,7 +623,7 @@ function CommentCardImpl({
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{canEditEntry && (
|
||||
<DropdownMenuItem onClick={startEdit}>
|
||||
<DropdownMenuItem onClick={edit.startEdit}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
{t(($) => $.comment.edit_action)}
|
||||
</DropdownMenuItem>
|
||||
@@ -716,36 +654,36 @@ function CommentCardImpl({
|
||||
<CollapsibleContent>
|
||||
{/* Parent comment body */}
|
||||
<div className="px-4 pb-3">
|
||||
{editing ? (
|
||||
{edit.editing ? (
|
||||
<div
|
||||
{...parentDropZoneProps}
|
||||
{...edit.dropZoneProps}
|
||||
className="relative pl-10"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") edit.cancelEdit(); }}
|
||||
>
|
||||
<div className="text-sm leading-relaxed">
|
||||
<ContentEditor
|
||||
ref={editEditorRef}
|
||||
defaultValue={parentEditInitialValue}
|
||||
ref={edit.editorRef}
|
||||
defaultValue={edit.initialValue}
|
||||
placeholder={t(($) => $.comment.edit_placeholder)}
|
||||
onUpdate={(md) => {
|
||||
if (md.trim().length > 0) setParentEditDraft(parentEditDraftKey, md);
|
||||
else clearParentEditDraft(parentEditDraftKey);
|
||||
if (md.trim().length > 0) edit.setDraft(edit.draftKey, md);
|
||||
else edit.clearDraft(edit.draftKey);
|
||||
}}
|
||||
onSubmit={saveEdit}
|
||||
onUploadFile={handleParentEditUpload}
|
||||
onSubmit={edit.saveEdit}
|
||||
onUploadFile={edit.handleUpload}
|
||||
debounceMs={100}
|
||||
currentIssueId={issueId}
|
||||
attachments={parentEditorAttachments}
|
||||
attachments={edit.editorAttachments}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
{parentStandaloneEditAttachments.length > 0 && (
|
||||
{edit.standaloneEditAttachments.length > 0 && (
|
||||
<AttachmentList
|
||||
attachments={parentStandaloneEditAttachments}
|
||||
attachments={edit.standaloneEditAttachments}
|
||||
className="max-w-full"
|
||||
onRemove={(attachmentId) =>
|
||||
setParentRetainedStandaloneIds((ids) => {
|
||||
edit.setRetainedStandaloneIds((ids) => {
|
||||
const next = new Set(ids ?? []);
|
||||
next.delete(attachmentId);
|
||||
return next;
|
||||
@@ -756,15 +694,15 @@ function CommentCardImpl({
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
multiple
|
||||
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
|
||||
onSelect={(file) => edit.editorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={cancelEdit}>{t(($) => $.comment.cancel_edit)}</Button>
|
||||
<Button size="sm" variant="outline" onClick={saveEdit}>{t(($) => $.comment.save_action)}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={edit.cancelEdit}>{t(($) => $.comment.cancel_edit)}</Button>
|
||||
<Button size="sm" variant="outline" onClick={edit.saveEdit}>{t(($) => $.comment.save_action)}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{parentDragOver && <FileDropOverlay />}
|
||||
{edit.isDragOver && <FileDropOverlay />}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -296,7 +296,7 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
);
|
||||
|
||||
const editComment = useCallback(
|
||||
async (commentId: string, content: string, attachmentIds?: string[]) => {
|
||||
async (commentId: string, content: string, attachmentIds: string[]) => {
|
||||
try {
|
||||
await updateComment({ commentId, content, attachmentIds });
|
||||
} catch (err) {
|
||||
|
||||
178
server/cmd/server/comment_attachment_integration_test.go
Normal file
178
server/cmd/server/comment_attachment_integration_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// createTestAttachment inserts a test attachment row directly into the DB,
|
||||
// linked to the given issue with no comment_id. Returns the attachment UUID.
|
||||
func createTestAttachment(t *testing.T, issueID string) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
err := testPool.QueryRow(context.Background(), `
|
||||
INSERT INTO attachment (workspace_id, issue_id, uploader_type, uploader_id, filename, url, content_type, size_bytes)
|
||||
VALUES ($1::uuid, $2::uuid, 'member', $3::uuid, 'test.txt', 'https://example.com/test.txt', 'text/plain', 42)
|
||||
RETURNING id::text
|
||||
`, testWorkspaceID, issueID, testUserID).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("createTestAttachment: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM attachment WHERE id = $1::uuid`, id)
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
// listCommentAttachmentIDs returns the attachment IDs linked to a comment.
|
||||
func listCommentAttachmentIDs(t *testing.T, commentID string) []string {
|
||||
t.Helper()
|
||||
rows, err := testPool.Query(context.Background(),
|
||||
`SELECT id::text FROM attachment WHERE comment_id = $1::uuid ORDER BY created_at`, commentID)
|
||||
if err != nil {
|
||||
t.Fatalf("listCommentAttachmentIDs: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
t.Fatalf("listCommentAttachmentIDs scan: %v", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestUpdateCommentAttachments(t *testing.T) {
|
||||
issueID := createIssue(t, "Attachment edit integration test")
|
||||
t.Cleanup(func() {
|
||||
resp := authRequest(t, "DELETE", "/api/issues/"+issueID, nil)
|
||||
resp.Body.Close()
|
||||
})
|
||||
|
||||
t.Run("edit to remove some attachments keeps the rest", func(t *testing.T) {
|
||||
att1 := createTestAttachment(t, issueID)
|
||||
att2 := createTestAttachment(t, issueID)
|
||||
att3 := createTestAttachment(t, issueID)
|
||||
|
||||
// Create comment with all three attachments.
|
||||
resp := authRequest(t, "POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
||||
"content": "comment with three attachments",
|
||||
"type": "comment",
|
||||
"attachment_ids": []string{att1, att2, att3},
|
||||
})
|
||||
if resp.StatusCode != 201 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
t.Fatalf("CreateComment: expected 201, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
var comment map[string]any
|
||||
readJSON(t, resp, &comment)
|
||||
commentID := comment["id"].(string)
|
||||
|
||||
// Verify all three are linked.
|
||||
ids := listCommentAttachmentIDs(t, commentID)
|
||||
if len(ids) != 3 {
|
||||
t.Fatalf("expected 3 attachments, got %d", len(ids))
|
||||
}
|
||||
|
||||
// Edit: keep only att1 and att3, remove att2.
|
||||
resp = authRequest(t, "PUT", "/api/comments/"+commentID, map[string]any{
|
||||
"content": "updated — removed att2",
|
||||
"attachment_ids": []string{att1, att3},
|
||||
})
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
t.Fatalf("UpdateComment: expected 200, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
ids = listCommentAttachmentIDs(t, commentID)
|
||||
if len(ids) != 2 {
|
||||
t.Fatalf("expected 2 attachments after edit, got %d", len(ids))
|
||||
}
|
||||
idSet := map[string]bool{ids[0]: true, ids[1]: true}
|
||||
if !idSet[att1] || !idSet[att3] {
|
||||
t.Errorf("expected att1 and att3 to remain, got %v", ids)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edit to remove all attachments", func(t *testing.T) {
|
||||
att1 := createTestAttachment(t, issueID)
|
||||
|
||||
resp := authRequest(t, "POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
||||
"content": "comment with one attachment",
|
||||
"type": "comment",
|
||||
"attachment_ids": []string{att1},
|
||||
})
|
||||
if resp.StatusCode != 201 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
t.Fatalf("CreateComment: expected 201, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
var comment map[string]any
|
||||
readJSON(t, resp, &comment)
|
||||
commentID := comment["id"].(string)
|
||||
|
||||
if ids := listCommentAttachmentIDs(t, commentID); len(ids) != 1 {
|
||||
t.Fatalf("expected 1 attachment, got %d", len(ids))
|
||||
}
|
||||
|
||||
// Edit with empty attachment_ids to remove all.
|
||||
resp = authRequest(t, "PUT", "/api/comments/"+commentID, map[string]any{
|
||||
"content": "no more attachments",
|
||||
"attachment_ids": []string{},
|
||||
})
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
t.Fatalf("UpdateComment: expected 200, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
if ids := listCommentAttachmentIDs(t, commentID); len(ids) != 0 {
|
||||
t.Fatalf("expected 0 attachments after removing all, got %d", len(ids))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("old client omitting attachment_ids preserves existing attachments", func(t *testing.T) {
|
||||
att1 := createTestAttachment(t, issueID)
|
||||
|
||||
resp := authRequest(t, "POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
||||
"content": "comment with attachment",
|
||||
"type": "comment",
|
||||
"attachment_ids": []string{att1},
|
||||
})
|
||||
if resp.StatusCode != 201 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
t.Fatalf("CreateComment: expected 201, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
var comment map[string]any
|
||||
readJSON(t, resp, &comment)
|
||||
commentID := comment["id"].(string)
|
||||
|
||||
if ids := listCommentAttachmentIDs(t, commentID); len(ids) != 1 {
|
||||
t.Fatalf("expected 1 attachment, got %d", len(ids))
|
||||
}
|
||||
|
||||
// Old client: only sends content, no attachment_ids field at all.
|
||||
resp = authRequest(t, "PUT", "/api/comments/"+commentID, map[string]any{
|
||||
"content": "edited content without attachment_ids",
|
||||
})
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
t.Fatalf("UpdateComment: expected 200, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
ids := listCommentAttachmentIDs(t, commentID)
|
||||
if len(ids) != 1 {
|
||||
t.Fatalf("expected 1 attachment preserved (old client), got %d", len(ids))
|
||||
}
|
||||
if ids[0] != att1 {
|
||||
t.Errorf("expected att1 %q preserved, got %q", att1, ids[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
120
server/cmd/server/comment_edit_mention_integration_test.go
Normal file
120
server/cmd/server/comment_edit_mention_integration_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEditCommentTriggers(t *testing.T) {
|
||||
agentID := getAgentID(t)
|
||||
issueID := createIssue(t, "Edit comment triggers integration test")
|
||||
t.Cleanup(func() {
|
||||
clearTasks(t, issueID)
|
||||
resp := authRequest(t, "DELETE", "/api/issues/"+issueID, nil)
|
||||
resp.Body.Close()
|
||||
})
|
||||
|
||||
t.Run("edit adds agent mention enqueues task", func(t *testing.T) {
|
||||
clearTasks(t, issueID)
|
||||
commentID := postComment(t, issueID, "plain comment no mentions", nil)
|
||||
clearTasks(t, issueID)
|
||||
|
||||
newContent := fmt.Sprintf("[@Agent](mention://agent/%s) please review", agentID)
|
||||
resp := authRequest(t, "PUT", "/api/comments/"+commentID, map[string]any{
|
||||
"content": newContent,
|
||||
})
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
t.Fatalf("UpdateComment: expected 200, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if n := countPendingTasks(t, issueID); n != 1 {
|
||||
t.Errorf("expected 1 pending task after adding agent mention via edit, got %d", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edit removes agent mention cancels task", func(t *testing.T) {
|
||||
clearTasks(t, issueID)
|
||||
content := fmt.Sprintf("[@Agent](mention://agent/%s) fix this", agentID)
|
||||
commentID := postComment(t, issueID, content, nil)
|
||||
|
||||
if n := countPendingTasks(t, issueID); n != 1 {
|
||||
t.Fatalf("expected 1 pending task from initial mention, got %d", n)
|
||||
}
|
||||
|
||||
resp := authRequest(t, "PUT", "/api/comments/"+commentID, map[string]any{
|
||||
"content": "removed the mention, nevermind",
|
||||
})
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
t.Fatalf("UpdateComment: expected 200, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if n := countPendingTasks(t, issueID); n != 0 {
|
||||
t.Errorf("expected 0 pending tasks after removing mention via edit, got %d", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edit changes content but keeps same mention re-triggers", func(t *testing.T) {
|
||||
clearTasks(t, issueID)
|
||||
content := fmt.Sprintf("[@Agent](mention://agent/%s) fix bug A", agentID)
|
||||
commentID := postComment(t, issueID, content, nil)
|
||||
|
||||
if n := countPendingTasks(t, issueID); n != 1 {
|
||||
t.Fatalf("expected 1 pending task from initial mention, got %d", n)
|
||||
}
|
||||
|
||||
clearTasks(t, issueID)
|
||||
|
||||
newContent := fmt.Sprintf("[@Agent](mention://agent/%s) actually fix bug B instead", agentID)
|
||||
resp := authRequest(t, "PUT", "/api/comments/"+commentID, map[string]any{
|
||||
"content": newContent,
|
||||
})
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
t.Fatalf("UpdateComment: expected 200, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if n := countPendingTasks(t, issueID); n != 1 {
|
||||
t.Errorf("expected 1 pending task after content change re-trigger, got %d", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edit on agent-assigned issue cancels and re-triggers assignee task", func(t *testing.T) {
|
||||
assignedIssue := createIssueAssignedToAgent(t, "Edit assignee trigger test", agentID)
|
||||
t.Cleanup(func() {
|
||||
clearTasks(t, assignedIssue)
|
||||
resp := authRequest(t, "DELETE", "/api/issues/"+assignedIssue, nil)
|
||||
resp.Body.Close()
|
||||
})
|
||||
clearTasks(t, assignedIssue)
|
||||
|
||||
commentID := postComment(t, assignedIssue, "fix the login page", nil)
|
||||
if n := countPendingTasks(t, assignedIssue); n != 1 {
|
||||
t.Fatalf("expected 1 pending task from on_comment trigger, got %d", n)
|
||||
}
|
||||
|
||||
clearTasks(t, assignedIssue)
|
||||
|
||||
resp := authRequest(t, "PUT", "/api/comments/"+commentID, map[string]any{
|
||||
"content": "actually fix the signup page instead",
|
||||
})
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
t.Fatalf("UpdateComment: expected 200, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if n := countPendingTasks(t, assignedIssue); n != 1 {
|
||||
t.Errorf("expected 1 pending task after edit re-triggered assignee, got %d", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -725,38 +725,25 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
||||
// must keep the resolved root in sync.
|
||||
h.TaskService.AutoUnresolveThreadOnReply(r.Context(), parentComment, uuidToString(issue.WorkspaceID), authorType, authorID)
|
||||
|
||||
// If the issue is assigned to an agent with on_comment trigger, enqueue a new task.
|
||||
// Skip when the comment comes from the assigned agent itself to avoid loops.
|
||||
// Also skip when the comment @mentions others but not the assignee agent —
|
||||
// the user is talking to someone else, not requesting work from the assignee.
|
||||
// Also skip when replying in a member-started thread without mentioning the
|
||||
// assignee — the user is continuing a member-to-member conversation.
|
||||
if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue, authorType, authorID) &&
|
||||
h.triggerTasksForComment(r.Context(), issue, comment, parentComment, authorType, authorID)
|
||||
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) triggerTasksForComment(ctx context.Context, issue db.Issue, comment db.Comment, parentComment *db.Comment, actorType, actorID string) {
|
||||
if actorType == "member" && h.shouldEnqueueOnComment(ctx, issue, actorType, actorID) &&
|
||||
!h.commentMentionsOthersButNotAssignee(comment.Content, issue) &&
|
||||
!h.isReplyToMemberThread(r.Context(), parentComment, comment.Content, issue) {
|
||||
// Always use the current comment as the trigger so the agent reads
|
||||
// the actual new reply, not the thread root. Reply placement (flat
|
||||
// thread grouping) is handled downstream by createAgentComment,
|
||||
// which resolves parent_id to the thread root before posting. This
|
||||
// mirrors the mention path's behavior (see enqueueMentionedAgentTasks).
|
||||
if _, err := h.TaskService.EnqueueTaskForIssue(r.Context(), issue, comment.ID); err != nil {
|
||||
slog.Warn("enqueue agent task on comment failed", "issue_id", issueID, "error", err)
|
||||
!h.isReplyToMemberThread(ctx, parentComment, comment.Content, issue) {
|
||||
if _, err := h.TaskService.EnqueueTaskForIssue(ctx, issue, comment.ID); err != nil {
|
||||
slog.Warn("enqueue agent task on comment failed", "issue_id", uuidToString(issue.ID), "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Squad trigger: if the issue is assigned to a squad, trigger the squad leader.
|
||||
// Skip when the comment author is the leader (prevent internal loops), or
|
||||
// when a member explicitly @mentions anyone (agent/member/squad/all) — that
|
||||
// counts as deliberate routing and the leader stays out.
|
||||
if h.shouldEnqueueSquadLeaderOnComment(r.Context(), issue, comment.Content, authorType, authorID) {
|
||||
h.enqueueSquadLeaderTask(r.Context(), issue, comment.ID, authorType, authorID)
|
||||
if h.shouldEnqueueSquadLeaderOnComment(ctx, issue, comment.Content, actorType, actorID) {
|
||||
h.enqueueSquadLeaderTask(ctx, issue, comment.ID, actorType, actorID)
|
||||
}
|
||||
|
||||
// Trigger @mentioned agents: parse agent mentions and enqueue tasks for each.
|
||||
// Pass parentComment so that replies inherit mentions from the thread root.
|
||||
h.enqueueMentionedAgentTasks(r.Context(), issue, comment, parentComment, authorType, authorID)
|
||||
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
h.enqueueMentionedAgentTasks(ctx, issue, comment, parentComment, actorType, actorID)
|
||||
}
|
||||
|
||||
// commentMentionsOthersButNotAssignee returns true if the comment @mentions
|
||||
@@ -1048,6 +1035,11 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// NOTE: See CreateComment — Markdown is sanitized at render/edit time, not here.
|
||||
|
||||
oldContent := existing.Content
|
||||
|
||||
// Expand bare issue identifiers (same pipeline as CreateComment).
|
||||
req.Content = mention.ExpandIssueIdentifiers(r.Context(), h.Queries, wsUUID, req.Content)
|
||||
|
||||
comment, err := h.Queries.UpdateComment(r.Context(), db.UpdateCommentParams{
|
||||
ID: commentUUID,
|
||||
Content: req.Content,
|
||||
@@ -1063,11 +1055,13 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
|
||||
// existing attachment links rather than unlinking everything.
|
||||
if replaceAttachments {
|
||||
if err := h.Queries.ReplaceCommentAttachments(r.Context(), db.ReplaceCommentAttachmentsParams{
|
||||
CommentID: comment.ID,
|
||||
IssueID: existing.IssueID,
|
||||
Column3: attachmentIDs,
|
||||
CommentID: comment.ID,
|
||||
IssueID: existing.IssueID,
|
||||
AttachmentIds: attachmentIDs,
|
||||
}); err != nil {
|
||||
slog.Error("failed to replace comment attachments", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to update attachments")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1078,6 +1072,28 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
|
||||
resp := commentToResponse(comment, grouped[cid], groupedAtt[cid])
|
||||
slog.Info("comment updated", append(logger.RequestAttrs(r), "comment_id", commentId)...)
|
||||
h.publish(protocol.EventCommentUpdated, workspaceID, actorType, actorID, map[string]any{"comment": resp})
|
||||
|
||||
if oldContent != comment.Content {
|
||||
if err := h.TaskService.CancelTasksByTriggerComment(r.Context(), existing.ID); err != nil {
|
||||
slog.Warn("cancel tasks for edited comment failed", "comment_id", uuidToString(existing.ID), "error", err)
|
||||
}
|
||||
|
||||
issue, err := h.Queries.GetIssue(r.Context(), existing.IssueID)
|
||||
if err != nil {
|
||||
slog.Warn("load issue for edit post-processing failed", "issue_id", uuidToString(existing.IssueID), "error", err)
|
||||
} else {
|
||||
var parentComment *db.Comment
|
||||
if existing.ParentID.Valid {
|
||||
parent, err := h.Queries.GetComment(r.Context(), existing.ParentID)
|
||||
if err == nil {
|
||||
parentComment = &parent
|
||||
}
|
||||
}
|
||||
|
||||
h.triggerTasksForComment(r.Context(), issue, comment, parentComment, actorType, actorID)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
|
||||
@@ -462,12 +462,12 @@ WHERE issue_id = $2
|
||||
`
|
||||
|
||||
type ReplaceCommentAttachmentsParams struct {
|
||||
CommentID pgtype.UUID `json:"comment_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
Column3 []pgtype.UUID `json:"column_3"`
|
||||
CommentID pgtype.UUID `json:"comment_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
AttachmentIds []pgtype.UUID `json:"attachment_ids"`
|
||||
}
|
||||
|
||||
func (q *Queries) ReplaceCommentAttachments(ctx context.Context, arg ReplaceCommentAttachmentsParams) error {
|
||||
_, err := q.db.Exec(ctx, replaceCommentAttachments, arg.CommentID, arg.IssueID, arg.Column3)
|
||||
_, err := q.db.Exec(ctx, replaceCommentAttachments, arg.CommentID, arg.IssueID, arg.AttachmentIds)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -47,13 +47,13 @@ WHERE issue_id = $2
|
||||
-- name: ReplaceCommentAttachments :exec
|
||||
UPDATE attachment
|
||||
SET comment_id = CASE
|
||||
WHEN id = ANY($3::uuid[]) THEN $1
|
||||
WHEN id = ANY(sqlc.arg(attachment_ids)::uuid[]) THEN $1
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE issue_id = $2
|
||||
AND (
|
||||
comment_id = $1
|
||||
OR (comment_id IS NULL AND id = ANY($3::uuid[]))
|
||||
OR (comment_id IS NULL AND id = ANY(sqlc.arg(attachment_ids)::uuid[]))
|
||||
);
|
||||
|
||||
-- name: LinkAttachmentsToChatMessage :exec
|
||||
|
||||
Reference in New Issue
Block a user