@actuate-media/cms-admin 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/components/comment-mark.test.d.ts +2 -0
- package/dist/__tests__/components/comment-mark.test.d.ts.map +1 -0
- package/dist/__tests__/components/comment-mark.test.js +89 -0
- package/dist/__tests__/components/comment-mark.test.js.map +1 -0
- package/dist/__tests__/components/comment-side-panel.test.d.ts +2 -0
- package/dist/__tests__/components/comment-side-panel.test.d.ts.map +1 -0
- package/dist/__tests__/components/comment-side-panel.test.js +291 -0
- package/dist/__tests__/components/comment-side-panel.test.js.map +1 -0
- package/dist/__tests__/components/notification-bell.test.d.ts +2 -0
- package/dist/__tests__/components/notification-bell.test.d.ts.map +1 -0
- package/dist/__tests__/components/notification-bell.test.js +346 -0
- package/dist/__tests__/components/notification-bell.test.js.map +1 -0
- package/dist/__tests__/components/offline-status.test.d.ts +2 -0
- package/dist/__tests__/components/offline-status.test.d.ts.map +1 -0
- package/dist/__tests__/components/offline-status.test.js +56 -0
- package/dist/__tests__/components/offline-status.test.js.map +1 -0
- package/dist/__tests__/components/presence-chips-helpers.test.d.ts +2 -0
- package/dist/__tests__/components/presence-chips-helpers.test.d.ts.map +1 -0
- package/dist/__tests__/components/presence-chips-helpers.test.js +35 -0
- package/dist/__tests__/components/presence-chips-helpers.test.js.map +1 -0
- package/dist/__tests__/lib/active-editor.test.d.ts +2 -0
- package/dist/__tests__/lib/active-editor.test.d.ts.map +1 -0
- package/dist/__tests__/lib/active-editor.test.js +57 -0
- package/dist/__tests__/lib/active-editor.test.js.map +1 -0
- package/dist/__tests__/lib/coauthor-client.test.d.ts +2 -0
- package/dist/__tests__/lib/coauthor-client.test.d.ts.map +1 -0
- package/dist/__tests__/lib/coauthor-client.test.js +173 -0
- package/dist/__tests__/lib/coauthor-client.test.js.map +1 -0
- package/dist/__tests__/lib/collaboration-provider.test.d.ts +2 -0
- package/dist/__tests__/lib/collaboration-provider.test.d.ts.map +1 -0
- package/dist/__tests__/lib/collaboration-provider.test.js +335 -0
- package/dist/__tests__/lib/collaboration-provider.test.js.map +1 -0
- package/dist/__tests__/lib/comment-anchor.test.d.ts +2 -0
- package/dist/__tests__/lib/comment-anchor.test.d.ts.map +1 -0
- package/dist/__tests__/lib/comment-anchor.test.js +173 -0
- package/dist/__tests__/lib/comment-anchor.test.js.map +1 -0
- package/dist/__tests__/lib/comments-client.test.d.ts +2 -0
- package/dist/__tests__/lib/comments-client.test.d.ts.map +1 -0
- package/dist/__tests__/lib/comments-client.test.js +202 -0
- package/dist/__tests__/lib/comments-client.test.js.map +1 -0
- package/dist/__tests__/lib/notifications-client.test.d.ts +2 -0
- package/dist/__tests__/lib/notifications-client.test.d.ts.map +1 -0
- package/dist/__tests__/lib/notifications-client.test.js +283 -0
- package/dist/__tests__/lib/notifications-client.test.js.map +1 -0
- package/dist/__tests__/lib/slash-command.test.d.ts +2 -0
- package/dist/__tests__/lib/slash-command.test.d.ts.map +1 -0
- package/dist/__tests__/lib/slash-command.test.js +123 -0
- package/dist/__tests__/lib/slash-command.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/AICoauthorBubbleMenu.d.ts +18 -0
- package/dist/components/AICoauthorBubbleMenu.d.ts.map +1 -0
- package/dist/components/AICoauthorBubbleMenu.js +113 -0
- package/dist/components/AICoauthorBubbleMenu.js.map +1 -0
- package/dist/components/CoauthorResultPopover.d.ts +28 -0
- package/dist/components/CoauthorResultPopover.d.ts.map +1 -0
- package/dist/components/CoauthorResultPopover.js +42 -0
- package/dist/components/CoauthorResultPopover.js.map +1 -0
- package/dist/components/CommandPalette.d.ts +1 -1
- package/dist/components/CommandPalette.d.ts.map +1 -1
- package/dist/components/CommandPalette.js +138 -40
- package/dist/components/CommandPalette.js.map +1 -1
- package/dist/components/CommentMark.d.ts +54 -0
- package/dist/components/CommentMark.d.ts.map +1 -0
- package/dist/components/CommentMark.js +104 -0
- package/dist/components/CommentMark.js.map +1 -0
- package/dist/components/CommentSidePanel.d.ts +86 -0
- package/dist/components/CommentSidePanel.d.ts.map +1 -0
- package/dist/components/CommentSidePanel.js +165 -0
- package/dist/components/CommentSidePanel.js.map +1 -0
- package/dist/components/LivePreview.d.ts.map +1 -1
- package/dist/components/LivePreview.js +80 -16
- package/dist/components/LivePreview.js.map +1 -1
- package/dist/components/NotificationBell.d.ts +66 -0
- package/dist/components/NotificationBell.d.ts.map +1 -0
- package/dist/components/NotificationBell.js +142 -0
- package/dist/components/NotificationBell.js.map +1 -0
- package/dist/components/OfflineStatus.d.ts +39 -0
- package/dist/components/OfflineStatus.d.ts.map +1 -0
- package/dist/components/OfflineStatus.js +47 -0
- package/dist/components/OfflineStatus.js.map +1 -0
- package/dist/components/PresenceChips.d.ts +31 -0
- package/dist/components/PresenceChips.d.ts.map +1 -0
- package/dist/components/PresenceChips.js +58 -0
- package/dist/components/PresenceChips.js.map +1 -0
- package/dist/components/TipTapEditor.d.ts +61 -1
- package/dist/components/TipTapEditor.d.ts.map +1 -1
- package/dist/components/TipTapEditor.js +211 -8
- package/dist/components/TipTapEditor.js.map +1 -1
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/active-editor.d.ts +6 -0
- package/dist/lib/active-editor.d.ts.map +1 -0
- package/dist/lib/active-editor.js +35 -0
- package/dist/lib/active-editor.js.map +1 -0
- package/dist/lib/api.d.ts +8 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +10 -0
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/coauthor-client.d.ts +72 -0
- package/dist/lib/coauthor-client.d.ts.map +1 -0
- package/dist/lib/coauthor-client.js +98 -0
- package/dist/lib/coauthor-client.js.map +1 -0
- package/dist/lib/collaboration-provider.d.ts +142 -0
- package/dist/lib/collaboration-provider.d.ts.map +1 -0
- package/dist/lib/collaboration-provider.js +168 -0
- package/dist/lib/collaboration-provider.js.map +1 -0
- package/dist/lib/comment-anchor.d.ts +86 -0
- package/dist/lib/comment-anchor.d.ts.map +1 -0
- package/dist/lib/comment-anchor.js +139 -0
- package/dist/lib/comment-anchor.js.map +1 -0
- package/dist/lib/comments-client.d.ts +106 -0
- package/dist/lib/comments-client.d.ts.map +1 -0
- package/dist/lib/comments-client.js +138 -0
- package/dist/lib/comments-client.js.map +1 -0
- package/dist/lib/notifications-client.d.ts +121 -0
- package/dist/lib/notifications-client.d.ts.map +1 -0
- package/dist/lib/notifications-client.js +173 -0
- package/dist/lib/notifications-client.js.map +1 -0
- package/dist/lib/slash-command.d.ts +39 -0
- package/dist/lib/slash-command.d.ts.map +1 -0
- package/dist/lib/slash-command.js +50 -0
- package/dist/lib/slash-command.js.map +1 -0
- package/package.json +12 -3
- package/src/__tests__/components/comment-mark.test.ts +99 -0
- package/src/__tests__/components/comment-side-panel.test.tsx +343 -0
- package/src/__tests__/components/notification-bell.test.tsx +379 -0
- package/src/__tests__/components/offline-status.test.tsx +66 -0
- package/src/__tests__/components/presence-chips-helpers.test.ts +44 -0
- package/src/__tests__/lib/active-editor.test.ts +70 -0
- package/src/__tests__/lib/coauthor-client.test.ts +213 -0
- package/src/__tests__/lib/collaboration-provider.test.ts +425 -0
- package/src/__tests__/lib/comment-anchor.test.ts +188 -0
- package/src/__tests__/lib/comments-client.test.ts +239 -0
- package/src/__tests__/lib/notifications-client.test.ts +339 -0
- package/src/__tests__/lib/slash-command.test.ts +146 -0
- package/src/components/AICoauthorBubbleMenu.tsx +187 -0
- package/src/components/CoauthorResultPopover.tsx +193 -0
- package/src/components/CommandPalette.tsx +397 -209
- package/src/components/CommentMark.ts +138 -0
- package/src/components/CommentSidePanel.tsx +521 -0
- package/src/components/LivePreview.tsx +117 -11
- package/src/components/NotificationBell.tsx +314 -0
- package/src/components/OfflineStatus.tsx +90 -0
- package/src/components/PresenceChips.tsx +150 -0
- package/src/components/TipTapEditor.tsx +310 -12
- package/src/index.ts +20 -0
- package/src/lib/active-editor.ts +39 -0
- package/src/lib/api.ts +11 -0
- package/src/lib/coauthor-client.ts +160 -0
- package/src/lib/collaboration-provider.ts +306 -0
- package/src/lib/comment-anchor.ts +179 -0
- package/src/lib/comments-client.ts +208 -0
- package/src/lib/notifications-client.ts +292 -0
- package/src/lib/slash-command.ts +67 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import * as Y from 'yjs'
|
|
3
|
+
|
|
4
|
+
import { decodeAnchor, encodeAnchor } from '../../lib/comment-anchor.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build a Yjs doc with a TipTap-style XmlFragment containing one
|
|
8
|
+
* paragraph element whose text is the supplied string. Returns both the
|
|
9
|
+
* doc and the `Y.XmlText` leaf so tests can anchor against the right
|
|
10
|
+
* type (`createRelativePositionFromTypeIndex` indexes characters when
|
|
11
|
+
* called against an `XmlText`/`Text`, and child slots when called
|
|
12
|
+
* against the `XmlFragment` / element).
|
|
13
|
+
*/
|
|
14
|
+
function makeDoc(text: string): { doc: Y.Doc; yText: Y.XmlText } {
|
|
15
|
+
const doc = new Y.Doc()
|
|
16
|
+
const fragment = doc.getXmlFragment('default')
|
|
17
|
+
const paragraph = new Y.XmlElement('paragraph')
|
|
18
|
+
const yText = new Y.XmlText(text)
|
|
19
|
+
paragraph.insert(0, [yText])
|
|
20
|
+
fragment.insert(0, [paragraph])
|
|
21
|
+
return { doc, yText }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('encodeAnchor', () => {
|
|
25
|
+
it('produces non-empty base64 strings for a valid selection', () => {
|
|
26
|
+
const { doc, yText } = makeDoc('Hello world')
|
|
27
|
+
const anchor = encodeAnchor({ doc, yType: yText, from: 0, to: 5 })
|
|
28
|
+
expect(anchor).not.toBeNull()
|
|
29
|
+
if (!anchor) return
|
|
30
|
+
expect(anchor.from.length).toBeGreaterThan(0)
|
|
31
|
+
expect(anchor.to.length).toBeGreaterThan(0)
|
|
32
|
+
expect(anchor.from).not.toBe(anchor.to)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('returns null for an empty range', () => {
|
|
36
|
+
const { doc, yText } = makeDoc('Hello world')
|
|
37
|
+
expect(encodeAnchor({ doc, yType: yText, from: 3, to: 3 })).toBeNull()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('returns null for an inverted range', () => {
|
|
41
|
+
const { doc, yText } = makeDoc('Hello world')
|
|
42
|
+
expect(encodeAnchor({ doc, yType: yText, from: 5, to: 1 })).toBeNull()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('returns null when from < 0', () => {
|
|
46
|
+
const { doc, yText } = makeDoc('Hello world')
|
|
47
|
+
expect(encodeAnchor({ doc, yType: yText, from: -1, to: 5 })).toBeNull()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('returns null when to > yType.length', () => {
|
|
51
|
+
const { doc, yText } = makeDoc('Hello world')
|
|
52
|
+
expect(encodeAnchor({ doc, yType: yText, from: 0, to: 999 })).toBeNull()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('produces stable output for the same range called twice', () => {
|
|
56
|
+
const { doc, yText } = makeDoc('Hello world')
|
|
57
|
+
const a1 = encodeAnchor({ doc, yType: yText, from: 0, to: 5 })
|
|
58
|
+
const a2 = encodeAnchor({ doc, yType: yText, from: 0, to: 5 })
|
|
59
|
+
expect(a1).toEqual(a2)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('decodeAnchor — round trip', () => {
|
|
64
|
+
it('round-trips the original positions on an unchanged doc', () => {
|
|
65
|
+
const { doc, yText } = makeDoc('Hello world')
|
|
66
|
+
const anchor = encodeAnchor({ doc, yType: yText, from: 6, to: 11 })
|
|
67
|
+
expect(anchor).not.toBeNull()
|
|
68
|
+
if (!anchor) return
|
|
69
|
+
const resolved = decodeAnchor({ doc, anchor })
|
|
70
|
+
expect(resolved).not.toBeNull()
|
|
71
|
+
if (!resolved) return
|
|
72
|
+
expect(resolved.from).toBe(6)
|
|
73
|
+
expect(resolved.to).toBe(11)
|
|
74
|
+
expect(resolved.yType).toBe(yText)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('returns null when the anchor blobs are not valid base64', () => {
|
|
78
|
+
const { doc } = makeDoc('Hello world')
|
|
79
|
+
expect(
|
|
80
|
+
decodeAnchor({
|
|
81
|
+
doc,
|
|
82
|
+
anchor: { from: 'not valid !@#$', to: 'also not !@#$' },
|
|
83
|
+
}),
|
|
84
|
+
).toBeNull()
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('decodeAnchor — survives concurrent edits', () => {
|
|
89
|
+
it('shifts the anchor right when text is inserted before it', () => {
|
|
90
|
+
const { doc, yText } = makeDoc('Hello world')
|
|
91
|
+
// Anchor "world" (chars 6..11).
|
|
92
|
+
const anchor = encodeAnchor({ doc, yType: yText, from: 6, to: 11 })
|
|
93
|
+
expect(anchor).not.toBeNull()
|
|
94
|
+
if (!anchor) return
|
|
95
|
+
|
|
96
|
+
// Prepend "Hey, " — five characters — to the text.
|
|
97
|
+
yText.insert(0, 'Hey, ')
|
|
98
|
+
|
|
99
|
+
const resolved = decodeAnchor({ doc, anchor })
|
|
100
|
+
expect(resolved).not.toBeNull()
|
|
101
|
+
if (!resolved) return
|
|
102
|
+
expect(yText.toString().slice(resolved.from, resolved.to)).toBe('world')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('still resolves when text is appended after the anchor', () => {
|
|
106
|
+
const { doc, yText } = makeDoc('Hello world')
|
|
107
|
+
const anchor = encodeAnchor({ doc, yType: yText, from: 0, to: 5 })
|
|
108
|
+
expect(anchor).not.toBeNull()
|
|
109
|
+
if (!anchor) return
|
|
110
|
+
|
|
111
|
+
yText.insert(yText.length, '!')
|
|
112
|
+
|
|
113
|
+
const resolved = decodeAnchor({ doc, anchor })
|
|
114
|
+
expect(resolved).not.toBeNull()
|
|
115
|
+
if (!resolved) return
|
|
116
|
+
expect(yText.toString().slice(resolved.from, resolved.to)).toBe('Hello')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('detaches (returns null) when the anchored text is deleted', () => {
|
|
120
|
+
const { doc, yText } = makeDoc('Hello world')
|
|
121
|
+
const anchor = encodeAnchor({ doc, yType: yText, from: 6, to: 11 })
|
|
122
|
+
expect(anchor).not.toBeNull()
|
|
123
|
+
if (!anchor) return
|
|
124
|
+
|
|
125
|
+
// Delete "world" — five chars starting at index 6.
|
|
126
|
+
yText.delete(6, 5)
|
|
127
|
+
|
|
128
|
+
const resolved = decodeAnchor({ doc, anchor })
|
|
129
|
+
// After deletion both ends collapse; we treat `from >= to` as detached
|
|
130
|
+
// so the panel can flag the comment as "anchor lost".
|
|
131
|
+
expect(resolved).toBeNull()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('survives a sequence of inserts on both sides', () => {
|
|
135
|
+
const { doc, yText } = makeDoc('alpha beta gamma')
|
|
136
|
+
const anchor = encodeAnchor({ doc, yType: yText, from: 6, to: 10 }) // "beta"
|
|
137
|
+
expect(anchor).not.toBeNull()
|
|
138
|
+
if (!anchor) return
|
|
139
|
+
|
|
140
|
+
yText.insert(0, '>> ')
|
|
141
|
+
yText.insert(yText.length, ' <<')
|
|
142
|
+
yText.insert(0, '!')
|
|
143
|
+
|
|
144
|
+
const resolved = decodeAnchor({ doc, anchor })
|
|
145
|
+
expect(resolved).not.toBeNull()
|
|
146
|
+
if (!resolved) return
|
|
147
|
+
expect(yText.toString().slice(resolved.from, resolved.to)).toBe('beta')
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('decodeAnchor — independent docs', () => {
|
|
152
|
+
it('survives transport between two clones (the multi-user scenario)', () => {
|
|
153
|
+
const a = makeDoc('Hello world')
|
|
154
|
+
const anchor = encodeAnchor({ doc: a.doc, yType: a.yText, from: 6, to: 11 })
|
|
155
|
+
expect(anchor).not.toBeNull()
|
|
156
|
+
if (!anchor) return
|
|
157
|
+
|
|
158
|
+
// Sync a fresh clone (the other client's doc) from author A's state.
|
|
159
|
+
const docB = new Y.Doc()
|
|
160
|
+
Y.applyUpdate(docB, Y.encodeStateAsUpdate(a.doc))
|
|
161
|
+
|
|
162
|
+
const resolved = decodeAnchor({ doc: docB, anchor })
|
|
163
|
+
expect(resolved).not.toBeNull()
|
|
164
|
+
if (!resolved) return
|
|
165
|
+
expect(resolved.from).toBe(6)
|
|
166
|
+
expect(resolved.to).toBe(11)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('still resolves after the clone makes its own edit', () => {
|
|
170
|
+
const a = makeDoc('Hello world')
|
|
171
|
+
const anchor = encodeAnchor({ doc: a.doc, yType: a.yText, from: 6, to: 11 })
|
|
172
|
+
expect(anchor).not.toBeNull()
|
|
173
|
+
if (!anchor) return
|
|
174
|
+
|
|
175
|
+
const docB = new Y.Doc()
|
|
176
|
+
Y.applyUpdate(docB, Y.encodeStateAsUpdate(a.doc))
|
|
177
|
+
// Look up B's mirror of the same text node and edit it.
|
|
178
|
+
const fragmentB = docB.getXmlFragment('default')
|
|
179
|
+
const paragraphB = fragmentB.firstChild as Y.XmlElement
|
|
180
|
+
const yTextB = paragraphB.firstChild as Y.XmlText
|
|
181
|
+
yTextB.insert(0, 'Hey, ')
|
|
182
|
+
|
|
183
|
+
const resolved = decodeAnchor({ doc: docB, anchor })
|
|
184
|
+
expect(resolved).not.toBeNull()
|
|
185
|
+
if (!resolved) return
|
|
186
|
+
expect(yTextB.toString().slice(resolved.from, resolved.to)).toBe('world')
|
|
187
|
+
})
|
|
188
|
+
})
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
const cmsApiMock = vi.fn()
|
|
4
|
+
vi.mock('../../lib/api.js', () => ({
|
|
5
|
+
cmsApi: (...args: unknown[]) => cmsApiMock(...args),
|
|
6
|
+
}))
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
createComment,
|
|
10
|
+
deleteComment,
|
|
11
|
+
groupCommentsIntoThreads,
|
|
12
|
+
listComments,
|
|
13
|
+
reopenComment,
|
|
14
|
+
resolveComment,
|
|
15
|
+
updateComment,
|
|
16
|
+
} = await import('../../lib/comments-client.js')
|
|
17
|
+
import type { Comment } from '../../lib/comments-client.js'
|
|
18
|
+
|
|
19
|
+
function commentRow(overrides: Partial<Comment> = {}): Comment {
|
|
20
|
+
return {
|
|
21
|
+
id: 'c-1',
|
|
22
|
+
documentId: 'doc-1',
|
|
23
|
+
parentId: null,
|
|
24
|
+
userId: 'user-1',
|
|
25
|
+
body: 'hi',
|
|
26
|
+
anchor: null,
|
|
27
|
+
resolvedAt: null,
|
|
28
|
+
resolvedById: null,
|
|
29
|
+
deletedAt: null,
|
|
30
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
31
|
+
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
32
|
+
isResolved: false,
|
|
33
|
+
...overrides,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
cmsApiMock.mockReset()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('createComment', () => {
|
|
42
|
+
it('posts to the document scoped endpoint and unwraps the envelope', async () => {
|
|
43
|
+
const row = commentRow({ id: 'c-99', body: 'fresh' })
|
|
44
|
+
cmsApiMock.mockResolvedValueOnce({ data: row, status: 201 })
|
|
45
|
+
|
|
46
|
+
const out = await createComment({ documentId: 'doc-1', body: ' fresh ' })
|
|
47
|
+
expect(out.ok).toBe(true)
|
|
48
|
+
if (!out.ok) return
|
|
49
|
+
expect(out.result.id).toBe('c-99')
|
|
50
|
+
|
|
51
|
+
expect(cmsApiMock).toHaveBeenCalledWith('/documents/doc-1/comments', {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
body: JSON.stringify({ body: 'fresh' }),
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('forwards anchor + parentId when supplied', async () => {
|
|
58
|
+
cmsApiMock.mockResolvedValueOnce({ data: commentRow(), status: 201 })
|
|
59
|
+
await createComment({
|
|
60
|
+
documentId: 'doc-1',
|
|
61
|
+
body: 'reply',
|
|
62
|
+
anchor: { from: 'AQ==', to: 'Ag==' },
|
|
63
|
+
parentId: 'c-1',
|
|
64
|
+
})
|
|
65
|
+
const [, init] = cmsApiMock.mock.calls[0] as [string, RequestInit]
|
|
66
|
+
expect(JSON.parse(init.body as string)).toEqual({
|
|
67
|
+
body: 'reply',
|
|
68
|
+
anchor: { from: 'AQ==', to: 'Ag==' },
|
|
69
|
+
parentId: 'c-1',
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('rejects empty bodies before hitting the wire', async () => {
|
|
74
|
+
const out = await createComment({ documentId: 'doc-1', body: ' ' })
|
|
75
|
+
expect(out.ok).toBe(false)
|
|
76
|
+
expect(cmsApiMock).not.toHaveBeenCalled()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('rejects missing documentId before hitting the wire', async () => {
|
|
80
|
+
const out = await createComment({ documentId: '', body: 'hi' })
|
|
81
|
+
expect(out.ok).toBe(false)
|
|
82
|
+
expect(cmsApiMock).not.toHaveBeenCalled()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('URL-encodes documentIds with awkward characters', async () => {
|
|
86
|
+
cmsApiMock.mockResolvedValueOnce({ data: commentRow(), status: 201 })
|
|
87
|
+
await createComment({ documentId: 'doc with space/?', body: 'hi' })
|
|
88
|
+
expect(cmsApiMock).toHaveBeenCalledWith(
|
|
89
|
+
`/documents/${encodeURIComponent('doc with space/?')}/comments`,
|
|
90
|
+
expect.anything(),
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('propagates server errors as { ok: false }', async () => {
|
|
95
|
+
cmsApiMock.mockResolvedValueOnce({ error: 'CSRF rejected', status: 403 })
|
|
96
|
+
const out = await createComment({ documentId: 'doc-1', body: 'hi' })
|
|
97
|
+
expect(out.ok).toBe(false)
|
|
98
|
+
if (out.ok) return
|
|
99
|
+
expect(out.status).toBe(403)
|
|
100
|
+
expect(out.error).toBe('CSRF rejected')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('falls back to a generic message when the server omits one', async () => {
|
|
104
|
+
cmsApiMock.mockResolvedValueOnce({ status: 500 })
|
|
105
|
+
const out = await createComment({ documentId: 'doc-1', body: 'hi' })
|
|
106
|
+
expect(out.ok).toBe(false)
|
|
107
|
+
if (out.ok) return
|
|
108
|
+
expect(out.error).toContain('500')
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('listComments', () => {
|
|
113
|
+
it('GETs the document endpoint with no query when no filters are set', async () => {
|
|
114
|
+
cmsApiMock.mockResolvedValueOnce({ data: [commentRow()], status: 200 })
|
|
115
|
+
const out = await listComments('doc-1')
|
|
116
|
+
expect(out.ok).toBe(true)
|
|
117
|
+
expect(cmsApiMock).toHaveBeenCalledWith('/documents/doc-1/comments', { method: 'GET' })
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('appends ?includeResolved + ?includeDeleted + ?pageSize when set', async () => {
|
|
121
|
+
cmsApiMock.mockResolvedValueOnce({ data: [], status: 200 })
|
|
122
|
+
await listComments('doc-1', { includeResolved: true, includeDeleted: true, pageSize: 25 })
|
|
123
|
+
expect(cmsApiMock).toHaveBeenCalledWith(
|
|
124
|
+
'/documents/doc-1/comments?includeResolved=true&includeDeleted=true&pageSize=25',
|
|
125
|
+
{ method: 'GET' },
|
|
126
|
+
)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('rejects empty documentId before hitting the wire', async () => {
|
|
130
|
+
const out = await listComments('')
|
|
131
|
+
expect(out.ok).toBe(false)
|
|
132
|
+
expect(cmsApiMock).not.toHaveBeenCalled()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('returns the data array unchanged', async () => {
|
|
136
|
+
const rows = [commentRow({ id: 'c-1' }), commentRow({ id: 'c-2' })]
|
|
137
|
+
cmsApiMock.mockResolvedValueOnce({ data: rows, status: 200 })
|
|
138
|
+
const out = await listComments('doc-1')
|
|
139
|
+
expect(out.ok).toBe(true)
|
|
140
|
+
if (!out.ok) return
|
|
141
|
+
expect(out.result.map((c) => c.id)).toEqual(['c-1', 'c-2'])
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('updateComment / resolveComment / reopenComment / deleteComment', () => {
|
|
146
|
+
it('PATCHes the comment endpoint with the trimmed body', async () => {
|
|
147
|
+
cmsApiMock.mockResolvedValueOnce({ data: commentRow({ body: 'next' }), status: 200 })
|
|
148
|
+
await updateComment('c-1', ' next ')
|
|
149
|
+
expect(cmsApiMock).toHaveBeenCalledWith('/comments/c-1', {
|
|
150
|
+
method: 'PATCH',
|
|
151
|
+
body: JSON.stringify({ body: 'next' }),
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('rejects empty bodies on update', async () => {
|
|
156
|
+
const out = await updateComment('c-1', ' ')
|
|
157
|
+
expect(out.ok).toBe(false)
|
|
158
|
+
expect(cmsApiMock).not.toHaveBeenCalled()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('POSTs to /resolve and /reopen with an empty body', async () => {
|
|
162
|
+
cmsApiMock
|
|
163
|
+
.mockResolvedValueOnce({
|
|
164
|
+
data: commentRow({ resolvedAt: 'now', isResolved: true }),
|
|
165
|
+
status: 200,
|
|
166
|
+
})
|
|
167
|
+
.mockResolvedValueOnce({ data: commentRow(), status: 200 })
|
|
168
|
+
|
|
169
|
+
await resolveComment('c-1')
|
|
170
|
+
await reopenComment('c-1')
|
|
171
|
+
|
|
172
|
+
expect(cmsApiMock).toHaveBeenNthCalledWith(1, '/comments/c-1/resolve', {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
body: '{}',
|
|
175
|
+
})
|
|
176
|
+
expect(cmsApiMock).toHaveBeenNthCalledWith(2, '/comments/c-1/reopen', {
|
|
177
|
+
method: 'POST',
|
|
178
|
+
body: '{}',
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('DELETEs the comment endpoint and returns the soft-deleted DTO', async () => {
|
|
183
|
+
cmsApiMock.mockResolvedValueOnce({
|
|
184
|
+
data: commentRow({ deletedAt: '2026-01-02T00:00:00.000Z' }),
|
|
185
|
+
status: 200,
|
|
186
|
+
})
|
|
187
|
+
const out = await deleteComment('c-1')
|
|
188
|
+
expect(out.ok).toBe(true)
|
|
189
|
+
if (!out.ok) return
|
|
190
|
+
expect(out.result.deletedAt).toBe('2026-01-02T00:00:00.000Z')
|
|
191
|
+
expect(cmsApiMock).toHaveBeenCalledWith('/comments/c-1', { method: 'DELETE' })
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('rejects missing id without hitting the wire', async () => {
|
|
195
|
+
expect((await updateComment('', 'x')).ok).toBe(false)
|
|
196
|
+
expect((await resolveComment('')).ok).toBe(false)
|
|
197
|
+
expect((await reopenComment('')).ok).toBe(false)
|
|
198
|
+
expect((await deleteComment('')).ok).toBe(false)
|
|
199
|
+
expect(cmsApiMock).not.toHaveBeenCalled()
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('groupCommentsIntoThreads', () => {
|
|
204
|
+
it('returns roots in chronological order with their replies attached', () => {
|
|
205
|
+
const root1 = commentRow({
|
|
206
|
+
id: 'r-1',
|
|
207
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
208
|
+
})
|
|
209
|
+
const root2 = commentRow({
|
|
210
|
+
id: 'r-2',
|
|
211
|
+
createdAt: '2026-01-01T00:00:02.000Z',
|
|
212
|
+
})
|
|
213
|
+
const reply1 = commentRow({
|
|
214
|
+
id: 'reply-1',
|
|
215
|
+
parentId: 'r-1',
|
|
216
|
+
createdAt: '2026-01-01T00:00:01.000Z',
|
|
217
|
+
})
|
|
218
|
+
const reply2 = commentRow({
|
|
219
|
+
id: 'reply-2',
|
|
220
|
+
parentId: 'r-1',
|
|
221
|
+
createdAt: '2026-01-01T00:00:03.000Z',
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
const threads = groupCommentsIntoThreads([reply2, root1, root2, reply1])
|
|
225
|
+
expect(threads.map((t) => t.root.id)).toEqual(['r-1', 'r-2'])
|
|
226
|
+
expect(threads[0]!.replies.map((r) => r.id)).toEqual(['reply-1', 'reply-2'])
|
|
227
|
+
expect(threads[1]!.replies).toEqual([])
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('drops replies whose root is missing from the input', () => {
|
|
231
|
+
const orphan = commentRow({ id: 'orphan', parentId: 'missing-root' })
|
|
232
|
+
const threads = groupCommentsIntoThreads([orphan])
|
|
233
|
+
expect(threads).toEqual([])
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('handles an empty input', () => {
|
|
237
|
+
expect(groupCommentsIntoThreads([])).toEqual([])
|
|
238
|
+
})
|
|
239
|
+
})
|