Skip to content

Commit 89458d9

Browse files
authored
feat(ui): add ui.comments.reply for thread replies (SD-2817) (#3035)
Wraps editor.doc.comments.create({ parentCommentId, text }). Replies inherit the parent's anchor, so callers don't pass a target — the doc-api adapter resolves it from the parent. Returns a NO_OP receipt on empty/whitespace text, matching the doc-api contract for top-level comments. Drops the useSuperDocHost() reach from the BYO-UI demo's reply composer; the typed ui.comments.reply() call is now the canonical path. Routes through the same routed editor as createFromSelection / createFromCapture so a header-focused composer posts in the header story.
1 parent 9749e97 commit 89458d9

4 files changed

Lines changed: 116 additions & 16 deletions

File tree

demos/build-your-own-ui/src/components/ActivitySidebar.tsx

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import { useEffect, useMemo, useRef, useState } from 'react';
2-
import type { SuperDoc } from 'superdoc';
32
import type { CommentsListResult, TrackChangeInfo } from 'superdoc/ui';
43
import {
54
useSuperDocComments,
6-
useSuperDocHost,
75
useSuperDocSelection,
86
useSuperDocTrackChanges,
97
useSuperDocUI,
@@ -271,7 +269,6 @@ function CommentBody({
271269
replies?: ActivityItem[];
272270
ui: NonNullable<ReturnType<typeof useSuperDocUI>>;
273271
}) {
274-
const host = useSuperDocHost() as SuperDoc | null;
275272
const [replyOpen, setReplyOpen] = useState(false);
276273
const [replyText, setReplyText] = useState('');
277274
const [replying, setReplying] = useState(false);
@@ -294,17 +291,15 @@ function CommentBody({
294291
};
295292

296293
const postReply = () => {
297-
const editor = host?.activeEditor;
298-
const commentsApi = editor?.doc?.comments;
299-
if (!commentsApi || typeof commentsApi.create !== 'function' || !replyText.trim()) return;
294+
if (!replyText.trim()) return;
300295
setReplying(true);
301296
try {
302-
// Reply uses the doc-api `create({ parentCommentId, text })`
303-
// path. `ui.comments` doesn't yet expose a typed `reply()`
304-
// method (filed as a follow-up under SD-2817); the `host`
305-
// surface is the documented escape hatch until then.
306-
commentsApi.create({ parentCommentId: comment.id, text: replyText.trim() });
307-
cancelReply();
297+
const receipt = ui.comments.reply(comment.id, { text: replyText.trim() });
298+
if (receipt.success) {
299+
cancelReply();
300+
} else {
301+
console.error('[ActivitySidebar] reply rejected', receipt);
302+
}
308303
} catch (err) {
309304
console.error('[ActivitySidebar] reply failed', err);
310305
} finally {
@@ -359,7 +354,7 @@ function CommentBody({
359354
<button onClick={cancelReply}>Cancel</button>
360355
<button
361356
className="primary"
362-
disabled={!host || replying || !replyText.trim()}
357+
disabled={replying || !replyText.trim()}
363358
onClick={postReply}
364359
>
365360
{replying ? 'Posting…' : 'Reply'}
@@ -376,9 +371,7 @@ function CommentBody({
376371
<>
377372
<button onClick={() => ui.comments.resolve(comment.id)}>Resolve</button>
378373
{!replyOpen && (
379-
<button disabled={!host} onClick={openReply}>
380-
Reply
381-
</button>
374+
<button onClick={openReply}>Reply</button>
382375
)}
383376
</>
384377
)}

packages/super-editor/src/ui/comments.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,77 @@ describe('ui.comments — actions route through editor.doc.*', () => {
379379
ui.destroy();
380380
});
381381

382+
it('reply forwards to comments.create with parentCommentId set and no target', () => {
383+
const { superdoc, mocks } = makeStubs();
384+
const ui = createSuperDocUI({ superdoc });
385+
386+
const receipt = ui.comments.reply('c-parent', { text: 'thanks!' });
387+
388+
expect(receipt.success).toBe(true);
389+
expect(mocks.create).toHaveBeenCalledWith({ parentCommentId: 'c-parent', text: 'thanks!' });
390+
// Reply must NOT carry a `target` — the doc-api adapter resolves
391+
// the parent's anchor itself. Sending one would either be ignored
392+
// or, worse, override the inherited address.
393+
expect(mocks.create.mock.calls[0]?.[0]).not.toHaveProperty('target');
394+
395+
ui.destroy();
396+
});
397+
398+
it('reply returns a NO_OP receipt when text is empty or whitespace-only', () => {
399+
const { superdoc, mocks } = makeStubs();
400+
const ui = createSuperDocUI({ superdoc });
401+
402+
const empty = ui.comments.reply('c-parent', { text: '' });
403+
expect(empty.success).toBe(false);
404+
405+
const whitespace = ui.comments.reply('c-parent', { text: ' \n\t' });
406+
expect(whitespace.success).toBe(false);
407+
408+
expect(mocks.create).not.toHaveBeenCalled();
409+
410+
ui.destroy();
411+
});
412+
413+
it('reply refreshes the comments snapshot synchronously after the post', async () => {
414+
const { superdoc } = makeStubs({
415+
comments: [{ id: 'c-parent', commentId: 'c-parent', text: 'parent' }],
416+
});
417+
const ui = createSuperDocUI({ superdoc });
418+
419+
expect(ui.comments.getSnapshot().items).toHaveLength(1);
420+
421+
// Stub mutates list as if a reply was just persisted.
422+
superdoc.setComments([
423+
{ id: 'c-parent', commentId: 'c-parent', text: 'parent' },
424+
{ id: 'c-reply', commentId: 'c-reply', text: 'thanks', parentCommentId: 'c-parent' },
425+
]);
426+
ui.comments.reply('c-parent', { text: 'thanks' });
427+
428+
// refreshAndNotify should already have re-read the cache.
429+
expect(ui.comments.getSnapshot().items.map((i) => i.id)).toEqual(['c-parent', 'c-reply']);
430+
431+
ui.destroy();
432+
});
433+
434+
it('reply routes through the routed editor (header / footer focus stays scoped)', () => {
435+
// Same posture as createFromSelection / createFromCapture: replies
436+
// go through `resolveRoutedEditor` so a header-focused composer
437+
// posts in the header story, not the body. Mirrors the doc-api
438+
// contract: `comments.create` is story-scoped on the routed editor.
439+
const { superdoc, mocks } = makeStubs();
440+
const ui = createSuperDocUI({ superdoc });
441+
442+
ui.comments.reply('c-parent', { text: 'in scope' });
443+
444+
expect(mocks.create).toHaveBeenCalledTimes(1);
445+
expect(mocks.create.mock.calls[0]?.[0]).toMatchObject({
446+
parentCommentId: 'c-parent',
447+
text: 'in scope',
448+
});
449+
450+
ui.destroy();
451+
});
452+
382453
it('resolve forwards to comments.patch({ commentId, status: "resolved" })', () => {
383454
const { superdoc, mocks } = makeStubs();
384455
const ui = createSuperDocUI({ superdoc });

packages/super-editor/src/ui/create-super-doc-ui.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1171,6 +1171,27 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
11711171
refreshAndNotify();
11721172
return receipt;
11731173
},
1174+
reply(parentCommentId, { text }) {
1175+
// Reply uses the same `create` operation as a top-level comment;
1176+
// discrimination is `parentCommentId` set vs absent. Replies
1177+
// inherit the parent's anchor, so callers don't pass a target —
1178+
// the doc-api adapter resolves the parent's positional address
1179+
// and stamps it on the new comment.
1180+
const trimmed = typeof text === 'string' ? text.trim() : '';
1181+
if (!trimmed) {
1182+
return {
1183+
success: false,
1184+
failure: { code: 'NO_OP', message: 'ui.comments.reply: text is empty.' },
1185+
};
1186+
}
1187+
const api = requireDocComments();
1188+
const receipt = (api.create as (input: unknown, options?: unknown) => Receipt).call(api, {
1189+
parentCommentId,
1190+
text,
1191+
});
1192+
refreshAndNotify();
1193+
return receipt;
1194+
},
11741195
resolve(commentId) {
11751196
const api = requireDocComments();
11761197
const receipt = (api.patch as (input: unknown, options?: unknown) => Receipt).call(api, {

packages/super-editor/src/ui/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -912,6 +912,21 @@ export interface CommentsHandle {
912912
* lacks a positional target.
913913
*/
914914
createFromCapture(capture: SelectionCapture, input: { text: string }): import('@superdoc/document-api').Receipt;
915+
/**
916+
* Post a reply to an existing thread. Routes through
917+
* `editor.doc.comments.create({ parentCommentId, text })`; the
918+
* reply inherits the parent's anchor, so callers don't pass a
919+
* target. The next `useSuperDocComments()` snapshot includes the
920+
* reply with `parentCommentId` set, which sidebars can group under
921+
* the thread root.
922+
*
923+
* Returns a `NO_OP` receipt when `text` is empty or whitespace-only,
924+
* matching the doc-api's text-required contract for top-level
925+
* comments. Returns a failure receipt when the parent id has been
926+
* deleted between the time the user opened the reply composer and
927+
* pressed Send.
928+
*/
929+
reply(parentCommentId: string, input: { text: string }): import('@superdoc/document-api').Receipt;
915930
/** Resolve a comment via `editor.doc.comments.patch`. */
916931
resolve(commentId: string): import('@superdoc/document-api').Receipt;
917932
/**

0 commit comments

Comments
 (0)