@actuate-media/cms-admin 0.12.1 → 0.14.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-side-panel.test.js +93 -0
- package/dist/__tests__/components/comment-side-panel.test.js.map +1 -1
- package/dist/__tests__/components/mentionable-textarea.test.d.ts +2 -0
- package/dist/__tests__/components/mentionable-textarea.test.d.ts.map +1 -0
- package/dist/__tests__/components/mentionable-textarea.test.js +190 -0
- package/dist/__tests__/components/mentionable-textarea.test.js.map +1 -0
- package/dist/__tests__/components/notification-bell.test.js +146 -0
- package/dist/__tests__/components/notification-bell.test.js.map +1 -1
- package/dist/__tests__/lib/mention-picker.test.d.ts +2 -0
- package/dist/__tests__/lib/mention-picker.test.d.ts.map +1 -0
- package/dist/__tests__/lib/mention-picker.test.js +122 -0
- package/dist/__tests__/lib/mention-picker.test.js.map +1 -0
- package/dist/__tests__/lib/notifications-client.test.js +129 -1
- package/dist/__tests__/lib/notifications-client.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/CommentSidePanel.d.ts +9 -1
- package/dist/components/CommentSidePanel.d.ts.map +1 -1
- package/dist/components/CommentSidePanel.js +8 -7
- package/dist/components/CommentSidePanel.js.map +1 -1
- package/dist/components/MentionableTextarea.d.ts +68 -0
- package/dist/components/MentionableTextarea.d.ts.map +1 -0
- package/dist/components/MentionableTextarea.js +181 -0
- package/dist/components/MentionableTextarea.js.map +1 -0
- package/dist/components/NotificationBell.d.ts +30 -9
- package/dist/components/NotificationBell.d.ts.map +1 -1
- package/dist/components/NotificationBell.js +57 -5
- package/dist/components/NotificationBell.js.map +1 -1
- 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/mention-picker.d.ts +69 -0
- package/dist/lib/mention-picker.d.ts.map +1 -0
- package/dist/lib/mention-picker.js +138 -0
- package/dist/lib/mention-picker.js.map +1 -0
- package/dist/lib/notifications-client.d.ts +53 -0
- package/dist/lib/notifications-client.d.ts.map +1 -1
- package/dist/lib/notifications-client.js +89 -1
- package/dist/lib/notifications-client.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/components/comment-side-panel.test.tsx +134 -0
- package/src/__tests__/components/mentionable-textarea.test.tsx +226 -0
- package/src/__tests__/components/notification-bell.test.tsx +167 -1
- package/src/__tests__/lib/mention-picker.test.ts +145 -0
- package/src/__tests__/lib/notifications-client.test.ts +145 -1
- package/src/components/CommentSidePanel.tsx +37 -14
- package/src/components/MentionableTextarea.tsx +328 -0
- package/src/components/NotificationBell.tsx +84 -12
- package/src/lib/api.ts +11 -0
- package/src/lib/mention-picker.ts +180 -0
- package/src/lib/notifications-client.ts +147 -1
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import React, { useState } from 'react'
|
|
4
|
+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
5
|
+
|
|
6
|
+
import { MentionableTextarea } from '../../components/MentionableTextarea.js'
|
|
7
|
+
import type { MentionUser } from '../../lib/mention-picker.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Mount the textarea against a state-managing host so we exercise the
|
|
11
|
+
* controlled-value contract. Returning the live `value` lets each
|
|
12
|
+
* test inspect what the parent ends up with after a selection.
|
|
13
|
+
*/
|
|
14
|
+
function Host({
|
|
15
|
+
initialValue = '',
|
|
16
|
+
search,
|
|
17
|
+
testid = 'ta',
|
|
18
|
+
}: {
|
|
19
|
+
initialValue?: string
|
|
20
|
+
search: (q: string) => Promise<MentionUser[]>
|
|
21
|
+
testid?: string
|
|
22
|
+
}) {
|
|
23
|
+
const [value, setValue] = useState(initialValue)
|
|
24
|
+
return (
|
|
25
|
+
<>
|
|
26
|
+
<MentionableTextarea value={value} onChange={setValue} search={search} data-testid={testid} />
|
|
27
|
+
<output data-testid="value">{value}</output>
|
|
28
|
+
</>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fakeSearch(rows: MentionUser[]) {
|
|
33
|
+
return vi.fn(async (q: string) => {
|
|
34
|
+
const ql = q.toLowerCase()
|
|
35
|
+
return rows.filter(
|
|
36
|
+
(u) => u.name.toLowerCase().includes(ql) || u.email.toLowerCase().includes(ql),
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ROWS: MentionUser[] = [
|
|
42
|
+
{ id: 'u_alice', name: 'Alice Anderson', email: 'alice@example.com' },
|
|
43
|
+
{ id: 'u_bob', name: 'Bob Brown', email: 'bob@example.com' },
|
|
44
|
+
{ id: 'u_carol', name: 'Carol Chen', email: 'carol@example.com' },
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
async function typeAt(testid: string, value: string, caret = value.length) {
|
|
48
|
+
const ta = screen.getByTestId(testid) as HTMLTextAreaElement
|
|
49
|
+
await act(async () => {
|
|
50
|
+
// Manually drive the textarea: testing-library's
|
|
51
|
+
// `fireEvent.change` resets `selectionStart` to 0 in happy-dom,
|
|
52
|
+
// which would defeat the mention picker (caret 0 → no mention).
|
|
53
|
+
// We bypass React's value-tracker the same way testing-library
|
|
54
|
+
// does, then set the caret BEFORE dispatching `input` so the
|
|
55
|
+
// React handler sees the correct selection.
|
|
56
|
+
ta.focus()
|
|
57
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
|
|
58
|
+
if (setter) setter.call(ta, value)
|
|
59
|
+
else ta.value = value
|
|
60
|
+
ta.setSelectionRange(caret, caret)
|
|
61
|
+
ta.dispatchEvent(new Event('input', { bubbles: true }))
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('MentionableTextarea — picker dropdown', () => {
|
|
66
|
+
it('does not show the picker before @ is typed', async () => {
|
|
67
|
+
const search = fakeSearch(ROWS)
|
|
68
|
+
render(<Host search={search} />)
|
|
69
|
+
await typeAt('ta', 'hello world')
|
|
70
|
+
expect(screen.queryByTestId('ta-picker')).toBeNull()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('opens the picker the instant @ is typed and shows the empty state', async () => {
|
|
74
|
+
const search = fakeSearch(ROWS)
|
|
75
|
+
render(<Host search={search} />)
|
|
76
|
+
await typeAt('ta', '@')
|
|
77
|
+
await waitFor(() => expect(screen.getByTestId('ta-picker')).toBeTruthy())
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('populates the dropdown with users that match the query', async () => {
|
|
81
|
+
const search = fakeSearch(ROWS)
|
|
82
|
+
render(<Host search={search} />)
|
|
83
|
+
await typeAt('ta', '@ali')
|
|
84
|
+
await waitFor(() => expect(screen.getByTestId('ta-picker-row-u_alice')).toBeTruthy())
|
|
85
|
+
// Bob/Carol should NOT match `ali`.
|
|
86
|
+
expect(screen.queryByTestId('ta-picker-row-u_bob')).toBeNull()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('shows "No matches" when the query has no hits', async () => {
|
|
90
|
+
const search = fakeSearch(ROWS)
|
|
91
|
+
render(<Host search={search} />)
|
|
92
|
+
await typeAt('ta', '@zzzz')
|
|
93
|
+
await waitFor(() => expect(screen.getByText(/No matches/i)).toBeTruthy())
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('closes the picker on Escape without inserting anything', async () => {
|
|
97
|
+
const search = fakeSearch(ROWS)
|
|
98
|
+
render(<Host search={search} />)
|
|
99
|
+
await typeAt('ta', '@al')
|
|
100
|
+
await waitFor(() => expect(screen.getByTestId('ta-picker')).toBeTruthy())
|
|
101
|
+
fireEvent.keyDown(screen.getByTestId('ta'), { key: 'Escape' })
|
|
102
|
+
expect(screen.queryByTestId('ta-picker')).toBeNull()
|
|
103
|
+
expect(screen.getByTestId('value').textContent).toBe('@al')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('inserts @<userId> + trailing space when a row is clicked', async () => {
|
|
107
|
+
const search = fakeSearch(ROWS)
|
|
108
|
+
render(<Host search={search} />)
|
|
109
|
+
await typeAt('ta', '@ali')
|
|
110
|
+
await waitFor(() => expect(screen.getByTestId('ta-picker-row-u_alice')).toBeTruthy())
|
|
111
|
+
await act(async () => {
|
|
112
|
+
// We use mousedown intentionally (the component listens for
|
|
113
|
+
// mousedown to avoid losing focus before commit).
|
|
114
|
+
fireEvent.mouseDown(screen.getByTestId('ta-picker-row-u_alice'))
|
|
115
|
+
})
|
|
116
|
+
expect(screen.getByTestId('value').textContent).toBe('@u_alice ')
|
|
117
|
+
expect(screen.queryByTestId('ta-picker')).toBeNull()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('Enter commits the highlighted row', async () => {
|
|
121
|
+
const search = fakeSearch(ROWS)
|
|
122
|
+
render(<Host search={search} />)
|
|
123
|
+
await typeAt('ta', '@b')
|
|
124
|
+
await waitFor(() => expect(screen.getByTestId('ta-picker-row-u_bob')).toBeTruthy())
|
|
125
|
+
await act(async () => {
|
|
126
|
+
fireEvent.keyDown(screen.getByTestId('ta'), { key: 'Enter' })
|
|
127
|
+
})
|
|
128
|
+
expect(screen.getByTestId('value').textContent).toBe('@u_bob ')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('ArrowDown then Enter commits the second result', async () => {
|
|
132
|
+
const search = fakeSearch(ROWS)
|
|
133
|
+
render(<Host search={search} />)
|
|
134
|
+
// Query 'e' hits Alice/Bob/Carol's emails — three matches.
|
|
135
|
+
await typeAt('ta', '@e')
|
|
136
|
+
await waitFor(() => expect(screen.getByTestId('ta-picker-row-u_alice')).toBeTruthy())
|
|
137
|
+
fireEvent.keyDown(screen.getByTestId('ta'), { key: 'ArrowDown' })
|
|
138
|
+
fireEvent.keyDown(screen.getByTestId('ta'), { key: 'ArrowDown' })
|
|
139
|
+
fireEvent.keyDown(screen.getByTestId('ta'), { key: 'ArrowUp' }) // back to row 2
|
|
140
|
+
await act(async () => {
|
|
141
|
+
fireEvent.keyDown(screen.getByTestId('ta'), { key: 'Enter' })
|
|
142
|
+
})
|
|
143
|
+
// Highlight after Down/Down/Up: 0 → 1 → 2 → 1. Row index 1 should
|
|
144
|
+
// be Bob (alphabetised by name? Actually fakeSearch returns in
|
|
145
|
+
// ROWS order — Alice, Bob, Carol). So commit = u_bob.
|
|
146
|
+
expect(screen.getByTestId('value').textContent).toBe('@u_bob ')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('keyboard does NOT swallow Enter when the picker is closed', async () => {
|
|
150
|
+
const search = fakeSearch(ROWS)
|
|
151
|
+
const onKeyDown = vi.fn()
|
|
152
|
+
const Wrapper = () => {
|
|
153
|
+
const [v, setV] = useState('')
|
|
154
|
+
return (
|
|
155
|
+
<MentionableTextarea
|
|
156
|
+
value={v}
|
|
157
|
+
onChange={setV}
|
|
158
|
+
search={search}
|
|
159
|
+
onKeyDown={onKeyDown}
|
|
160
|
+
data-testid="ta"
|
|
161
|
+
/>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
render(<Wrapper />)
|
|
165
|
+
fireEvent.keyDown(screen.getByTestId('ta'), { key: 'Enter' })
|
|
166
|
+
expect(onKeyDown).toHaveBeenCalledTimes(1)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('does NOT commit a stale row when Enter fires before a new query resolves (PR #159 review)', async () => {
|
|
170
|
+
// Regression for PR #159 Copilot finding: typing `@a`, getting
|
|
171
|
+
// Alice in the dropdown, then quickly typing `@bo` and hitting
|
|
172
|
+
// Enter before the `@bo` query resolves used to commit Alice
|
|
173
|
+
// (from the previous query) instead of doing nothing or
|
|
174
|
+
// committing Bob. The fix clears `users` on every new query so
|
|
175
|
+
// Enter has no row to commit until the new results land.
|
|
176
|
+
let resolveSlow: (rows: MentionUser[]) => void = () => undefined
|
|
177
|
+
const search = vi.fn((q: string) => {
|
|
178
|
+
if (q === 'bo') {
|
|
179
|
+
return new Promise<MentionUser[]>((resolve) => {
|
|
180
|
+
resolveSlow = resolve
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
return Promise.resolve(ROWS.filter((u) => u.name.toLowerCase().includes(q.toLowerCase())))
|
|
184
|
+
})
|
|
185
|
+
render(<Host search={search} />)
|
|
186
|
+
await typeAt('ta', '@a')
|
|
187
|
+
await waitFor(() => expect(screen.getByTestId('ta-picker-row-u_alice')).toBeTruthy())
|
|
188
|
+
// Now type the new query that hangs. Enter should NOT commit.
|
|
189
|
+
await typeAt('ta', '@bo')
|
|
190
|
+
fireEvent.keyDown(screen.getByTestId('ta'), { key: 'Enter' })
|
|
191
|
+
// Value should still be `@bo` — no token was inserted.
|
|
192
|
+
expect(screen.getByTestId('value').textContent).toBe('@bo')
|
|
193
|
+
// Resolve the hung query and confirm Bob is then committable.
|
|
194
|
+
await act(async () => {
|
|
195
|
+
resolveSlow(ROWS.filter((u) => u.name.toLowerCase().includes('bo')))
|
|
196
|
+
})
|
|
197
|
+
await waitFor(() => expect(screen.getByTestId('ta-picker-row-u_bob')).toBeTruthy())
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('drops stale responses when typing quickly (race protection)', async () => {
|
|
201
|
+
let resolveSlow: (rows: MentionUser[]) => void = () => undefined
|
|
202
|
+
const search = vi.fn((q: string) => {
|
|
203
|
+
if (q === 'a') {
|
|
204
|
+
return new Promise<MentionUser[]>((resolve) => {
|
|
205
|
+
resolveSlow = resolve
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
// Fast path for `ali`.
|
|
209
|
+
return Promise.resolve(ROWS.filter((u) => u.name.toLowerCase().includes(q.toLowerCase())))
|
|
210
|
+
})
|
|
211
|
+
render(<Host search={search} />)
|
|
212
|
+
// Trigger the slow request.
|
|
213
|
+
await typeAt('ta', '@a')
|
|
214
|
+
// Trigger the fast request which will resolve first.
|
|
215
|
+
await typeAt('ta', '@ali')
|
|
216
|
+
await waitFor(() => expect(screen.getByTestId('ta-picker-row-u_alice')).toBeTruthy())
|
|
217
|
+
// Now resolve the older request with the wrong rows — should NOT
|
|
218
|
+
// appear in the dropdown.
|
|
219
|
+
await act(async () => {
|
|
220
|
+
resolveSlow(ROWS)
|
|
221
|
+
})
|
|
222
|
+
// Carol should still be filtered out (the fast `ali` query is
|
|
223
|
+
// what won the race).
|
|
224
|
+
expect(screen.queryByTestId('ta-picker-row-u_carol')).toBeNull()
|
|
225
|
+
})
|
|
226
|
+
})
|
|
@@ -4,7 +4,12 @@ import React from 'react'
|
|
|
4
4
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
5
5
|
|
|
6
6
|
import { NotificationBell, type NotificationsApi } from '../../components/NotificationBell.js'
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
Notification,
|
|
9
|
+
NotificationOutcome,
|
|
10
|
+
NotificationStreamHandle,
|
|
11
|
+
NotificationStreamOptions,
|
|
12
|
+
} from '../../lib/notifications-client.js'
|
|
8
13
|
|
|
9
14
|
function ok<T>(value: T): NotificationOutcome<T> {
|
|
10
15
|
return { ok: true, result: value }
|
|
@@ -211,3 +216,164 @@ describe('NotificationBell — dropdown', () => {
|
|
|
211
216
|
await waitFor(() => expect(onError).toHaveBeenCalledWith('CSRF'))
|
|
212
217
|
})
|
|
213
218
|
})
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// SSE push channel (Phase 7) — verify the bell wires up subscribe(),
|
|
222
|
+
// processes incoming notifications, and tears down on unmount.
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
describe('NotificationBell — SSE push', () => {
|
|
225
|
+
function withSubscribe(api: NotificationsApi) {
|
|
226
|
+
let captured: NotificationStreamOptions | undefined
|
|
227
|
+
let closed = false
|
|
228
|
+
const handle: NotificationStreamHandle = {
|
|
229
|
+
close: () => {
|
|
230
|
+
closed = true
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
const subscribe = vi.fn((opts: NotificationStreamOptions) => {
|
|
234
|
+
captured = opts
|
|
235
|
+
return handle
|
|
236
|
+
})
|
|
237
|
+
return {
|
|
238
|
+
api: { ...api, subscribe },
|
|
239
|
+
push: (n: Notification) => captured?.onNotification(n),
|
|
240
|
+
pushError: (e: Event) => captured?.onError?.(e),
|
|
241
|
+
isClosed: () => closed,
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
it('subscribes on mount and bumps the badge when a new notification arrives', async () => {
|
|
246
|
+
const fake = createFakeApi()
|
|
247
|
+
const wired = withSubscribe(fake.api)
|
|
248
|
+
render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
|
|
249
|
+
await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
|
|
250
|
+
await act(async () => {
|
|
251
|
+
wired.push(row({ id: 'sse-1' }))
|
|
252
|
+
})
|
|
253
|
+
await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('does not double-count a notification that arrives via both SSE and REST', async () => {
|
|
257
|
+
const fake = createFakeApi()
|
|
258
|
+
const wired = withSubscribe(fake.api)
|
|
259
|
+
fake.setRows([row({ id: 'race' })])
|
|
260
|
+
render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
|
|
261
|
+
// Open the dropdown so the row is in `items` from the REST list.
|
|
262
|
+
await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
|
|
263
|
+
fireEvent.click(screen.getByTestId('bell-button'))
|
|
264
|
+
await waitFor(() => expect(screen.getByTestId('bell-row-race')).toBeTruthy())
|
|
265
|
+
// Now push the same row via SSE — must NOT add a duplicate or
|
|
266
|
+
// bump the badge above 1.
|
|
267
|
+
await act(async () => {
|
|
268
|
+
wired.push(row({ id: 'race' }))
|
|
269
|
+
})
|
|
270
|
+
expect(screen.getAllByTestId('bell-row-race')).toHaveLength(1)
|
|
271
|
+
expect(screen.getByTestId('bell-badge').textContent).toBe('1')
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('prepends new pushed rows to the open dropdown', async () => {
|
|
275
|
+
const fake = createFakeApi()
|
|
276
|
+
const wired = withSubscribe(fake.api)
|
|
277
|
+
fake.setRows([row({ id: 'old' })])
|
|
278
|
+
render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
|
|
279
|
+
await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
|
|
280
|
+
fireEvent.click(screen.getByTestId('bell-button'))
|
|
281
|
+
await waitFor(() => expect(screen.getByTestId('bell-row-old')).toBeTruthy())
|
|
282
|
+
await act(async () => {
|
|
283
|
+
wired.push(row({ id: 'fresh' }))
|
|
284
|
+
})
|
|
285
|
+
const rows = screen.getAllByTestId(/^bell-row-/)
|
|
286
|
+
expect(rows[0]!.getAttribute('data-testid')).toBe('bell-row-fresh')
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('does NOT bump the badge when the pushed notification is already read', async () => {
|
|
290
|
+
const fake = createFakeApi()
|
|
291
|
+
const wired = withSubscribe(fake.api)
|
|
292
|
+
render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
|
|
293
|
+
await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
|
|
294
|
+
await act(async () => {
|
|
295
|
+
wired.push(row({ id: 'already-read', readAt: '2026-01-02T00:00:00.000Z' }))
|
|
296
|
+
})
|
|
297
|
+
// Already-read notifications still appear in the list when the
|
|
298
|
+
// user opens the dropdown with includeRead=true, but they must
|
|
299
|
+
// NOT register a fresh unread bump.
|
|
300
|
+
expect(screen.queryByTestId('bell-badge')).toBeNull()
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('closes the SSE subscription when the bell unmounts', async () => {
|
|
304
|
+
const fake = createFakeApi()
|
|
305
|
+
const wired = withSubscribe(fake.api)
|
|
306
|
+
const { unmount } = render(<NotificationBell api={wired.api} pollIntervalMs={0} />)
|
|
307
|
+
await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
|
|
308
|
+
unmount()
|
|
309
|
+
expect(wired.isClosed()).toBe(true)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('does not surface transient SSE errors via onError (poll loop handles convergence)', async () => {
|
|
313
|
+
const fake = createFakeApi()
|
|
314
|
+
const wired = withSubscribe(fake.api)
|
|
315
|
+
const onError = vi.fn()
|
|
316
|
+
render(<NotificationBell api={wired.api} pollIntervalMs={0} onError={onError} />)
|
|
317
|
+
await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
|
|
318
|
+
await act(async () => {
|
|
319
|
+
wired.pushError(new Event('error'))
|
|
320
|
+
})
|
|
321
|
+
// Deliberately quiet — see NotificationBell SSE handler comment.
|
|
322
|
+
expect(onError).not.toHaveBeenCalled()
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('mark-all-read failure does not drop SSE rows that arrived mid-flight', async () => {
|
|
326
|
+
const fake = createFakeApi()
|
|
327
|
+
const wired = withSubscribe(fake.api)
|
|
328
|
+
fake.setRows([row({ id: 'before' })])
|
|
329
|
+
// Hold the markAllRead promise so we can interleave an SSE push.
|
|
330
|
+
let resolveMark: (v: NotificationOutcome<number>) => void = () => undefined
|
|
331
|
+
;(wired.api.markAllRead as ReturnType<typeof vi.fn>).mockImplementationOnce(
|
|
332
|
+
() =>
|
|
333
|
+
new Promise((resolve) => {
|
|
334
|
+
resolveMark = resolve
|
|
335
|
+
}),
|
|
336
|
+
)
|
|
337
|
+
const onError = vi.fn()
|
|
338
|
+
render(<NotificationBell api={wired.api} pollIntervalMs={0} onError={onError} />)
|
|
339
|
+
await waitFor(() => expect(screen.getByTestId('bell-badge').textContent).toBe('1'))
|
|
340
|
+
fireEvent.click(screen.getByTestId('bell-button'))
|
|
341
|
+
await waitFor(() => expect(screen.getByTestId('bell-row-before')).toBeTruthy())
|
|
342
|
+
|
|
343
|
+
// Click mark-all — handler optimistically marks 'before' read,
|
|
344
|
+
// but the API promise is still pending.
|
|
345
|
+
await act(async () => {
|
|
346
|
+
fireEvent.click(screen.getByTestId('bell-mark-all'))
|
|
347
|
+
})
|
|
348
|
+
// While in flight, a fresh notification arrives via SSE.
|
|
349
|
+
await act(async () => {
|
|
350
|
+
wired.push(row({ id: 'mid-flight' }))
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
// Now resolve the API with a failure.
|
|
354
|
+
await act(async () => {
|
|
355
|
+
resolveMark({ ok: false, error: 'server', status: 500 })
|
|
356
|
+
// flush promise chain
|
|
357
|
+
await Promise.resolve()
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
expect(onError).toHaveBeenCalledWith('server')
|
|
361
|
+
// The original row was rolled back to unread...
|
|
362
|
+
expect(screen.getByTestId('bell-row-before').getAttribute('data-read')).toBe('false')
|
|
363
|
+
// ...and the SSE arrival is still present (not dropped by the
|
|
364
|
+
// legacy snapshot-replace rollback).
|
|
365
|
+
expect(screen.getByTestId('bell-row-mid-flight')).toBeTruthy()
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('still mounts cleanly when api.subscribe is undefined (no SSE support)', async () => {
|
|
369
|
+
const fake = createFakeApi()
|
|
370
|
+
// Strip the `subscribe` member entirely — defaultApi has it,
|
|
371
|
+
// but consumer-provided test APIs may not.
|
|
372
|
+
const apiWithoutSubscribe: NotificationsApi = { ...fake.api }
|
|
373
|
+
delete (apiWithoutSubscribe as { subscribe?: unknown }).subscribe
|
|
374
|
+
render(<NotificationBell api={apiWithoutSubscribe} pollIntervalMs={0} />)
|
|
375
|
+
await waitFor(() => expect(fake.api.unread).toHaveBeenCalled())
|
|
376
|
+
// No crash, badge from unread() shows nothing, no items.
|
|
377
|
+
expect(screen.queryByTestId('bell-badge')).toBeNull()
|
|
378
|
+
})
|
|
379
|
+
})
|
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
getApiBase: () => '/api/cms',
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
const { detectMentionContext, insertMentionToken, searchMentionUsers } =
|
|
10
|
+
await import('../../lib/mention-picker.js')
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
cmsApiMock.mockReset()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('detectMentionContext', () => {
|
|
17
|
+
it('returns null when there is no @ before the caret', () => {
|
|
18
|
+
expect(detectMentionContext('hello world', 5)).toBeNull()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('matches a fresh @ at the start of the field', () => {
|
|
22
|
+
expect(detectMentionContext('@ali', 4)).toEqual({ start: 0, end: 4, query: 'ali' })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('matches @ after a leading space', () => {
|
|
26
|
+
expect(detectMentionContext('hey @bob', 8)).toEqual({ start: 4, end: 8, query: 'bob' })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('opens immediately with an empty query the moment @ is typed', () => {
|
|
30
|
+
expect(detectMentionContext('@', 1)).toEqual({ start: 0, end: 1, query: '' })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('does NOT open mid-word for email-like input (`foo@bar`)', () => {
|
|
34
|
+
expect(detectMentionContext('foo@bar', 7)).toBeNull()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('terminates on a whitespace character between @ and caret', () => {
|
|
38
|
+
expect(detectMentionContext('@ali ce', 7)).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('terminates on a non-mention punctuation character', () => {
|
|
42
|
+
expect(detectMentionContext('@ali.ce', 7)).toBeNull()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('supports underscores and hyphens (id-shape charset)', () => {
|
|
46
|
+
expect(detectMentionContext('@user_id-42', 11)).toEqual({
|
|
47
|
+
start: 0,
|
|
48
|
+
end: 11,
|
|
49
|
+
query: 'user_id-42',
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('matches when the caret is mid-query (not at end of value)', () => {
|
|
54
|
+
// value: "@ali rest", caret after the `i`
|
|
55
|
+
expect(detectMentionContext('@ali rest', 4)).toEqual({ start: 0, end: 4, query: 'ali' })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('handles newline as a boundary', () => {
|
|
59
|
+
expect(detectMentionContext('line\n@bob', 9)).toEqual({ start: 5, end: 9, query: 'bob' })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('returns null when the caret is at offset 0', () => {
|
|
63
|
+
expect(detectMentionContext('@ali', 0)).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('returns null when the caret is past the end of value', () => {
|
|
67
|
+
expect(detectMentionContext('@ali', 99)).toBeNull()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('rejects queries that exceed the 64-char server cap', () => {
|
|
71
|
+
const long = 'a'.repeat(65)
|
|
72
|
+
expect(detectMentionContext(`@${long}`, 1 + long.length)).toBeNull()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('returns null for non-string input (defensive)', () => {
|
|
76
|
+
// @ts-expect-error — runtime guard against caller misuse
|
|
77
|
+
expect(detectMentionContext(undefined, 0)).toBeNull()
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('insertMentionToken', () => {
|
|
82
|
+
it('replaces the @<query> slice with @<userId> + trailing space', () => {
|
|
83
|
+
const ctx = { start: 0, end: 4, query: 'ali' }
|
|
84
|
+
expect(insertMentionToken('@ali rest', ctx, 'user_123')).toEqual({
|
|
85
|
+
value: '@user_123 rest',
|
|
86
|
+
caret: '@user_123 '.length,
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('does not duplicate a trailing space when one is already present', () => {
|
|
91
|
+
const ctx = { start: 0, end: 4, query: 'ali' }
|
|
92
|
+
const out = insertMentionToken('@ali extra', ctx, 'u1')
|
|
93
|
+
expect(out.value).toBe('@u1 extra')
|
|
94
|
+
expect(out.value.slice(out.caret)).toBe('extra')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('preserves text before the @ token', () => {
|
|
98
|
+
const ctx = { start: 4, end: 8, query: 'bob' }
|
|
99
|
+
expect(insertMentionToken('hey @bob', ctx, 'u_99').value).toBe('hey @u_99 ')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('places the caret immediately past the inserted token + space', () => {
|
|
103
|
+
const ctx = { start: 0, end: 1, query: '' }
|
|
104
|
+
const out = insertMentionToken('@', ctx, 'u_xyz')
|
|
105
|
+
expect(out.value).toBe('@u_xyz ')
|
|
106
|
+
expect(out.caret).toBe(out.value.length)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('searchMentionUsers', () => {
|
|
111
|
+
it('short-circuits an empty query without hitting the wire', async () => {
|
|
112
|
+
const out = await searchMentionUsers('')
|
|
113
|
+
expect(out).toEqual({ ok: true, result: [] })
|
|
114
|
+
expect(cmsApiMock).not.toHaveBeenCalled()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('short-circuits a whitespace-only query', async () => {
|
|
118
|
+
const out = await searchMentionUsers(' ')
|
|
119
|
+
expect(out).toEqual({ ok: true, result: [] })
|
|
120
|
+
expect(cmsApiMock).not.toHaveBeenCalled()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('URL-encodes the query and forwards the result array', async () => {
|
|
124
|
+
cmsApiMock.mockResolvedValueOnce({
|
|
125
|
+
data: [{ id: 'u-1', name: 'Alice', email: 'a@x.com' }],
|
|
126
|
+
status: 200,
|
|
127
|
+
})
|
|
128
|
+
const out = await searchMentionUsers('a li ce')
|
|
129
|
+
expect(cmsApiMock).toHaveBeenCalledWith(`/users/search?q=${encodeURIComponent('a li ce')}`, {
|
|
130
|
+
method: 'GET',
|
|
131
|
+
})
|
|
132
|
+
expect(out.ok).toBe(true)
|
|
133
|
+
if (!out.ok) return
|
|
134
|
+
expect(out.result.map((u) => u.id)).toEqual(['u-1'])
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('propagates server errors as { ok: false }', async () => {
|
|
138
|
+
cmsApiMock.mockResolvedValueOnce({ error: 'CSRF', status: 403 })
|
|
139
|
+
const out = await searchMentionUsers('ali')
|
|
140
|
+
expect(out.ok).toBe(false)
|
|
141
|
+
if (out.ok) return
|
|
142
|
+
expect(out.error).toBe('CSRF')
|
|
143
|
+
expect(out.status).toBe(403)
|
|
144
|
+
})
|
|
145
|
+
})
|