@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
|
@@ -3,6 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
3
3
|
const cmsApiMock = vi.fn()
|
|
4
4
|
vi.mock('../../lib/api.js', () => ({
|
|
5
5
|
cmsApi: (...args: unknown[]) => cmsApiMock(...args),
|
|
6
|
+
// SSE uses EventSource, which doesn't go through cmsApi. The
|
|
7
|
+
// helper resolves the URL via getApiBase() — return the production
|
|
8
|
+
// default so tests don't need to know the prefix.
|
|
9
|
+
getApiBase: () => '/api/cms',
|
|
6
10
|
}))
|
|
7
11
|
|
|
8
12
|
const {
|
|
@@ -10,9 +14,14 @@ const {
|
|
|
10
14
|
markAllNotificationsRead,
|
|
11
15
|
markNotificationRead,
|
|
12
16
|
notificationSummary,
|
|
17
|
+
subscribeNotificationStream,
|
|
13
18
|
unreadCount,
|
|
14
19
|
} = await import('../../lib/notifications-client.js')
|
|
15
|
-
import type {
|
|
20
|
+
import type {
|
|
21
|
+
EventSourceLike,
|
|
22
|
+
Notification,
|
|
23
|
+
NotificationStreamOptions,
|
|
24
|
+
} from '../../lib/notifications-client.js'
|
|
16
25
|
|
|
17
26
|
function row(overrides: Partial<Notification> = {}): Notification {
|
|
18
27
|
return {
|
|
@@ -193,3 +202,138 @@ describe('notificationSummary', () => {
|
|
|
193
202
|
).toMatch(/^Someone replied/)
|
|
194
203
|
})
|
|
195
204
|
})
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// SSE push channel — subscribeNotificationStream
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* In-memory `EventSource` double. Tests drive the wire by invoking
|
|
212
|
+
* `emit(event, payload)`; the subscriber dispatches the right
|
|
213
|
+
* listener. We deliberately keep the shape minimal — `EventSource`
|
|
214
|
+
* has dozens of properties our admin doesn't touch.
|
|
215
|
+
*/
|
|
216
|
+
function makeFakeSource() {
|
|
217
|
+
const listeners = new Map<string, Set<(e: MessageEvent | Event) => void>>()
|
|
218
|
+
let closed = false
|
|
219
|
+
const source: EventSourceLike = {
|
|
220
|
+
addEventListener: (type, listener) => {
|
|
221
|
+
if (closed) return
|
|
222
|
+
let set = listeners.get(type)
|
|
223
|
+
if (!set) {
|
|
224
|
+
set = new Set()
|
|
225
|
+
listeners.set(type, set)
|
|
226
|
+
}
|
|
227
|
+
set.add(listener)
|
|
228
|
+
},
|
|
229
|
+
removeEventListener: (type, listener) => {
|
|
230
|
+
listeners.get(type)?.delete(listener)
|
|
231
|
+
},
|
|
232
|
+
close: () => {
|
|
233
|
+
closed = true
|
|
234
|
+
listeners.clear()
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
source,
|
|
239
|
+
listenerCount: (type: string) => listeners.get(type)?.size ?? 0,
|
|
240
|
+
isClosed: () => closed,
|
|
241
|
+
emit(type: string, payload: unknown) {
|
|
242
|
+
const set = listeners.get(type)
|
|
243
|
+
if (!set) return
|
|
244
|
+
const event =
|
|
245
|
+
typeof payload === 'string'
|
|
246
|
+
? ({ data: payload } as MessageEvent)
|
|
247
|
+
: payload === undefined
|
|
248
|
+
? (new Event(type) as Event)
|
|
249
|
+
: ({ data: JSON.stringify(payload) } as MessageEvent)
|
|
250
|
+
for (const l of [...set]) l(event)
|
|
251
|
+
},
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function startStream(
|
|
256
|
+
fake: ReturnType<typeof makeFakeSource>,
|
|
257
|
+
overrides: Partial<NotificationStreamOptions> = {},
|
|
258
|
+
) {
|
|
259
|
+
return subscribeNotificationStream({
|
|
260
|
+
onNotification: () => undefined,
|
|
261
|
+
factory: () => fake.source,
|
|
262
|
+
...overrides,
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
describe('subscribeNotificationStream', () => {
|
|
267
|
+
it('dispatches `notification` frames as Notification objects', () => {
|
|
268
|
+
const fake = makeFakeSource()
|
|
269
|
+
const seen: Notification[] = []
|
|
270
|
+
startStream(fake, { onNotification: (n) => seen.push(n) })
|
|
271
|
+
fake.emit('notification', row({ id: 'sse-1' }))
|
|
272
|
+
expect(seen.map((n) => n.id)).toEqual(['sse-1'])
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('dispatches `ready` frames once via onReady', () => {
|
|
276
|
+
const fake = makeFakeSource()
|
|
277
|
+
const readys: Array<{ userId: string }> = []
|
|
278
|
+
startStream(fake, { onReady: (p) => readys.push(p) })
|
|
279
|
+
fake.emit('ready', { userId: 'user-1' })
|
|
280
|
+
expect(readys).toEqual([{ userId: 'user-1' }])
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('ignores malformed payloads (defence against wire drift)', () => {
|
|
284
|
+
const fake = makeFakeSource()
|
|
285
|
+
const seen: Notification[] = []
|
|
286
|
+
startStream(fake, { onNotification: (n) => seen.push(n) })
|
|
287
|
+
// Missing id + payload — must not throw, must not deliver.
|
|
288
|
+
fake.emit('notification', { userId: 'user-1', createdAt: 'now' })
|
|
289
|
+
fake.emit('notification', 'not json')
|
|
290
|
+
expect(seen).toEqual([])
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('removes its listeners and closes the underlying source on close()', () => {
|
|
294
|
+
const fake = makeFakeSource()
|
|
295
|
+
const handle = startStream(fake)
|
|
296
|
+
expect(fake.listenerCount('notification')).toBe(1)
|
|
297
|
+
expect(fake.listenerCount('ready')).toBe(1)
|
|
298
|
+
expect(fake.listenerCount('error')).toBe(1)
|
|
299
|
+
handle.close()
|
|
300
|
+
expect(fake.isClosed()).toBe(true)
|
|
301
|
+
// Second close() is a no-op — guards against double-unmount loops.
|
|
302
|
+
handle.close()
|
|
303
|
+
expect(fake.isClosed()).toBe(true)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('returns a no-op handle when no EventSource is available', () => {
|
|
307
|
+
// No factory + no global EventSource (vitest jsdom env doesn't
|
|
308
|
+
// ship one). The subscriber must NOT throw — admin code must
|
|
309
|
+
// remain mountable in SSR / Node environments.
|
|
310
|
+
const handle = subscribeNotificationStream({
|
|
311
|
+
onNotification: () => undefined,
|
|
312
|
+
factory: () => null,
|
|
313
|
+
})
|
|
314
|
+
expect(() => handle.close()).not.toThrow()
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('forwards error events to onError without crashing the page', () => {
|
|
318
|
+
const fake = makeFakeSource()
|
|
319
|
+
const errors: Event[] = []
|
|
320
|
+
startStream(fake, { onError: (e) => errors.push(e) })
|
|
321
|
+
fake.emit('error', undefined)
|
|
322
|
+
expect(errors).toHaveLength(1)
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('builds the stream URL from getApiBase() so consumers with a custom prefix connect to the right host', () => {
|
|
326
|
+
// Custom factory inspects the URL it's handed. Verifies the
|
|
327
|
+
// subscriber composes /<base>/notifications/stream rather than
|
|
328
|
+
// hardcoding the production prefix.
|
|
329
|
+
const seen: string[] = []
|
|
330
|
+
subscribeNotificationStream({
|
|
331
|
+
onNotification: () => undefined,
|
|
332
|
+
factory: (url) => {
|
|
333
|
+
seen.push(url)
|
|
334
|
+
return null
|
|
335
|
+
},
|
|
336
|
+
})
|
|
337
|
+
expect(seen).toEqual(['/api/cms/notifications/stream'])
|
|
338
|
+
})
|
|
339
|
+
})
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
type CommentThread,
|
|
16
16
|
type CommentOutcome,
|
|
17
17
|
} from '../lib/comments-client.js'
|
|
18
|
+
import type { MentionUser } from '../lib/mention-picker.js'
|
|
19
|
+
import { MentionableTextarea } from './MentionableTextarea.js'
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* Side-panel view of the slice-4 comments API. Renders threaded comments
|
|
@@ -69,6 +71,13 @@ export interface CommentSidePanelProps {
|
|
|
69
71
|
* should leave this unset.
|
|
70
72
|
*/
|
|
71
73
|
api?: CommentsApi
|
|
74
|
+
/**
|
|
75
|
+
* Override the mention search — tests pass a synchronous fake so
|
|
76
|
+
* the picker is deterministic without HTTP mocking. Production
|
|
77
|
+
* callers leave this unset and the textarea uses the real
|
|
78
|
+
* `/users/search` REST helper.
|
|
79
|
+
*/
|
|
80
|
+
mentionSearch?: (query: string) => Promise<MentionUser[]>
|
|
72
81
|
className?: string
|
|
73
82
|
}
|
|
74
83
|
|
|
@@ -111,6 +120,7 @@ export function CommentSidePanel({
|
|
|
111
120
|
onActiveCommentChange,
|
|
112
121
|
onError,
|
|
113
122
|
api = defaultApi,
|
|
123
|
+
mentionSearch,
|
|
114
124
|
className,
|
|
115
125
|
}: CommentSidePanelProps): React.JSX.Element {
|
|
116
126
|
const [comments, setComments] = useState<Comment[]>([])
|
|
@@ -236,13 +246,14 @@ export function CommentSidePanel({
|
|
|
236
246
|
</header>
|
|
237
247
|
|
|
238
248
|
<section className="flex flex-col gap-1.5">
|
|
239
|
-
<
|
|
249
|
+
<MentionableTextarea
|
|
240
250
|
className="w-full resize-none rounded border border-gray-300 p-2 text-sm focus:border-blue-500 focus:outline-none"
|
|
241
251
|
rows={3}
|
|
242
|
-
placeholder="Write a comment…"
|
|
252
|
+
placeholder="Write a comment… Type @ to mention a teammate."
|
|
243
253
|
value={draft}
|
|
244
|
-
onChange={
|
|
254
|
+
onChange={setDraft}
|
|
245
255
|
data-testid="comment-composer"
|
|
256
|
+
search={mentionSearch}
|
|
246
257
|
/>
|
|
247
258
|
<div className="flex justify-end">
|
|
248
259
|
<button
|
|
@@ -276,6 +287,7 @@ export function CommentSidePanel({
|
|
|
276
287
|
onEdit={(id, body) => handleEdit(id, body)}
|
|
277
288
|
onToggleResolve={(id, alreadyResolved) => handleResolve(id, alreadyResolved)}
|
|
278
289
|
onDelete={(id) => handleDelete(id)}
|
|
290
|
+
mentionSearch={mentionSearch}
|
|
279
291
|
/>
|
|
280
292
|
))}
|
|
281
293
|
</ul>
|
|
@@ -295,6 +307,7 @@ interface CommentThreadViewProps {
|
|
|
295
307
|
onEdit: (id: string, body: string) => Promise<boolean>
|
|
296
308
|
onToggleResolve: (id: string, alreadyResolved: boolean) => void
|
|
297
309
|
onDelete: (id: string) => void
|
|
310
|
+
mentionSearch?: (query: string) => Promise<MentionUser[]>
|
|
298
311
|
}
|
|
299
312
|
|
|
300
313
|
function CommentThreadView({
|
|
@@ -307,6 +320,7 @@ function CommentThreadView({
|
|
|
307
320
|
onEdit,
|
|
308
321
|
onToggleResolve,
|
|
309
322
|
onDelete,
|
|
323
|
+
mentionSearch,
|
|
310
324
|
}: CommentThreadViewProps): React.JSX.Element {
|
|
311
325
|
const [replyDraft, setReplyDraft] = useState('')
|
|
312
326
|
const cardClass = `rounded border bg-gray-50 p-2 ${
|
|
@@ -327,6 +341,7 @@ function CommentThreadView({
|
|
|
327
341
|
onEdit={onEdit}
|
|
328
342
|
onToggleResolve={() => onToggleResolve(thread.root.id, thread.root.isResolved)}
|
|
329
343
|
onDelete={() => onDelete(thread.root.id)}
|
|
344
|
+
mentionSearch={mentionSearch}
|
|
330
345
|
/>
|
|
331
346
|
{thread.replies.length > 0 && (
|
|
332
347
|
<ul className="mt-2 flex flex-col gap-1 border-l-2 border-gray-200 pl-2">
|
|
@@ -341,20 +356,25 @@ function CommentThreadView({
|
|
|
341
356
|
// expose delete here.
|
|
342
357
|
onToggleResolve={null}
|
|
343
358
|
onDelete={() => onDelete(reply.id)}
|
|
359
|
+
mentionSearch={mentionSearch}
|
|
344
360
|
/>
|
|
345
361
|
</li>
|
|
346
362
|
))}
|
|
347
363
|
</ul>
|
|
348
364
|
)}
|
|
349
|
-
<div className="mt-2 flex gap-1">
|
|
350
|
-
<
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
365
|
+
<div className="mt-2 flex items-start gap-1">
|
|
366
|
+
<div className="flex-1">
|
|
367
|
+
<MentionableTextarea
|
|
368
|
+
className="w-full resize-none rounded border border-gray-300 px-2 py-1 text-xs focus:border-blue-500 focus:outline-none"
|
|
369
|
+
placeholder="Reply… Type @ to mention."
|
|
370
|
+
rows={1}
|
|
371
|
+
value={replyDraft}
|
|
372
|
+
onChange={setReplyDraft}
|
|
373
|
+
data-testid={`reply-input-${thread.root.id}`}
|
|
374
|
+
onClick={(e) => e.stopPropagation()}
|
|
375
|
+
search={mentionSearch}
|
|
376
|
+
/>
|
|
377
|
+
</div>
|
|
358
378
|
<button
|
|
359
379
|
type="button"
|
|
360
380
|
className="rounded bg-gray-700 px-2 py-1 text-xs font-medium text-white hover:bg-gray-600 disabled:opacity-50"
|
|
@@ -380,6 +400,7 @@ interface CommentRowProps {
|
|
|
380
400
|
onEdit: (id: string, body: string) => Promise<boolean>
|
|
381
401
|
onToggleResolve: null | (() => void)
|
|
382
402
|
onDelete: () => void
|
|
403
|
+
mentionSearch?: (query: string) => Promise<MentionUser[]>
|
|
383
404
|
}
|
|
384
405
|
|
|
385
406
|
function CommentRow({
|
|
@@ -389,6 +410,7 @@ function CommentRow({
|
|
|
389
410
|
onEdit,
|
|
390
411
|
onToggleResolve,
|
|
391
412
|
onDelete,
|
|
413
|
+
mentionSearch,
|
|
392
414
|
}: CommentRowProps): React.JSX.Element {
|
|
393
415
|
const [editing, setEditing] = useState(false)
|
|
394
416
|
const [draft, setDraft] = useState(comment.body)
|
|
@@ -409,13 +431,14 @@ function CommentRow({
|
|
|
409
431
|
{meta}
|
|
410
432
|
{editing ? (
|
|
411
433
|
<>
|
|
412
|
-
<
|
|
434
|
+
<MentionableTextarea
|
|
413
435
|
className="w-full resize-none rounded border border-gray-300 p-1 text-sm"
|
|
414
436
|
rows={3}
|
|
415
437
|
value={draft}
|
|
416
|
-
onChange={
|
|
438
|
+
onChange={setDraft}
|
|
417
439
|
data-testid={`edit-textarea-${comment.id}`}
|
|
418
440
|
onClick={(e) => e.stopPropagation()}
|
|
441
|
+
search={mentionSearch}
|
|
419
442
|
/>
|
|
420
443
|
<div className="flex gap-1">
|
|
421
444
|
<button
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useCallback, useEffect, useId, useRef, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type MentionUser,
|
|
7
|
+
detectMentionContext,
|
|
8
|
+
insertMentionToken,
|
|
9
|
+
searchMentionUsers,
|
|
10
|
+
} from '../lib/mention-picker.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `<MentionableTextarea />` — drop-in replacement for `<textarea>` /
|
|
14
|
+
* `<input type="text">` that opens a Radix-styled mention picker
|
|
15
|
+
* whenever the user types `@`.
|
|
16
|
+
*
|
|
17
|
+
* Behaviour
|
|
18
|
+
* ─────────
|
|
19
|
+
* - The picker appears anchored to the textarea, below the input.
|
|
20
|
+
* We intentionally do NOT track the caret pixel position (that
|
|
21
|
+
* requires a textarea-mirror trick which doesn't add enough UX to
|
|
22
|
+
* justify the complexity); GitHub, Linear, and Asana all anchor
|
|
23
|
+
* their pickers this way on simple textareas.
|
|
24
|
+
* - Arrow Up / Arrow Down move the highlight. Enter / Tab commit the
|
|
25
|
+
* selection. Escape closes the picker without inserting anything.
|
|
26
|
+
* Mouse click on a row also commits.
|
|
27
|
+
* - The picker only fires server queries while a real mention is
|
|
28
|
+
* being typed; it short-circuits an empty `query` so the dropdown
|
|
29
|
+
* shows up immediately after `@` is typed (with an empty state)
|
|
30
|
+
* and only flips to "no matches" once the user has typed at least
|
|
31
|
+
* one character.
|
|
32
|
+
* - The textarea value is fully controlled by the parent — we never
|
|
33
|
+
* keep a separate copy. `onChange` is called with the next value
|
|
34
|
+
* on every keystroke AND every mention insertion.
|
|
35
|
+
*
|
|
36
|
+
* Dependency-injection seam
|
|
37
|
+
* ─────────────────────────
|
|
38
|
+
* The `search` prop accepts an override so tests can substitute a
|
|
39
|
+
* synchronous fake. Production callers leave it unset and the
|
|
40
|
+
* component uses the real `searchMentionUsers()` REST helper.
|
|
41
|
+
*/
|
|
42
|
+
export interface MentionableTextareaProps {
|
|
43
|
+
value: string
|
|
44
|
+
onChange: (next: string) => void
|
|
45
|
+
/** Forwarded to the underlying textarea. */
|
|
46
|
+
placeholder?: string
|
|
47
|
+
/** Forwarded to the underlying textarea. */
|
|
48
|
+
rows?: number
|
|
49
|
+
className?: string
|
|
50
|
+
/** Forwarded — useful for keyboard shortcuts the parent owns. */
|
|
51
|
+
onKeyDown?: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void
|
|
52
|
+
/** Forwarded — useful for click-to-stop-propagation in nested cards. */
|
|
53
|
+
onClick?: (event: React.MouseEvent<HTMLTextAreaElement>) => void
|
|
54
|
+
/** Test hook — required when rendering with React state assertions. */
|
|
55
|
+
'data-testid'?: string
|
|
56
|
+
/**
|
|
57
|
+
* Override the user search. The default uses the REST helper.
|
|
58
|
+
* Tests inject an in-memory fake so dropdown contents are
|
|
59
|
+
* deterministic without HTTP mocking.
|
|
60
|
+
*/
|
|
61
|
+
search?: (query: string) => Promise<MentionUser[]>
|
|
62
|
+
/**
|
|
63
|
+
* Optional fixed id for the picker dropdown — pass when the parent
|
|
64
|
+
* wires up `aria-controls`. We generate one with `useId()` when
|
|
65
|
+
* unset.
|
|
66
|
+
*/
|
|
67
|
+
pickerId?: string
|
|
68
|
+
/**
|
|
69
|
+
* Format the displayed token. By default we show `@<name>` while
|
|
70
|
+
* the underlying value stores `@<userId>`. A future slice can swap
|
|
71
|
+
* to a rich-text editor where these diverge; the prop lets that
|
|
72
|
+
* land without changing the picker.
|
|
73
|
+
*/
|
|
74
|
+
formatLabel?: (user: MentionUser) => string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface PickerState {
|
|
78
|
+
open: boolean
|
|
79
|
+
query: string
|
|
80
|
+
highlight: number
|
|
81
|
+
users: MentionUser[]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const initialPicker: PickerState = {
|
|
85
|
+
open: false,
|
|
86
|
+
query: '',
|
|
87
|
+
highlight: 0,
|
|
88
|
+
users: [],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function MentionableTextarea({
|
|
92
|
+
value,
|
|
93
|
+
onChange,
|
|
94
|
+
placeholder,
|
|
95
|
+
rows = 3,
|
|
96
|
+
className,
|
|
97
|
+
onKeyDown,
|
|
98
|
+
onClick,
|
|
99
|
+
'data-testid': testId,
|
|
100
|
+
search = (q) => searchMentionUsers(q).then((r) => (r.ok ? r.result : [])),
|
|
101
|
+
pickerId: pickerIdProp,
|
|
102
|
+
formatLabel = (u) => `@${u.name}`,
|
|
103
|
+
}: MentionableTextareaProps): React.JSX.Element {
|
|
104
|
+
const generatedId = useId()
|
|
105
|
+
const pickerId = pickerIdProp ?? `actuate-mention-picker-${generatedId}`
|
|
106
|
+
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
|
107
|
+
const [picker, setPicker] = useState<PickerState>(initialPicker)
|
|
108
|
+
// `pickerRef` mirrors `picker` for the keydown handler so the
|
|
109
|
+
// arrow-key / enter listeners don't go stale between renders.
|
|
110
|
+
const pickerRef = useRef(picker)
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
pickerRef.current = picker
|
|
113
|
+
}, [picker])
|
|
114
|
+
const requestSeq = useRef(0)
|
|
115
|
+
const mountedRef = useRef(true)
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
return () => {
|
|
118
|
+
mountedRef.current = false
|
|
119
|
+
}
|
|
120
|
+
}, [])
|
|
121
|
+
|
|
122
|
+
const closePicker = useCallback(() => {
|
|
123
|
+
setPicker((p) => (p.open ? initialPicker : p))
|
|
124
|
+
}, [])
|
|
125
|
+
|
|
126
|
+
// Recompute the active mention context whenever the value or
|
|
127
|
+
// selection changes. Centralising this in one effect keeps the
|
|
128
|
+
// open/close logic in a single place — every textarea event
|
|
129
|
+
// (input, click, keyup) just calls `recompute()`.
|
|
130
|
+
const recompute = useCallback(
|
|
131
|
+
async (nextValue: string, caret: number) => {
|
|
132
|
+
const ctx = detectMentionContext(nextValue, caret)
|
|
133
|
+
if (!ctx) {
|
|
134
|
+
closePicker()
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
const id = ++requestSeq.current
|
|
138
|
+
// Open immediately so the user sees a dropdown the moment they
|
|
139
|
+
// type `@`. Clear the `users` array on every new query so the
|
|
140
|
+
// user can never press Enter / Tab and commit a stale row
|
|
141
|
+
// from the previous query while the new request is in flight
|
|
142
|
+
// (PR #159 Copilot finding — type `@a` then `@bo` quickly and
|
|
143
|
+
// hit Enter before the `@bo` results arrive: without this
|
|
144
|
+
// reset, the picker would commit a row from `@a`).
|
|
145
|
+
setPicker((p) => ({
|
|
146
|
+
open: true,
|
|
147
|
+
query: ctx.query,
|
|
148
|
+
highlight: 0,
|
|
149
|
+
users: p.query === ctx.query ? p.users : [],
|
|
150
|
+
}))
|
|
151
|
+
try {
|
|
152
|
+
const users = await search(ctx.query)
|
|
153
|
+
if (!mountedRef.current) return
|
|
154
|
+
// Drop stale responses — only commit if our request is still
|
|
155
|
+
// the latest one. Otherwise the user has typed more and a
|
|
156
|
+
// newer query is in flight.
|
|
157
|
+
if (id !== requestSeq.current) return
|
|
158
|
+
setPicker((p) =>
|
|
159
|
+
// If the picker was closed (Escape) between request and
|
|
160
|
+
// response, don't reopen it.
|
|
161
|
+
p.open ? { ...p, users, highlight: 0 } : p,
|
|
162
|
+
)
|
|
163
|
+
} catch {
|
|
164
|
+
if (!mountedRef.current) return
|
|
165
|
+
if (id !== requestSeq.current) return
|
|
166
|
+
// Surface no rows on failure — the textarea must keep
|
|
167
|
+
// working when the user is offline. The poll loop / native
|
|
168
|
+
// submit path is unaffected.
|
|
169
|
+
setPicker((p) => (p.open ? { ...p, users: [] } : p))
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
[closePicker, search],
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
const commitMention = useCallback(
|
|
176
|
+
(user: MentionUser) => {
|
|
177
|
+
const el = textareaRef.current
|
|
178
|
+
if (!el) return
|
|
179
|
+
const caret = el.selectionStart ?? value.length
|
|
180
|
+
const ctx = detectMentionContext(value, caret)
|
|
181
|
+
if (!ctx) {
|
|
182
|
+
closePicker()
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
const next = insertMentionToken(value, ctx, user.id)
|
|
186
|
+
onChange(next.value)
|
|
187
|
+
closePicker()
|
|
188
|
+
// Move the caret past the inserted token AFTER React paints
|
|
189
|
+
// the new value, so the selection isn't clobbered by the
|
|
190
|
+
// controlled update.
|
|
191
|
+
requestAnimationFrame(() => {
|
|
192
|
+
const live = textareaRef.current
|
|
193
|
+
if (!live) return
|
|
194
|
+
live.focus()
|
|
195
|
+
live.setSelectionRange(next.caret, next.caret)
|
|
196
|
+
})
|
|
197
|
+
},
|
|
198
|
+
[closePicker, onChange, value],
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
202
|
+
const next = event.target.value
|
|
203
|
+
const caret = event.target.selectionStart ?? next.length
|
|
204
|
+
onChange(next)
|
|
205
|
+
void recompute(next, caret)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
209
|
+
const p = pickerRef.current
|
|
210
|
+
if (p.open && p.users.length > 0) {
|
|
211
|
+
if (event.key === 'ArrowDown') {
|
|
212
|
+
event.preventDefault()
|
|
213
|
+
setPicker((s) => ({ ...s, highlight: (s.highlight + 1) % s.users.length }))
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
if (event.key === 'ArrowUp') {
|
|
217
|
+
event.preventDefault()
|
|
218
|
+
setPicker((s) => ({
|
|
219
|
+
...s,
|
|
220
|
+
highlight: (s.highlight - 1 + s.users.length) % s.users.length,
|
|
221
|
+
}))
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
225
|
+
event.preventDefault()
|
|
226
|
+
// Defensive: highlight could theoretically point past the
|
|
227
|
+
// end of `users` if state went stale between renders. Guard
|
|
228
|
+
// it so we never `undefined`-bang.
|
|
229
|
+
const target = p.users[p.highlight]
|
|
230
|
+
if (target) commitMention(target)
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (event.key === 'Escape' && p.open) {
|
|
235
|
+
event.preventDefault()
|
|
236
|
+
closePicker()
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
onKeyDown?.(event)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const handleClick = (event: React.MouseEvent<HTMLTextAreaElement>) => {
|
|
243
|
+
onClick?.(event)
|
|
244
|
+
const el = textareaRef.current
|
|
245
|
+
if (!el) return
|
|
246
|
+
void recompute(el.value, el.selectionStart ?? el.value.length)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Closing on outside click — when the user clicks anywhere outside
|
|
250
|
+
// the textarea or the picker, the dropdown should disappear so it
|
|
251
|
+
// doesn't sit on top of unrelated UI.
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
if (!picker.open) return
|
|
254
|
+
const onDocMouseDown = (ev: MouseEvent) => {
|
|
255
|
+
const t = ev.target as Node | null
|
|
256
|
+
if (!t) return
|
|
257
|
+
const parent = textareaRef.current?.parentElement
|
|
258
|
+
if (parent && parent.contains(t)) return
|
|
259
|
+
closePicker()
|
|
260
|
+
}
|
|
261
|
+
document.addEventListener('mousedown', onDocMouseDown)
|
|
262
|
+
return () => document.removeEventListener('mousedown', onDocMouseDown)
|
|
263
|
+
}, [picker.open, closePicker])
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
<div className="relative">
|
|
267
|
+
<textarea
|
|
268
|
+
ref={textareaRef}
|
|
269
|
+
value={value}
|
|
270
|
+
onChange={handleChange}
|
|
271
|
+
onKeyDown={handleKeyDown}
|
|
272
|
+
onClick={handleClick}
|
|
273
|
+
placeholder={placeholder}
|
|
274
|
+
rows={rows}
|
|
275
|
+
className={className}
|
|
276
|
+
aria-autocomplete="list"
|
|
277
|
+
aria-controls={picker.open ? pickerId : undefined}
|
|
278
|
+
aria-expanded={picker.open}
|
|
279
|
+
data-testid={testId}
|
|
280
|
+
/>
|
|
281
|
+
{picker.open && (
|
|
282
|
+
<div
|
|
283
|
+
id={pickerId}
|
|
284
|
+
role="listbox"
|
|
285
|
+
aria-label="Mention a teammate"
|
|
286
|
+
className="absolute top-full left-0 z-30 mt-1 max-h-56 w-64 overflow-y-auto rounded border border-gray-200 bg-white shadow-lg"
|
|
287
|
+
data-testid={testId ? `${testId}-picker` : 'mention-picker'}
|
|
288
|
+
>
|
|
289
|
+
{picker.users.length === 0 ? (
|
|
290
|
+
<p className="px-3 py-2 text-xs text-gray-500">
|
|
291
|
+
{picker.query ? 'No matches' : 'Type a name…'}
|
|
292
|
+
</p>
|
|
293
|
+
) : (
|
|
294
|
+
<ul>
|
|
295
|
+
{picker.users.map((u, idx) => (
|
|
296
|
+
<li key={u.id}>
|
|
297
|
+
<button
|
|
298
|
+
type="button"
|
|
299
|
+
role="option"
|
|
300
|
+
aria-selected={idx === picker.highlight}
|
|
301
|
+
onMouseDown={(e) => {
|
|
302
|
+
// mousedown (not click) — prevents the
|
|
303
|
+
// textarea from losing focus before we
|
|
304
|
+
// commit, which would otherwise hide the
|
|
305
|
+
// selection and feel janky.
|
|
306
|
+
e.preventDefault()
|
|
307
|
+
commitMention(u)
|
|
308
|
+
}}
|
|
309
|
+
onMouseEnter={() => setPicker((s) => ({ ...s, highlight: idx }))}
|
|
310
|
+
className={`block w-full px-3 py-1.5 text-left text-xs ${
|
|
311
|
+
idx === picker.highlight
|
|
312
|
+
? 'bg-blue-50 text-blue-900'
|
|
313
|
+
: 'text-gray-800 hover:bg-gray-50'
|
|
314
|
+
}`}
|
|
315
|
+
data-testid={testId ? `${testId}-picker-row-${u.id}` : `mention-row-${u.id}`}
|
|
316
|
+
>
|
|
317
|
+
<div className="font-medium">{formatLabel(u)}</div>
|
|
318
|
+
<div className="text-[10px] text-gray-500">{u.email}</div>
|
|
319
|
+
</button>
|
|
320
|
+
</li>
|
|
321
|
+
))}
|
|
322
|
+
</ul>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
)
|
|
328
|
+
}
|