@actuate-media/cms-admin 0.13.0 → 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__/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/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/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/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__/lib/mention-picker.test.ts +145 -0
- package/src/components/CommentSidePanel.tsx +37 -14
- package/src/components/MentionableTextarea.tsx +328 -0
- package/src/lib/mention-picker.ts +180 -0
|
@@ -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
|
+
})
|
|
@@ -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
|
+
})
|
|
@@ -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
|