@actuate-media/cms-admin 0.11.0 → 0.12.1

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.
Files changed (183) hide show
  1. package/LICENSE +21 -21
  2. package/dist/__tests__/components/comment-mark.test.d.ts +2 -0
  3. package/dist/__tests__/components/comment-mark.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/comment-mark.test.js +89 -0
  5. package/dist/__tests__/components/comment-mark.test.js.map +1 -0
  6. package/dist/__tests__/components/comment-side-panel.test.d.ts +2 -0
  7. package/dist/__tests__/components/comment-side-panel.test.d.ts.map +1 -0
  8. package/dist/__tests__/components/comment-side-panel.test.js +291 -0
  9. package/dist/__tests__/components/comment-side-panel.test.js.map +1 -0
  10. package/dist/__tests__/components/notification-bell.test.d.ts +2 -0
  11. package/dist/__tests__/components/notification-bell.test.d.ts.map +1 -0
  12. package/dist/__tests__/components/notification-bell.test.js +200 -0
  13. package/dist/__tests__/components/notification-bell.test.js.map +1 -0
  14. package/dist/__tests__/components/offline-status.test.d.ts +2 -0
  15. package/dist/__tests__/components/offline-status.test.d.ts.map +1 -0
  16. package/dist/__tests__/components/offline-status.test.js +56 -0
  17. package/dist/__tests__/components/offline-status.test.js.map +1 -0
  18. package/dist/__tests__/components/presence-chips-helpers.test.d.ts +2 -0
  19. package/dist/__tests__/components/presence-chips-helpers.test.d.ts.map +1 -0
  20. package/dist/__tests__/components/presence-chips-helpers.test.js +35 -0
  21. package/dist/__tests__/components/presence-chips-helpers.test.js.map +1 -0
  22. package/dist/__tests__/fields/component-block-helpers.test.d.ts +7 -0
  23. package/dist/__tests__/fields/component-block-helpers.test.d.ts.map +1 -0
  24. package/dist/__tests__/fields/component-block-helpers.test.js +592 -0
  25. package/dist/__tests__/fields/component-block-helpers.test.js.map +1 -0
  26. package/dist/__tests__/lib/active-editor.test.d.ts +2 -0
  27. package/dist/__tests__/lib/active-editor.test.d.ts.map +1 -0
  28. package/dist/__tests__/lib/active-editor.test.js +57 -0
  29. package/dist/__tests__/lib/active-editor.test.js.map +1 -0
  30. package/dist/__tests__/lib/coauthor-client.test.d.ts +2 -0
  31. package/dist/__tests__/lib/coauthor-client.test.d.ts.map +1 -0
  32. package/dist/__tests__/lib/coauthor-client.test.js +173 -0
  33. package/dist/__tests__/lib/coauthor-client.test.js.map +1 -0
  34. package/dist/__tests__/lib/collaboration-provider.test.d.ts +2 -0
  35. package/dist/__tests__/lib/collaboration-provider.test.d.ts.map +1 -0
  36. package/dist/__tests__/lib/collaboration-provider.test.js +335 -0
  37. package/dist/__tests__/lib/collaboration-provider.test.js.map +1 -0
  38. package/dist/__tests__/lib/comment-anchor.test.d.ts +2 -0
  39. package/dist/__tests__/lib/comment-anchor.test.d.ts.map +1 -0
  40. package/dist/__tests__/lib/comment-anchor.test.js +173 -0
  41. package/dist/__tests__/lib/comment-anchor.test.js.map +1 -0
  42. package/dist/__tests__/lib/comments-client.test.d.ts +2 -0
  43. package/dist/__tests__/lib/comments-client.test.d.ts.map +1 -0
  44. package/dist/__tests__/lib/comments-client.test.js +202 -0
  45. package/dist/__tests__/lib/comments-client.test.js.map +1 -0
  46. package/dist/__tests__/lib/notifications-client.test.d.ts +2 -0
  47. package/dist/__tests__/lib/notifications-client.test.d.ts.map +1 -0
  48. package/dist/__tests__/lib/notifications-client.test.js +155 -0
  49. package/dist/__tests__/lib/notifications-client.test.js.map +1 -0
  50. package/dist/__tests__/lib/slash-command.test.d.ts +2 -0
  51. package/dist/__tests__/lib/slash-command.test.d.ts.map +1 -0
  52. package/dist/__tests__/lib/slash-command.test.js +123 -0
  53. package/dist/__tests__/lib/slash-command.test.js.map +1 -0
  54. package/dist/actuate-admin.css +1 -1
  55. package/dist/components/AICoauthorBubbleMenu.d.ts +18 -0
  56. package/dist/components/AICoauthorBubbleMenu.d.ts.map +1 -0
  57. package/dist/components/AICoauthorBubbleMenu.js +113 -0
  58. package/dist/components/AICoauthorBubbleMenu.js.map +1 -0
  59. package/dist/components/CoauthorResultPopover.d.ts +28 -0
  60. package/dist/components/CoauthorResultPopover.d.ts.map +1 -0
  61. package/dist/components/CoauthorResultPopover.js +42 -0
  62. package/dist/components/CoauthorResultPopover.js.map +1 -0
  63. package/dist/components/CommandPalette.d.ts +1 -1
  64. package/dist/components/CommandPalette.d.ts.map +1 -1
  65. package/dist/components/CommandPalette.js +138 -40
  66. package/dist/components/CommandPalette.js.map +1 -1
  67. package/dist/components/CommentMark.d.ts +54 -0
  68. package/dist/components/CommentMark.d.ts.map +1 -0
  69. package/dist/components/CommentMark.js +104 -0
  70. package/dist/components/CommentMark.js.map +1 -0
  71. package/dist/components/CommentSidePanel.d.ts +86 -0
  72. package/dist/components/CommentSidePanel.d.ts.map +1 -0
  73. package/dist/components/CommentSidePanel.js +165 -0
  74. package/dist/components/CommentSidePanel.js.map +1 -0
  75. package/dist/components/LivePreview.d.ts.map +1 -1
  76. package/dist/components/LivePreview.js +80 -16
  77. package/dist/components/LivePreview.js.map +1 -1
  78. package/dist/components/NotificationBell.d.ts +45 -0
  79. package/dist/components/NotificationBell.d.ts.map +1 -0
  80. package/dist/components/NotificationBell.js +90 -0
  81. package/dist/components/NotificationBell.js.map +1 -0
  82. package/dist/components/OfflineStatus.d.ts +39 -0
  83. package/dist/components/OfflineStatus.d.ts.map +1 -0
  84. package/dist/components/OfflineStatus.js +47 -0
  85. package/dist/components/OfflineStatus.js.map +1 -0
  86. package/dist/components/PresenceChips.d.ts +31 -0
  87. package/dist/components/PresenceChips.d.ts.map +1 -0
  88. package/dist/components/PresenceChips.js +58 -0
  89. package/dist/components/PresenceChips.js.map +1 -0
  90. package/dist/components/TipTapEditor.d.ts +61 -1
  91. package/dist/components/TipTapEditor.d.ts.map +1 -1
  92. package/dist/components/TipTapEditor.js +211 -8
  93. package/dist/components/TipTapEditor.js.map +1 -1
  94. package/dist/components/ui/Badge.d.ts +1 -1
  95. package/dist/fields/ComponentBlockField.d.ts +25 -0
  96. package/dist/fields/ComponentBlockField.d.ts.map +1 -0
  97. package/dist/fields/ComponentBlockField.js +74 -0
  98. package/dist/fields/ComponentBlockField.js.map +1 -0
  99. package/dist/fields/FieldRenderer.d.ts +3 -0
  100. package/dist/fields/FieldRenderer.d.ts.map +1 -1
  101. package/dist/fields/FieldRenderer.js +3 -1
  102. package/dist/fields/FieldRenderer.js.map +1 -1
  103. package/dist/fields/PropInput.d.ts +14 -0
  104. package/dist/fields/PropInput.d.ts.map +1 -0
  105. package/dist/fields/PropInput.js +163 -0
  106. package/dist/fields/PropInput.js.map +1 -0
  107. package/dist/fields/component-block-helpers.d.ts +96 -0
  108. package/dist/fields/component-block-helpers.d.ts.map +1 -0
  109. package/dist/fields/component-block-helpers.js +323 -0
  110. package/dist/fields/component-block-helpers.js.map +1 -0
  111. package/dist/fields/index.d.ts +4 -0
  112. package/dist/fields/index.d.ts.map +1 -1
  113. package/dist/fields/index.js +2 -0
  114. package/dist/fields/index.js.map +1 -1
  115. package/dist/index.d.ts +10 -0
  116. package/dist/index.d.ts.map +1 -1
  117. package/dist/index.js +5 -0
  118. package/dist/index.js.map +1 -1
  119. package/dist/lib/active-editor.d.ts +6 -0
  120. package/dist/lib/active-editor.d.ts.map +1 -0
  121. package/dist/lib/active-editor.js +35 -0
  122. package/dist/lib/active-editor.js.map +1 -0
  123. package/dist/lib/coauthor-client.d.ts +72 -0
  124. package/dist/lib/coauthor-client.d.ts.map +1 -0
  125. package/dist/lib/coauthor-client.js +98 -0
  126. package/dist/lib/coauthor-client.js.map +1 -0
  127. package/dist/lib/collaboration-provider.d.ts +142 -0
  128. package/dist/lib/collaboration-provider.d.ts.map +1 -0
  129. package/dist/lib/collaboration-provider.js +168 -0
  130. package/dist/lib/collaboration-provider.js.map +1 -0
  131. package/dist/lib/comment-anchor.d.ts +86 -0
  132. package/dist/lib/comment-anchor.d.ts.map +1 -0
  133. package/dist/lib/comment-anchor.js +139 -0
  134. package/dist/lib/comment-anchor.js.map +1 -0
  135. package/dist/lib/comments-client.d.ts +106 -0
  136. package/dist/lib/comments-client.d.ts.map +1 -0
  137. package/dist/lib/comments-client.js +138 -0
  138. package/dist/lib/comments-client.js.map +1 -0
  139. package/dist/lib/notifications-client.d.ts +68 -0
  140. package/dist/lib/notifications-client.d.ts.map +1 -0
  141. package/dist/lib/notifications-client.js +85 -0
  142. package/dist/lib/notifications-client.js.map +1 -0
  143. package/dist/lib/slash-command.d.ts +39 -0
  144. package/dist/lib/slash-command.d.ts.map +1 -0
  145. package/dist/lib/slash-command.js +50 -0
  146. package/dist/lib/slash-command.js.map +1 -0
  147. package/package.json +20 -4
  148. package/src/__tests__/components/comment-mark.test.ts +99 -0
  149. package/src/__tests__/components/comment-side-panel.test.tsx +343 -0
  150. package/src/__tests__/components/notification-bell.test.tsx +213 -0
  151. package/src/__tests__/components/offline-status.test.tsx +66 -0
  152. package/src/__tests__/components/presence-chips-helpers.test.ts +44 -0
  153. package/src/__tests__/fields/component-block-helpers.test.ts +674 -0
  154. package/src/__tests__/lib/active-editor.test.ts +70 -0
  155. package/src/__tests__/lib/coauthor-client.test.ts +213 -0
  156. package/src/__tests__/lib/collaboration-provider.test.ts +425 -0
  157. package/src/__tests__/lib/comment-anchor.test.ts +188 -0
  158. package/src/__tests__/lib/comments-client.test.ts +239 -0
  159. package/src/__tests__/lib/notifications-client.test.ts +195 -0
  160. package/src/__tests__/lib/slash-command.test.ts +146 -0
  161. package/src/components/AICoauthorBubbleMenu.tsx +187 -0
  162. package/src/components/CoauthorResultPopover.tsx +193 -0
  163. package/src/components/CommandPalette.tsx +397 -209
  164. package/src/components/CommentMark.ts +138 -0
  165. package/src/components/CommentSidePanel.tsx +521 -0
  166. package/src/components/LivePreview.tsx +117 -11
  167. package/src/components/NotificationBell.tsx +242 -0
  168. package/src/components/OfflineStatus.tsx +90 -0
  169. package/src/components/PresenceChips.tsx +150 -0
  170. package/src/components/TipTapEditor.tsx +310 -12
  171. package/src/fields/ComponentBlockField.tsx +179 -0
  172. package/src/fields/FieldRenderer.tsx +8 -0
  173. package/src/fields/PropInput.tsx +552 -0
  174. package/src/fields/component-block-helpers.ts +341 -0
  175. package/src/fields/index.ts +4 -0
  176. package/src/index.ts +27 -0
  177. package/src/lib/active-editor.ts +39 -0
  178. package/src/lib/coauthor-client.ts +160 -0
  179. package/src/lib/collaboration-provider.ts +306 -0
  180. package/src/lib/comment-anchor.ts +179 -0
  181. package/src/lib/comments-client.ts +208 -0
  182. package/src/lib/notifications-client.ts +146 -0
  183. 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
+ })
@@ -0,0 +1,195 @@
1
+ import { beforeEach, describe, expect, it, vi } 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
+ listNotifications,
10
+ markAllNotificationsRead,
11
+ markNotificationRead,
12
+ notificationSummary,
13
+ unreadCount,
14
+ } = await import('../../lib/notifications-client.js')
15
+ import type { Notification } from '../../lib/notifications-client.js'
16
+
17
+ function row(overrides: Partial<Notification> = {}): Notification {
18
+ return {
19
+ id: 'n-1',
20
+ userId: 'user-1',
21
+ kind: 'comment_reply',
22
+ documentId: 'doc-1',
23
+ commentId: 'c-1',
24
+ payload: {
25
+ kind: 'comment_reply',
26
+ actorId: 'someone',
27
+ actorName: 'Someone',
28
+ excerpt: 'hi',
29
+ },
30
+ readAt: null,
31
+ createdAt: '2026-01-01T00:00:00.000Z',
32
+ ...overrides,
33
+ }
34
+ }
35
+
36
+ beforeEach(() => {
37
+ cmsApiMock.mockReset()
38
+ })
39
+
40
+ describe('listNotifications', () => {
41
+ it('GETs /notifications without query when no options', async () => {
42
+ cmsApiMock.mockResolvedValueOnce({ data: [row()], status: 200 })
43
+ const out = await listNotifications()
44
+ expect(out.ok).toBe(true)
45
+ expect(cmsApiMock).toHaveBeenCalledWith('/notifications', { method: 'GET' })
46
+ })
47
+
48
+ it('appends ?includeRead and ?pageSize when set', async () => {
49
+ cmsApiMock.mockResolvedValueOnce({ data: [], status: 200 })
50
+ await listNotifications({ includeRead: true, pageSize: 25 })
51
+ expect(cmsApiMock).toHaveBeenCalledWith('/notifications?includeRead=true&pageSize=25', {
52
+ method: 'GET',
53
+ })
54
+ })
55
+
56
+ it('returns the data array unchanged', async () => {
57
+ const rows = [row({ id: 'a' }), row({ id: 'b' })]
58
+ cmsApiMock.mockResolvedValueOnce({ data: rows, status: 200 })
59
+ const out = await listNotifications()
60
+ expect(out.ok).toBe(true)
61
+ if (!out.ok) return
62
+ expect(out.result.map((n) => n.id)).toEqual(['a', 'b'])
63
+ })
64
+
65
+ it('propagates errors as { ok: false }', async () => {
66
+ cmsApiMock.mockResolvedValueOnce({ error: 'nope', status: 500 })
67
+ const out = await listNotifications()
68
+ expect(out.ok).toBe(false)
69
+ if (out.ok) return
70
+ expect(out.error).toBe('nope')
71
+ expect(out.status).toBe(500)
72
+ })
73
+ })
74
+
75
+ describe('unreadCount', () => {
76
+ it('returns the count from the envelope', async () => {
77
+ cmsApiMock.mockResolvedValueOnce({ data: { count: 7 }, status: 200 })
78
+ const out = await unreadCount()
79
+ expect(out.ok).toBe(true)
80
+ if (!out.ok) return
81
+ expect(out.result).toBe(7)
82
+ })
83
+
84
+ it('propagates failure when the server returns no data', async () => {
85
+ cmsApiMock.mockResolvedValueOnce({ status: 503 })
86
+ const out = await unreadCount()
87
+ expect(out.ok).toBe(false)
88
+ })
89
+ })
90
+
91
+ describe('markNotificationRead', () => {
92
+ it('POSTs to the id-specific endpoint', async () => {
93
+ cmsApiMock.mockResolvedValueOnce({ data: row({ readAt: 'now' }), status: 200 })
94
+ const out = await markNotificationRead('n-1')
95
+ expect(out.ok).toBe(true)
96
+ expect(cmsApiMock).toHaveBeenCalledWith('/notifications/n-1/read', {
97
+ method: 'POST',
98
+ body: '{}',
99
+ })
100
+ })
101
+
102
+ it('URL-encodes weird ids', async () => {
103
+ cmsApiMock.mockResolvedValueOnce({ data: row(), status: 200 })
104
+ await markNotificationRead('n/1?')
105
+ expect(cmsApiMock).toHaveBeenCalledWith(
106
+ `/notifications/${encodeURIComponent('n/1?')}/read`,
107
+ expect.anything(),
108
+ )
109
+ })
110
+
111
+ it('rejects an empty id without hitting the wire', async () => {
112
+ const out = await markNotificationRead('')
113
+ expect(out.ok).toBe(false)
114
+ expect(cmsApiMock).not.toHaveBeenCalled()
115
+ })
116
+ })
117
+
118
+ describe('markAllNotificationsRead', () => {
119
+ it('POSTs /notifications/read-all and returns the count', async () => {
120
+ cmsApiMock.mockResolvedValueOnce({ data: { count: 4 }, status: 200 })
121
+ const out = await markAllNotificationsRead()
122
+ expect(out.ok).toBe(true)
123
+ if (!out.ok) return
124
+ expect(out.result).toBe(4)
125
+ expect(cmsApiMock).toHaveBeenCalledWith('/notifications/read-all', {
126
+ method: 'POST',
127
+ body: '{}',
128
+ })
129
+ })
130
+
131
+ it('propagates a server error', async () => {
132
+ cmsApiMock.mockResolvedValueOnce({ error: 'CSRF', status: 403 })
133
+ const out = await markAllNotificationsRead()
134
+ expect(out.ok).toBe(false)
135
+ })
136
+ })
137
+
138
+ describe('notificationSummary', () => {
139
+ it('formats comment_reply with the actor + excerpt', () => {
140
+ expect(
141
+ notificationSummary(
142
+ row({
143
+ payload: {
144
+ kind: 'comment_reply',
145
+ actorId: 'a',
146
+ actorName: 'Alice',
147
+ excerpt: 'good point',
148
+ },
149
+ }),
150
+ ),
151
+ ).toBe('Alice replied: "good point"')
152
+ })
153
+
154
+ it('formats comment_resolved with rootExcerpt', () => {
155
+ expect(
156
+ notificationSummary(
157
+ row({
158
+ kind: 'comment_resolved',
159
+ payload: {
160
+ kind: 'comment_resolved',
161
+ actorId: 'a',
162
+ actorName: 'Bob',
163
+ rootExcerpt: 'fix this',
164
+ },
165
+ }),
166
+ ),
167
+ ).toBe('Bob resolved your thread: "fix this"')
168
+ })
169
+
170
+ it('formats comment_mention with excerpt', () => {
171
+ expect(
172
+ notificationSummary(
173
+ row({
174
+ kind: 'comment_mention',
175
+ payload: {
176
+ kind: 'comment_mention',
177
+ actorId: 'a',
178
+ actorName: 'Carol',
179
+ excerpt: 'cc @you',
180
+ },
181
+ }),
182
+ ),
183
+ ).toBe('Carol mentioned you: "cc @you"')
184
+ })
185
+
186
+ it('falls back to "Someone" when actorName is null', () => {
187
+ expect(
188
+ notificationSummary(
189
+ row({
190
+ payload: { kind: 'comment_reply', actorId: null, actorName: null, excerpt: 'x' },
191
+ }),
192
+ ),
193
+ ).toMatch(/^Someone replied/)
194
+ })
195
+ })