@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,343 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it, beforeEach, vi } from 'vitest'
3
+ import React from 'react'
4
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
5
+
6
+ import { CommentSidePanel, type CommentsApi } from '../../components/CommentSidePanel.js'
7
+ import type { Comment, CommentOutcome } from '../../lib/comments-client.js'
8
+
9
+ function ok<T>(value: T): CommentOutcome<T> {
10
+ return { ok: true, result: value }
11
+ }
12
+
13
+ function err(error = 'boom', status = 500): CommentOutcome<never> {
14
+ return { ok: false, error, status }
15
+ }
16
+
17
+ function comment(overrides: Partial<Comment> = {}): Comment {
18
+ return {
19
+ id: 'c-1',
20
+ documentId: 'doc-1',
21
+ parentId: null,
22
+ userId: 'user-1',
23
+ body: 'Hello there',
24
+ anchor: null,
25
+ resolvedAt: null,
26
+ resolvedById: null,
27
+ deletedAt: null,
28
+ createdAt: '2026-01-01T00:00:00.000Z',
29
+ updatedAt: '2026-01-01T00:00:00.000Z',
30
+ isResolved: false,
31
+ ...overrides,
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Build a fresh in-memory CommentsApi stub for each test. We expose
37
+ * the `list` payload as `state.rows` so tests can poke server state
38
+ * directly and verify the panel re-fetches after every mutation.
39
+ */
40
+ function createFakeApi() {
41
+ const rows: Comment[] = []
42
+ const calls: { name: string; args: unknown[] }[] = []
43
+
44
+ const stub: CommentsApi = {
45
+ list: vi.fn(async (_documentId: string, opts?: { includeResolved?: boolean }) => {
46
+ calls.push({ name: 'list', args: [opts] })
47
+ const visible = rows.filter(
48
+ (c) => (opts?.includeResolved ? true : !c.isResolved) && c.deletedAt === null,
49
+ )
50
+ return ok(visible)
51
+ }),
52
+ create: vi.fn(async (input) => {
53
+ calls.push({ name: 'create', args: [input] })
54
+ const created = comment({
55
+ id: `c-${rows.length + 1}`,
56
+ body: input.body,
57
+ anchor: input.anchor ?? null,
58
+ parentId: input.parentId ?? null,
59
+ userId: 'current',
60
+ })
61
+ rows.push(created)
62
+ return ok(created)
63
+ }),
64
+ update: vi.fn(async (id, body) => {
65
+ calls.push({ name: 'update', args: [id, body] })
66
+ const row = rows.find((r) => r.id === id)
67
+ if (!row) return err('not found', 404)
68
+ row.body = body
69
+ return ok(row)
70
+ }),
71
+ resolve: vi.fn(async (id) => {
72
+ calls.push({ name: 'resolve', args: [id] })
73
+ const row = rows.find((r) => r.id === id)
74
+ if (!row) return err('not found', 404)
75
+ row.isResolved = true
76
+ row.resolvedAt = '2026-01-02T00:00:00.000Z'
77
+ return ok(row)
78
+ }),
79
+ reopen: vi.fn(async (id) => {
80
+ calls.push({ name: 'reopen', args: [id] })
81
+ const row = rows.find((r) => r.id === id)
82
+ if (!row) return err('not found', 404)
83
+ row.isResolved = false
84
+ row.resolvedAt = null
85
+ return ok(row)
86
+ }),
87
+ remove: vi.fn(async (id) => {
88
+ calls.push({ name: 'remove', args: [id] })
89
+ const row = rows.find((r) => r.id === id)
90
+ if (!row) return err('not found', 404)
91
+ row.deletedAt = '2026-01-02T00:00:00.000Z'
92
+ return ok(row)
93
+ }),
94
+ }
95
+
96
+ return { api: stub, rows, calls }
97
+ }
98
+
99
+ beforeEach(() => {
100
+ vi.clearAllMocks()
101
+ })
102
+
103
+ describe('CommentSidePanel — loading & list', () => {
104
+ it('renders an empty state once the list call resolves', async () => {
105
+ const { api } = createFakeApi()
106
+ render(<CommentSidePanel documentId="doc-1" currentUserId="current" api={api} />)
107
+ expect(screen.getByText('Loading…')).toBeTruthy()
108
+ await waitFor(() => expect(screen.getByText('No comments yet.')).toBeTruthy())
109
+ expect(api.list).toHaveBeenCalledWith('doc-1', { includeResolved: false })
110
+ })
111
+
112
+ it('groups roots + replies into a single card', async () => {
113
+ const { api, rows } = createFakeApi()
114
+ rows.push(comment({ id: 'root', body: 'root' }))
115
+ rows.push(
116
+ comment({
117
+ id: 'reply',
118
+ parentId: 'root',
119
+ body: 'reply',
120
+ createdAt: '2026-01-01T00:00:01.000Z',
121
+ }),
122
+ )
123
+ render(<CommentSidePanel documentId="doc-1" currentUserId="user-1" api={api} />)
124
+ await waitFor(() => expect(screen.getByTestId('comment-thread-root')).toBeTruthy())
125
+ expect(screen.getByTestId('comment-body-root').textContent).toBe('root')
126
+ expect(screen.getByTestId('comment-body-reply').textContent).toBe('reply')
127
+ })
128
+
129
+ it('hides resolved threads until the toggle is flipped', async () => {
130
+ const { api, rows } = createFakeApi()
131
+ rows.push(comment({ id: 'open', body: 'open' }))
132
+ rows.push(
133
+ comment({
134
+ id: 'closed',
135
+ body: 'closed',
136
+ isResolved: true,
137
+ resolvedAt: '2026-01-02T00:00:00.000Z',
138
+ }),
139
+ )
140
+ render(<CommentSidePanel documentId="doc-1" currentUserId="user-1" api={api} />)
141
+ await waitFor(() => expect(screen.getByTestId('comment-thread-open')).toBeTruthy())
142
+ expect(screen.queryByTestId('comment-thread-closed')).toBeNull()
143
+
144
+ fireEvent.click(screen.getByTestId('include-resolved-toggle'))
145
+ await waitFor(() => expect(screen.getByTestId('comment-thread-closed')).toBeTruthy())
146
+ // Most-recent list call should have flipped `includeResolved`.
147
+ expect(api.list).toHaveBeenLastCalledWith('doc-1', { includeResolved: true })
148
+ })
149
+
150
+ it('surfaces a server failure through onError', async () => {
151
+ const { api } = createFakeApi()
152
+ ;(api.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce(err('CSRF', 403))
153
+ const onError = vi.fn()
154
+ render(
155
+ <CommentSidePanel documentId="doc-1" currentUserId="user-1" api={api} onError={onError} />,
156
+ )
157
+ await waitFor(() => expect(onError).toHaveBeenCalledWith('CSRF'))
158
+ })
159
+ })
160
+
161
+ describe('CommentSidePanel — compose', () => {
162
+ it('disables the submit button while the draft is empty', async () => {
163
+ const { api } = createFakeApi()
164
+ render(<CommentSidePanel documentId="doc-1" currentUserId="user-1" api={api} />)
165
+ await waitFor(() => expect(screen.getByText('No comments yet.')).toBeTruthy())
166
+ const btn = screen.getByTestId('comment-submit') as HTMLButtonElement
167
+ expect(btn.disabled).toBe(true)
168
+ fireEvent.change(screen.getByTestId('comment-composer'), {
169
+ target: { value: 'hello' },
170
+ })
171
+ expect((screen.getByTestId('comment-submit') as HTMLButtonElement).disabled).toBe(false)
172
+ })
173
+
174
+ it('posts a new comment, clears the draft, and refreshes', async () => {
175
+ const { api, calls } = createFakeApi()
176
+ render(<CommentSidePanel documentId="doc-1" currentUserId="user-1" api={api} />)
177
+ await waitFor(() => expect(screen.getByText('No comments yet.')).toBeTruthy())
178
+
179
+ fireEvent.change(screen.getByTestId('comment-composer'), {
180
+ target: { value: ' fresh thought ' },
181
+ })
182
+ await act(async () => {
183
+ fireEvent.click(screen.getByTestId('comment-submit'))
184
+ })
185
+
186
+ expect(calls.some((c) => c.name === 'create')).toBe(true)
187
+ const createCall = calls.find((c) => c.name === 'create')!
188
+ expect((createCall.args[0] as { body: string }).body).toBe('fresh thought')
189
+ expect((screen.getByTestId('comment-composer') as HTMLTextAreaElement).value).toBe('')
190
+ expect(calls.filter((c) => c.name === 'list').length).toBeGreaterThanOrEqual(2)
191
+ })
192
+
193
+ it('forwards onComposeAnchor result into the create call', async () => {
194
+ const { api, calls } = createFakeApi()
195
+ const onComposeAnchor = vi.fn().mockReturnValue({ from: 'AQ==', to: 'Ag==' })
196
+ render(
197
+ <CommentSidePanel
198
+ documentId="doc-1"
199
+ currentUserId="user-1"
200
+ api={api}
201
+ onComposeAnchor={onComposeAnchor}
202
+ />,
203
+ )
204
+ await waitFor(() => expect(screen.getByText('No comments yet.')).toBeTruthy())
205
+ fireEvent.change(screen.getByTestId('comment-composer'), {
206
+ target: { value: 'anchored' },
207
+ })
208
+ await act(async () => {
209
+ fireEvent.click(screen.getByTestId('comment-submit'))
210
+ })
211
+
212
+ expect(onComposeAnchor).toHaveBeenCalledTimes(1)
213
+ const createCall = calls.find((c) => c.name === 'create')!
214
+ expect((createCall.args[0] as { anchor: unknown }).anchor).toEqual({
215
+ from: 'AQ==',
216
+ to: 'Ag==',
217
+ })
218
+ })
219
+ })
220
+
221
+ describe('CommentSidePanel — reply, edit, resolve, delete', () => {
222
+ async function setupWith(rowsInit: Comment[]) {
223
+ const fake = createFakeApi()
224
+ for (const row of rowsInit) fake.rows.push(row)
225
+ const renderResult = render(
226
+ <CommentSidePanel documentId="doc-1" currentUserId="user-1" isAdmin={false} api={fake.api} />,
227
+ )
228
+ await waitFor(() =>
229
+ expect(screen.getByTestId(`comment-thread-${rowsInit[0]!.id}`)).toBeTruthy(),
230
+ )
231
+ return { ...fake, ...renderResult }
232
+ }
233
+
234
+ it('replies through the thread input + clears it on submit', async () => {
235
+ const { api, calls } = await setupWith([
236
+ comment({ id: 'root', userId: 'user-1', body: 'root' }),
237
+ ])
238
+ fireEvent.change(screen.getByTestId('reply-input-root'), {
239
+ target: { value: 'hi back' },
240
+ })
241
+ await act(async () => {
242
+ fireEvent.click(screen.getByTestId('reply-submit-root'))
243
+ })
244
+
245
+ const replyCall = calls.find(
246
+ (c) => c.name === 'create' && (c.args[0] as { parentId?: string | null }).parentId === 'root',
247
+ )
248
+ expect(replyCall).toBeDefined()
249
+ expect((screen.getByTestId('reply-input-root') as HTMLInputElement).value).toBe('')
250
+ expect(api.list).toHaveBeenCalled()
251
+ })
252
+
253
+ it('shows edit + delete only on comments the user owns (non-admin)', async () => {
254
+ await setupWith([
255
+ comment({ id: 'mine', userId: 'user-1', body: 'mine' }),
256
+ comment({
257
+ id: 'theirs',
258
+ userId: 'someone-else',
259
+ body: 'theirs',
260
+ createdAt: '2026-01-01T00:00:01.000Z',
261
+ }),
262
+ ])
263
+ expect(screen.queryByTestId('edit-mine')).toBeTruthy()
264
+ expect(screen.queryByTestId('delete-mine')).toBeTruthy()
265
+ expect(screen.queryByTestId('edit-theirs')).toBeNull()
266
+ expect(screen.queryByTestId('delete-theirs')).toBeNull()
267
+ })
268
+
269
+ it('admins see edit + delete on every comment', async () => {
270
+ const fake = createFakeApi()
271
+ fake.rows.push(comment({ id: 'theirs', userId: 'someone-else' }))
272
+ render(<CommentSidePanel documentId="doc-1" currentUserId="user-1" isAdmin api={fake.api} />)
273
+ await waitFor(() => expect(screen.getByTestId('comment-thread-theirs')).toBeTruthy())
274
+ expect(screen.getByTestId('edit-theirs')).toBeTruthy()
275
+ expect(screen.getByTestId('delete-theirs')).toBeTruthy()
276
+ })
277
+
278
+ it('edits a comment body in place', async () => {
279
+ const { api } = await setupWith([comment({ id: 'c1', userId: 'user-1', body: 'before' })])
280
+ fireEvent.click(screen.getByTestId('edit-c1'))
281
+ fireEvent.change(screen.getByTestId('edit-textarea-c1'), {
282
+ target: { value: 'after' },
283
+ })
284
+ await act(async () => {
285
+ fireEvent.click(screen.getByTestId('edit-save-c1'))
286
+ })
287
+ expect(api.update).toHaveBeenCalledWith('c1', 'after')
288
+ await waitFor(() => expect(screen.getByTestId('comment-body-c1').textContent).toBe('after'))
289
+ })
290
+
291
+ it('cancels an edit without writing', async () => {
292
+ const { api } = await setupWith([comment({ id: 'c1', userId: 'user-1', body: 'before' })])
293
+ fireEvent.click(screen.getByTestId('edit-c1'))
294
+ fireEvent.change(screen.getByTestId('edit-textarea-c1'), {
295
+ target: { value: 'never saved' },
296
+ })
297
+ fireEvent.click(screen.getByTestId('edit-cancel-c1'))
298
+ expect(api.update).not.toHaveBeenCalled()
299
+ expect(screen.getByTestId('comment-body-c1').textContent).toBe('before')
300
+ })
301
+
302
+ it('resolves an open thread and reopens a closed one', async () => {
303
+ const { api, rows } = await setupWith([comment({ id: 'open', userId: 'user-1', body: 'open' })])
304
+ await act(async () => {
305
+ fireEvent.click(screen.getByTestId('resolve-open'))
306
+ })
307
+ expect(api.resolve).toHaveBeenCalledWith('open')
308
+ // Flip the toggle so resolved comments stay in the list, then reopen.
309
+ fireEvent.click(screen.getByTestId('include-resolved-toggle'))
310
+ await waitFor(() => expect(screen.getByTestId('resolve-open')).toBeTruthy())
311
+ await act(async () => {
312
+ fireEvent.click(screen.getByTestId('resolve-open'))
313
+ })
314
+ expect(api.reopen).toHaveBeenCalledWith('open')
315
+ expect(rows[0]!.isResolved).toBe(false)
316
+ })
317
+
318
+ it('deletes a comment and refreshes', async () => {
319
+ const { api } = await setupWith([comment({ id: 'c1', userId: 'user-1' })])
320
+ await act(async () => {
321
+ fireEvent.click(screen.getByTestId('delete-c1'))
322
+ })
323
+ expect(api.remove).toHaveBeenCalledWith('c1')
324
+ await waitFor(() => expect(screen.queryByTestId('comment-thread-c1')).toBeNull())
325
+ })
326
+
327
+ it('notifies onActiveCommentChange when a thread is clicked', async () => {
328
+ const fake = createFakeApi()
329
+ fake.rows.push(comment({ id: 'c1', userId: 'user-1' }))
330
+ const onActive = vi.fn()
331
+ render(
332
+ <CommentSidePanel
333
+ documentId="doc-1"
334
+ currentUserId="user-1"
335
+ api={fake.api}
336
+ onActiveCommentChange={onActive}
337
+ />,
338
+ )
339
+ await waitFor(() => expect(screen.getByTestId('comment-thread-c1')).toBeTruthy())
340
+ fireEvent.click(screen.getByTestId('comment-thread-c1'))
341
+ expect(onActive).toHaveBeenCalledWith('c1')
342
+ })
343
+ })
@@ -0,0 +1,213 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import React from 'react'
4
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
5
+
6
+ import { NotificationBell, type NotificationsApi } from '../../components/NotificationBell.js'
7
+ import type { Notification, NotificationOutcome } from '../../lib/notifications-client.js'
8
+
9
+ function ok<T>(value: T): NotificationOutcome<T> {
10
+ return { ok: true, result: value }
11
+ }
12
+
13
+ function err(error = 'boom', status = 500): NotificationOutcome<never> {
14
+ return { ok: false, error, status }
15
+ }
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
+ function createFakeApi() {
37
+ let rows: Notification[] = []
38
+ const calls: { name: string; args: unknown[] }[] = []
39
+ const api: NotificationsApi = {
40
+ list: vi.fn(async (opts) => {
41
+ calls.push({ name: 'list', args: [opts] })
42
+ return ok(opts.includeRead ? rows : rows.filter((r) => r.readAt === null))
43
+ }),
44
+ unread: vi.fn(async () => {
45
+ calls.push({ name: 'unread', args: [] })
46
+ return ok(rows.filter((r) => r.readAt === null).length)
47
+ }),
48
+ markRead: vi.fn(async (id) => {
49
+ calls.push({ name: 'markRead', args: [id] })
50
+ const target = rows.find((r) => r.id === id)
51
+ if (!target) return err('not found', 404)
52
+ target.readAt = '2026-01-02T00:00:00.000Z'
53
+ return ok(target)
54
+ }),
55
+ markAllRead: vi.fn(async () => {
56
+ calls.push({ name: 'markAllRead', args: [] })
57
+ let n = 0
58
+ for (const r of rows) {
59
+ if (r.readAt === null) {
60
+ r.readAt = '2026-01-02T00:00:00.000Z'
61
+ n++
62
+ }
63
+ }
64
+ return ok(n)
65
+ }),
66
+ }
67
+ return {
68
+ api,
69
+ calls,
70
+ setRows: (next: Notification[]) => {
71
+ rows = next
72
+ },
73
+ rows: () => rows,
74
+ }
75
+ }
76
+
77
+ describe('NotificationBell — badge', () => {
78
+ it('shows the unread badge from the unread() call', async () => {
79
+ const fake = createFakeApi()
80
+ fake.setRows([row(), row({ id: 'n-2' })])
81
+ render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
82
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('2'))
83
+ })
84
+
85
+ it('hides the badge when count is 0', async () => {
86
+ const fake = createFakeApi()
87
+ render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
88
+ // Let the initial unread() settle.
89
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
90
+ expect(screen.queryByTestId('bell-badge')).toBeNull()
91
+ })
92
+
93
+ it('clamps the badge at "99+"', async () => {
94
+ const fake = createFakeApi()
95
+ fake.setRows(Array.from({ length: 150 }, (_, i) => row({ id: `n-${i}` })))
96
+ render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
97
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('99+'))
98
+ })
99
+
100
+ it('polls unread() at pollIntervalMs', async () => {
101
+ vi.useFakeTimers({ shouldAdvanceTime: true })
102
+ try {
103
+ const fake = createFakeApi()
104
+ render(<NotificationBell api={fake.api} pollIntervalMs={5000} />)
105
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalledTimes(1))
106
+ await act(async () => {
107
+ vi.advanceTimersByTime(5000)
108
+ })
109
+ expect(fake.api.unread).toHaveBeenCalledTimes(2)
110
+ } finally {
111
+ vi.useRealTimers()
112
+ }
113
+ })
114
+ })
115
+
116
+ describe('NotificationBell — dropdown', () => {
117
+ it('shows the empty state when there are no rows', async () => {
118
+ const fake = createFakeApi()
119
+ render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
120
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
121
+ fireEvent.click(screen.getByTestId('bell-button'))
122
+ await waitFor(() => expect(screen.getByTestId('bell-dropdown')).toBeTruthy())
123
+ await waitFor(() => expect(screen.getByText('All caught up.')).toBeTruthy())
124
+ })
125
+
126
+ it('renders one button per row with the formatted summary', async () => {
127
+ const fake = createFakeApi()
128
+ fake.setRows([
129
+ row({
130
+ id: 'n-a',
131
+ payload: {
132
+ kind: 'comment_reply',
133
+ actorId: 'x',
134
+ actorName: 'Alice',
135
+ excerpt: 'good',
136
+ },
137
+ }),
138
+ row({
139
+ id: 'n-b',
140
+ kind: 'comment_resolved',
141
+ payload: {
142
+ kind: 'comment_resolved',
143
+ actorId: 'y',
144
+ actorName: 'Bob',
145
+ rootExcerpt: 'fix',
146
+ },
147
+ }),
148
+ ])
149
+ render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
150
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
151
+ fireEvent.click(screen.getByTestId('bell-button'))
152
+ await waitFor(() => expect(screen.getByTestId('bell-row-n-a')).toBeTruthy())
153
+ expect(screen.getByTestId('bell-row-n-a').textContent).toContain('Alice replied')
154
+ expect(screen.getByTestId('bell-row-n-b').textContent).toContain('Bob resolved')
155
+ })
156
+
157
+ it('marks a row read optimistically, then via the API', async () => {
158
+ const fake = createFakeApi()
159
+ const onSelect = vi.fn()
160
+ fake.setRows([row()])
161
+ render(<NotificationBell api={fake.api} pollIntervalMs={0} onSelect={onSelect} />)
162
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
163
+ fireEvent.click(screen.getByTestId('bell-button'))
164
+ await waitFor(() => expect(screen.getByTestId('bell-row-n-1')).toBeTruthy())
165
+ await act(async () => {
166
+ fireEvent.click(screen.getByTestId('bell-row-n-1'))
167
+ })
168
+ expect(fake.api.markRead).toHaveBeenCalledWith('n-1')
169
+ expect(onSelect).toHaveBeenCalledTimes(1)
170
+ expect(screen.queryByTestId('bell-badge')).toBeNull()
171
+ })
172
+
173
+ it('rolls back the optimistic update when markRead fails', async () => {
174
+ const fake = createFakeApi()
175
+ ;(fake.api.markRead as ReturnType<typeof vi.fn>).mockResolvedValueOnce(err('server', 500))
176
+ const onError = vi.fn()
177
+ fake.setRows([row()])
178
+ render(<NotificationBell api={fake.api} pollIntervalMs={0} onError={onError} />)
179
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
180
+ fireEvent.click(screen.getByTestId('bell-button'))
181
+ await waitFor(() => expect(screen.getByTestId('bell-row-n-1')).toBeTruthy())
182
+ await act(async () => {
183
+ fireEvent.click(screen.getByTestId('bell-row-n-1'))
184
+ })
185
+ expect(onError).toHaveBeenCalledWith('server')
186
+ // The badge should bounce back to 1.
187
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
188
+ })
189
+
190
+ it('marks all read in one click and disables the button after', async () => {
191
+ const fake = createFakeApi()
192
+ fake.setRows([row(), row({ id: 'n-2' })])
193
+ render(<NotificationBell api={fake.api} pollIntervalMs={0} />)
194
+ await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('2'))
195
+ fireEvent.click(screen.getByTestId('bell-button'))
196
+ await waitFor(() => expect(screen.getByTestId('bell-mark-all')).toBeTruthy())
197
+ await act(async () => {
198
+ fireEvent.click(screen.getByTestId('bell-mark-all'))
199
+ })
200
+ expect(fake.api.markAllRead).toHaveBeenCalled()
201
+ expect((screen.getByTestId('bell-mark-all') as HTMLButtonElement).disabled).toBe(true)
202
+ })
203
+
204
+ it('surfaces list() errors via onError', async () => {
205
+ const fake = createFakeApi()
206
+ ;(fake.api.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce(err('CSRF', 403))
207
+ const onError = vi.fn()
208
+ render(<NotificationBell api={fake.api} pollIntervalMs={0} onError={onError} />)
209
+ await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
210
+ fireEvent.click(screen.getByTestId('bell-button'))
211
+ await waitFor(() => expect(onError).toHaveBeenCalledWith('CSRF'))
212
+ })
213
+ })
@@ -0,0 +1,66 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from 'vitest'
3
+ import React from 'react'
4
+ import { render, screen } from '@testing-library/react'
5
+
6
+ import { OfflineStatus, describeOfflineStatus } from '../../components/OfflineStatus.js'
7
+
8
+ describe('describeOfflineStatus', () => {
9
+ it('error wins over everything', () => {
10
+ expect(describeOfflineStatus('connected', 'error').tone).toBe('bad')
11
+ expect(describeOfflineStatus('disconnected', 'error').tone).toBe('bad')
12
+ })
13
+
14
+ it('reports Synced when offline is unsupported but socket is connected', () => {
15
+ const out = describeOfflineStatus('connected', 'unsupported')
16
+ expect(out.tone).toBe('good')
17
+ expect(out.label).toBe('Synced')
18
+ })
19
+
20
+ it('warns when offline is unsupported and socket is disconnected', () => {
21
+ expect(describeOfflineStatus('disconnected', 'unsupported').tone).toBe('warn')
22
+ })
23
+
24
+ it('shows Connecting… when offline is unsupported and socket is connecting', () => {
25
+ expect(describeOfflineStatus('connecting', 'unsupported').label).toBe('Connecting…')
26
+ })
27
+
28
+ it('reports Restoring local copy… while IndexedDB is loading', () => {
29
+ expect(describeOfflineStatus('connecting', 'loading').label).toMatch(/Restoring/)
30
+ expect(describeOfflineStatus('connecting', 'pending').label).toMatch(/Restoring/)
31
+ })
32
+
33
+ it('reports Saved & synced when both ready', () => {
34
+ const out = describeOfflineStatus('connected', 'ready')
35
+ expect(out.tone).toBe('good')
36
+ expect(out.label).toBe('Saved & synced')
37
+ })
38
+
39
+ it('reports Saved locally when offline is ready but socket is disconnected', () => {
40
+ const out = describeOfflineStatus('disconnected', 'ready')
41
+ expect(out.tone).toBe('warn')
42
+ expect(out.label).toMatch(/Saved locally/)
43
+ })
44
+
45
+ it('reports Saved locally — reconnecting… while reconnecting', () => {
46
+ expect(describeOfflineStatus('connecting', 'ready').label).toMatch(/reconnecting/)
47
+ })
48
+ })
49
+
50
+ describe('<OfflineStatus />', () => {
51
+ it('renders the resolved label + tone', () => {
52
+ render(<OfflineStatus connection="connected" offline="ready" />)
53
+ const chip = screen.getByTestId('offline-status')
54
+ expect(chip.textContent).toContain('Saved & synced')
55
+ expect(chip.dataset.tone).toBe('good')
56
+ })
57
+
58
+ it('switches tone when conditions change', () => {
59
+ const { rerender } = render(<OfflineStatus connection="connected" offline="ready" />)
60
+ expect(screen.getByTestId('offline-status').dataset.tone).toBe('good')
61
+ rerender(<OfflineStatus connection="disconnected" offline="ready" />)
62
+ expect(screen.getByTestId('offline-status').dataset.tone).toBe('warn')
63
+ rerender(<OfflineStatus connection="connected" offline="error" />)
64
+ expect(screen.getByTestId('offline-status').dataset.tone).toBe('bad')
65
+ })
66
+ })
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { nameToInitials } from '../../components/PresenceChips.js'
4
+
5
+ describe('nameToInitials', () => {
6
+ it('returns the first letter of a single name', () => {
7
+ expect(nameToInitials('Alice')).toBe('A')
8
+ })
9
+
10
+ it('returns the first letter of two name parts', () => {
11
+ expect(nameToInitials('Alice Cooper')).toBe('AC')
12
+ })
13
+
14
+ it('caps at two letters for three+ part names', () => {
15
+ expect(nameToInitials('Mary Jane Watson')).toBe('MJ')
16
+ })
17
+
18
+ it('uppercases the result', () => {
19
+ expect(nameToInitials('alice cooper')).toBe('AC')
20
+ })
21
+
22
+ it('trims surrounding whitespace before processing', () => {
23
+ expect(nameToInitials(' Alice ')).toBe('A')
24
+ })
25
+
26
+ it('collapses multiple whitespace separators', () => {
27
+ expect(nameToInitials('Alice Cooper')).toBe('AC')
28
+ })
29
+
30
+ it('falls back to "?" for an empty string', () => {
31
+ expect(nameToInitials('')).toBe('?')
32
+ })
33
+
34
+ it('falls back to "?" for a whitespace-only string', () => {
35
+ expect(nameToInitials(' ')).toBe('?')
36
+ })
37
+
38
+ it('handles a single emoji name without crashing', () => {
39
+ const result = nameToInitials('🐱 Whiskers')
40
+ // JS upper-case is a no-op for the cat — test just confirms we don't crash
41
+ // and return *something* with two character-positions.
42
+ expect(result.length).toBeGreaterThan(0)
43
+ })
44
+ })