@proteos/ui 0.31.0 → 0.32.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/index.cjs +1302 -516
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +259 -1
- package/dist/index.d.ts +259 -1
- package/dist/index.js +1292 -520
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/draft-message-composer/create-draft-message-client.ts +27 -0
- package/src/components/draft-message-composer/draft-message-actions-store.test.ts +62 -0
- package/src/components/draft-message-composer/draft-message-actions-store.ts +81 -0
- package/src/components/draft-message-composer/draft-message-composer.stories.tsx +175 -0
- package/src/components/draft-message-composer/draft-message-composer.test.tsx +276 -0
- package/src/components/draft-message-composer/draft-message-composer.tsx +470 -0
- package/src/components/draft-message-composer/draft-message-outcome-store.test.ts +59 -0
- package/src/components/draft-message-composer/draft-message-outcome-store.ts +56 -0
- package/src/components/draft-message-composer/index.ts +19 -0
- package/src/components/draft-message-composer/types.ts +32 -0
- package/src/components/message-composer/index.ts +13 -0
- package/src/components/message-composer/message-composer.stories.tsx +155 -0
- package/src/components/message-composer/message-composer.test.tsx +320 -0
- package/src/components/message-composer/message-composer.tsx +480 -0
- package/src/components/recipient-select/index.ts +2 -0
- package/src/components/recipient-select/recipient-select.test.tsx +139 -0
- package/src/components/recipient-select/recipient-select.tsx +94 -0
- package/src/index.ts +3 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { ContactAddress } from '@proteos/sdk'
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { type ComposerValue, MessageComposer, type SearchRecipients } from './message-composer'
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof MessageComposer> = {
|
|
8
|
+
title: 'Components/MessageComposer',
|
|
9
|
+
component: MessageComposer,
|
|
10
|
+
tags: ['autodocs'],
|
|
11
|
+
decorators: [
|
|
12
|
+
(Story) => (
|
|
13
|
+
<div className="w-[640px]">
|
|
14
|
+
<Story />
|
|
15
|
+
</div>
|
|
16
|
+
),
|
|
17
|
+
],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default meta
|
|
21
|
+
type Story = StoryObj<typeof MessageComposer>
|
|
22
|
+
|
|
23
|
+
/* Self-contained mock directory — the lib never fetches; hosts inject the
|
|
24
|
+
search. Filters on name/value like a server-side `q` would. */
|
|
25
|
+
const directory = [
|
|
26
|
+
{
|
|
27
|
+
id: 'addr-1',
|
|
28
|
+
value: 'alice@example.com',
|
|
29
|
+
raw_value: 'alice@example.com',
|
|
30
|
+
name: 'Alice Johnson',
|
|
31
|
+
},
|
|
32
|
+
{ id: 'addr-2', value: 'bob@example.com', raw_value: 'bob@example.com', name: 'Bob Meyer' },
|
|
33
|
+
{
|
|
34
|
+
id: 'addr-3',
|
|
35
|
+
value: 'carol@example.com',
|
|
36
|
+
raw_value: 'carol@example.com',
|
|
37
|
+
name: 'Carol Fischer',
|
|
38
|
+
},
|
|
39
|
+
{ id: 'addr-4', value: 'dan@example.com', raw_value: 'dan@example.com', name: 'Dan Okafor' },
|
|
40
|
+
] as ContactAddress[]
|
|
41
|
+
|
|
42
|
+
const searchRecipients: SearchRecipients = async (_connectionId, q) => {
|
|
43
|
+
const needle = q.trim().toLowerCase()
|
|
44
|
+
return directory.filter(
|
|
45
|
+
(address) =>
|
|
46
|
+
needle === '' ||
|
|
47
|
+
address.name.toLowerCase().includes(needle) ||
|
|
48
|
+
address.value.includes(needle),
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const sendAfterAMoment = async () => {
|
|
53
|
+
await new Promise((resolve) => setTimeout(resolve, 400))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const Chat: Story = {
|
|
57
|
+
render: () => {
|
|
58
|
+
const [value, setValue] = useState<ComposerValue>({ text: '' })
|
|
59
|
+
return (
|
|
60
|
+
<MessageComposer
|
|
61
|
+
mode="chat"
|
|
62
|
+
value={value}
|
|
63
|
+
onValueChange={setValue}
|
|
64
|
+
placeholder="Message #ops"
|
|
65
|
+
onSend={sendAfterAMoment}
|
|
66
|
+
onAddFiles={() => {}}
|
|
67
|
+
attachments={[
|
|
68
|
+
{
|
|
69
|
+
localId: 'a-1',
|
|
70
|
+
name: 'q3-report.pdf',
|
|
71
|
+
sizeInBytes: 48_213,
|
|
72
|
+
contentType: 'application/pdf',
|
|
73
|
+
status: 'ready',
|
|
74
|
+
},
|
|
75
|
+
{ localId: 'a-2', name: 'sync-notes.txt', status: 'uploading' },
|
|
76
|
+
]}
|
|
77
|
+
/>
|
|
78
|
+
)
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const Email: Story = {
|
|
83
|
+
render: () => {
|
|
84
|
+
const [value, setValue] = useState<ComposerValue>({ text: '' })
|
|
85
|
+
return (
|
|
86
|
+
<MessageComposer
|
|
87
|
+
mode="email"
|
|
88
|
+
value={value}
|
|
89
|
+
onValueChange={setValue}
|
|
90
|
+
placeholder="Write your message…"
|
|
91
|
+
connectionId="conn-1"
|
|
92
|
+
searchRecipients={searchRecipients}
|
|
93
|
+
showToField
|
|
94
|
+
showRecipientFields
|
|
95
|
+
onSend={sendAfterAMoment}
|
|
96
|
+
/>
|
|
97
|
+
)
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const Disabled: Story = {
|
|
102
|
+
render: () => {
|
|
103
|
+
const [value, setValue] = useState<ComposerValue>({ text: '' })
|
|
104
|
+
return (
|
|
105
|
+
<MessageComposer
|
|
106
|
+
mode="chat"
|
|
107
|
+
value={value}
|
|
108
|
+
onValueChange={setValue}
|
|
109
|
+
placeholder="Reply…"
|
|
110
|
+
disabledReason="This conversation ended — replies are closed."
|
|
111
|
+
onSend={sendAfterAMoment}
|
|
112
|
+
/>
|
|
113
|
+
)
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Submission delegated to an external driver (draft review): no send
|
|
118
|
+
* button, no keybinding hint, Enter never submits. */
|
|
119
|
+
export const HiddenSendButton: Story = {
|
|
120
|
+
render: () => {
|
|
121
|
+
const [value, setValue] = useState<ComposerValue>({ text: 'Edited draft body…' })
|
|
122
|
+
return (
|
|
123
|
+
<MessageComposer
|
|
124
|
+
mode="chat"
|
|
125
|
+
value={value}
|
|
126
|
+
onValueChange={setValue}
|
|
127
|
+
placeholder="Edit the draft…"
|
|
128
|
+
showSendButton={false}
|
|
129
|
+
onSend={sendAfterAMoment}
|
|
130
|
+
/>
|
|
131
|
+
)
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Reply drafts derive the subject from the thread — rendered as static
|
|
136
|
+
* text; only the body is editable. */
|
|
137
|
+
export const ReadOnlySubject: Story = {
|
|
138
|
+
render: () => {
|
|
139
|
+
const [value, setValue] = useState<ComposerValue>({ text: '' })
|
|
140
|
+
return (
|
|
141
|
+
<MessageComposer
|
|
142
|
+
mode="email"
|
|
143
|
+
value={value}
|
|
144
|
+
onValueChange={setValue}
|
|
145
|
+
placeholder="Reply…"
|
|
146
|
+
subjectPrefill="re: Quarterly numbers"
|
|
147
|
+
isSubjectReadOnly
|
|
148
|
+
connectionId="conn-1"
|
|
149
|
+
searchRecipients={searchRecipients}
|
|
150
|
+
showRecipientFields
|
|
151
|
+
onSend={sendAfterAMoment}
|
|
152
|
+
/>
|
|
153
|
+
)
|
|
154
|
+
},
|
|
155
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import type { ContactAddress } from '@proteos/sdk'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import userEvent from '@testing-library/user-event'
|
|
4
|
+
import { useState } from 'react'
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
type ComposerAttachment,
|
|
9
|
+
type ComposerValue,
|
|
10
|
+
MessageComposer,
|
|
11
|
+
type MessageComposerProps,
|
|
12
|
+
} from './message-composer'
|
|
13
|
+
|
|
14
|
+
function composerAttachment(overrides: Partial<ComposerAttachment> = {}): ComposerAttachment {
|
|
15
|
+
return {
|
|
16
|
+
localId: 'local-1',
|
|
17
|
+
name: 'report.pdf',
|
|
18
|
+
sizeInBytes: 9,
|
|
19
|
+
contentType: 'application/pdf',
|
|
20
|
+
status: 'ready',
|
|
21
|
+
...overrides,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function contactAddress(overrides: Partial<ContactAddress> = {}): ContactAddress {
|
|
26
|
+
return {
|
|
27
|
+
id: 'addr-1',
|
|
28
|
+
value: 'alice@example.com',
|
|
29
|
+
raw_value: 'alice@example.com',
|
|
30
|
+
name: 'Alice',
|
|
31
|
+
...overrides,
|
|
32
|
+
} as ContactAddress
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The composer is controlled — a tiny stateful host stands in for the app's
|
|
36
|
+
* draft persistence so typing works like production. */
|
|
37
|
+
type HarnessProps = Omit<MessageComposerProps, 'value' | 'onValueChange'> & {
|
|
38
|
+
initialValue?: ComposerValue
|
|
39
|
+
onValueChange?: (next: ComposerValue) => void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function Harness({ initialValue, onValueChange, ...props }: HarnessProps) {
|
|
43
|
+
const [value, setValue] = useState<ComposerValue>(initialValue ?? { text: '' })
|
|
44
|
+
return (
|
|
45
|
+
<MessageComposer
|
|
46
|
+
{...props}
|
|
47
|
+
value={value}
|
|
48
|
+
onValueChange={(next) => {
|
|
49
|
+
setValue(next)
|
|
50
|
+
onValueChange?.(next)
|
|
51
|
+
}}
|
|
52
|
+
/>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('MessageComposer', () => {
|
|
57
|
+
it('chat mode: Enter sends, Shift+Enter makes a newline', async () => {
|
|
58
|
+
const onSend = vi.fn().mockResolvedValue({})
|
|
59
|
+
render(<Harness mode="chat" placeholder="Message #ops" onSend={onSend} />)
|
|
60
|
+
const input = screen.getByPlaceholderText('Message #ops')
|
|
61
|
+
|
|
62
|
+
await userEvent.type(input, 'line one{Shift>}{Enter}{/Shift}line two')
|
|
63
|
+
expect(onSend).not.toHaveBeenCalled()
|
|
64
|
+
|
|
65
|
+
await userEvent.type(input, '{Enter}')
|
|
66
|
+
expect(onSend).toHaveBeenCalledWith({ text: 'line one\nline two' })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('email mode: Enter does NOT send, Cmd+Enter does, subject required', async () => {
|
|
70
|
+
const onSend = vi.fn().mockResolvedValue({})
|
|
71
|
+
render(<Harness mode="email" placeholder="Reply…" onSend={onSend} />)
|
|
72
|
+
const input = screen.getByPlaceholderText('Reply…')
|
|
73
|
+
|
|
74
|
+
await userEvent.type(input, 'hello{Meta>}{Enter}{/Meta}')
|
|
75
|
+
expect(onSend).not.toHaveBeenCalled() // subject still empty
|
|
76
|
+
|
|
77
|
+
await userEvent.type(screen.getByPlaceholderText('Subject'), 'Quarterly numbers')
|
|
78
|
+
await userEvent.type(input, '{Enter}')
|
|
79
|
+
expect(onSend).not.toHaveBeenCalled() // plain Enter is a newline in email mode
|
|
80
|
+
|
|
81
|
+
await userEvent.type(input, '{Meta>}{Enter}{/Meta}')
|
|
82
|
+
expect(onSend).toHaveBeenCalledWith({ text: 'hello', subject: 'Quarterly numbers' })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('a failed send keeps the text and offers retry', async () => {
|
|
86
|
+
const onSend = vi.fn().mockRejectedValue(new Error('slack_channel_archived'))
|
|
87
|
+
render(<Harness mode="chat" placeholder="Message #ops" onSend={onSend} />)
|
|
88
|
+
const input = screen.getByPlaceholderText('Message #ops')
|
|
89
|
+
|
|
90
|
+
await userEvent.type(input, 'important text{Enter}')
|
|
91
|
+
expect(await screen.findByText(/slack_channel_archived/)).toBeInTheDocument()
|
|
92
|
+
expect(input).toHaveValue('important text')
|
|
93
|
+
expect(screen.getByRole('button', { name: 'retry' })).toBeInTheDocument()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('a successful send clears the input via onValueChange({ text: "" })', async () => {
|
|
97
|
+
const onSend = vi.fn().mockResolvedValue({})
|
|
98
|
+
const onValueChange = vi.fn()
|
|
99
|
+
render(
|
|
100
|
+
<Harness
|
|
101
|
+
mode="chat"
|
|
102
|
+
placeholder="Message #ops"
|
|
103
|
+
onSend={onSend}
|
|
104
|
+
onValueChange={onValueChange}
|
|
105
|
+
/>,
|
|
106
|
+
)
|
|
107
|
+
const input = screen.getByPlaceholderText('Message #ops')
|
|
108
|
+
await userEvent.type(input, 'ship it{Enter}')
|
|
109
|
+
expect(input).toHaveValue('')
|
|
110
|
+
|
|
111
|
+
const lastValue = onValueChange.mock.calls.at(-1)?.[0] as ComposerValue
|
|
112
|
+
expect(lastValue).toEqual({ text: '' })
|
|
113
|
+
// The subject KEY is omitted on the cleared value, not set to undefined.
|
|
114
|
+
expect('subject' in lastValue).toBe(false)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('disabledReason replaces the composer with honest copy', () => {
|
|
118
|
+
render(
|
|
119
|
+
<Harness
|
|
120
|
+
mode="chat"
|
|
121
|
+
placeholder="Reply…"
|
|
122
|
+
disabledReason="This conversation ended — replies are closed."
|
|
123
|
+
onSend={vi.fn()}
|
|
124
|
+
/>,
|
|
125
|
+
)
|
|
126
|
+
expect(screen.queryByPlaceholderText('Reply…')).not.toBeInTheDocument()
|
|
127
|
+
expect(screen.getByText(/replies are closed/)).toBeInTheDocument()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('renders attachment chips with status and remove', async () => {
|
|
131
|
+
const onRemoveAttachment = vi.fn()
|
|
132
|
+
render(
|
|
133
|
+
<Harness
|
|
134
|
+
mode="chat"
|
|
135
|
+
placeholder="Message #ops"
|
|
136
|
+
onSend={vi.fn()}
|
|
137
|
+
attachments={[
|
|
138
|
+
composerAttachment(),
|
|
139
|
+
{
|
|
140
|
+
localId: 'local-2',
|
|
141
|
+
name: 'huge.bin',
|
|
142
|
+
sizeInBytes: 1,
|
|
143
|
+
status: 'error',
|
|
144
|
+
errorMessage: 'Too large (max 25 MB)',
|
|
145
|
+
},
|
|
146
|
+
]}
|
|
147
|
+
onAddFiles={vi.fn()}
|
|
148
|
+
onRemoveAttachment={onRemoveAttachment}
|
|
149
|
+
/>,
|
|
150
|
+
)
|
|
151
|
+
expect(screen.getByText('report.pdf')).toBeInTheDocument()
|
|
152
|
+
expect(screen.getByText('Too large (max 25 MB)')).toBeInTheDocument()
|
|
153
|
+
|
|
154
|
+
await userEvent.click(screen.getByRole('button', { name: 'Remove report.pdf' }))
|
|
155
|
+
expect(onRemoveAttachment).toHaveBeenCalledWith('local-1')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('the clip button renders only when onAddFiles is provided', () => {
|
|
159
|
+
const { rerender } = render(<Harness mode="chat" placeholder="Message #ops" onSend={vi.fn()} />)
|
|
160
|
+
expect(screen.queryByRole('button', { name: 'Attach files' })).not.toBeInTheDocument()
|
|
161
|
+
|
|
162
|
+
rerender(
|
|
163
|
+
<Harness mode="chat" placeholder="Message #ops" onSend={vi.fn()} onAddFiles={vi.fn()} />,
|
|
164
|
+
)
|
|
165
|
+
expect(screen.getByRole('button', { name: 'Attach files' })).toBeInTheDocument()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('an attachments-only send fires with empty text', async () => {
|
|
169
|
+
const onSend = vi.fn().mockResolvedValue({})
|
|
170
|
+
render(
|
|
171
|
+
<Harness
|
|
172
|
+
mode="chat"
|
|
173
|
+
placeholder="Message #ops"
|
|
174
|
+
onSend={onSend}
|
|
175
|
+
attachments={[composerAttachment()]}
|
|
176
|
+
onAddFiles={vi.fn()}
|
|
177
|
+
/>,
|
|
178
|
+
)
|
|
179
|
+
await userEvent.type(screen.getByPlaceholderText('Message #ops'), '{Enter}')
|
|
180
|
+
expect(onSend).toHaveBeenCalledWith({ text: '' })
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('an in-flight upload blocks send', async () => {
|
|
184
|
+
const onSend = vi.fn().mockResolvedValue({})
|
|
185
|
+
render(
|
|
186
|
+
<Harness
|
|
187
|
+
mode="chat"
|
|
188
|
+
placeholder="Message #ops"
|
|
189
|
+
onSend={onSend}
|
|
190
|
+
attachments={[composerAttachment({ status: 'uploading' })]}
|
|
191
|
+
onAddFiles={vi.fn()}
|
|
192
|
+
hasUploadingAttachments
|
|
193
|
+
/>,
|
|
194
|
+
)
|
|
195
|
+
const input = screen.getByPlaceholderText('Message #ops')
|
|
196
|
+
await userEvent.type(input, 'wait for it{Enter}')
|
|
197
|
+
expect(onSend).not.toHaveBeenCalled()
|
|
198
|
+
expect(screen.getByRole('button', { name: 'Send' })).toBeDisabled()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('disabledReason suppresses chips and clip', () => {
|
|
202
|
+
render(
|
|
203
|
+
<Harness
|
|
204
|
+
mode="chat"
|
|
205
|
+
placeholder="Reply…"
|
|
206
|
+
disabledReason="closed"
|
|
207
|
+
onSend={vi.fn()}
|
|
208
|
+
attachments={[composerAttachment()]}
|
|
209
|
+
onAddFiles={vi.fn()}
|
|
210
|
+
/>,
|
|
211
|
+
)
|
|
212
|
+
expect(screen.queryByText('report.pdf')).not.toBeInTheDocument()
|
|
213
|
+
expect(screen.queryByRole('button', { name: 'Attach files' })).not.toBeInTheDocument()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('showSendButton=false hides the button and hint and disables the Enter combo', async () => {
|
|
217
|
+
const onSend = vi.fn().mockResolvedValue({})
|
|
218
|
+
render(
|
|
219
|
+
<Harness mode="chat" placeholder="Message #ops" onSend={onSend} showSendButton={false} />,
|
|
220
|
+
)
|
|
221
|
+
expect(screen.queryByRole('button', { name: 'Send' })).not.toBeInTheDocument()
|
|
222
|
+
expect(screen.queryByText('↵')).not.toBeInTheDocument()
|
|
223
|
+
|
|
224
|
+
await userEvent.type(screen.getByPlaceholderText('Message #ops'), 'external driver{Enter}')
|
|
225
|
+
expect(onSend).not.toHaveBeenCalled()
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('isSubjectReadOnly renders the subject as text, not an input', async () => {
|
|
229
|
+
const onSend = vi.fn().mockResolvedValue({})
|
|
230
|
+
render(
|
|
231
|
+
<Harness
|
|
232
|
+
mode="email"
|
|
233
|
+
placeholder="Reply…"
|
|
234
|
+
subjectPrefill="re: Quarterly numbers"
|
|
235
|
+
isSubjectReadOnly
|
|
236
|
+
onSend={onSend}
|
|
237
|
+
/>,
|
|
238
|
+
)
|
|
239
|
+
// No Subject input to type into — the subject is static text.
|
|
240
|
+
expect(screen.queryByPlaceholderText('Subject')).not.toBeInTheDocument()
|
|
241
|
+
expect(screen.getByText('re: Quarterly numbers')).toBeInTheDocument()
|
|
242
|
+
|
|
243
|
+
// The read-only subject still satisfies email's subject-required rule.
|
|
244
|
+
await userEvent.type(screen.getByPlaceholderText('Reply…'), 'hello{Meta>}{Enter}{/Meta}')
|
|
245
|
+
expect(onSend).toHaveBeenCalledWith({ text: 'hello', subject: 're: Quarterly numbers' })
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('the To row renders only with searchRecipients + connectionId, and gates send', async () => {
|
|
249
|
+
const onSend = vi.fn().mockResolvedValue({})
|
|
250
|
+
const searchRecipients = vi.fn().mockResolvedValue([contactAddress()])
|
|
251
|
+
|
|
252
|
+
const { rerender } = render(
|
|
253
|
+
<Harness
|
|
254
|
+
mode="chat"
|
|
255
|
+
placeholder="Message…"
|
|
256
|
+
onSend={onSend}
|
|
257
|
+
connectionId="conn-1"
|
|
258
|
+
showToField
|
|
259
|
+
/>,
|
|
260
|
+
)
|
|
261
|
+
// No injected search — the composer cannot own addressing.
|
|
262
|
+
expect(screen.queryByText('To')).not.toBeInTheDocument()
|
|
263
|
+
|
|
264
|
+
rerender(
|
|
265
|
+
<Harness
|
|
266
|
+
mode="chat"
|
|
267
|
+
placeholder="Message…"
|
|
268
|
+
onSend={onSend}
|
|
269
|
+
connectionId="conn-1"
|
|
270
|
+
showToField
|
|
271
|
+
searchRecipients={searchRecipients}
|
|
272
|
+
/>,
|
|
273
|
+
)
|
|
274
|
+
expect(screen.getByText('To')).toBeInTheDocument()
|
|
275
|
+
|
|
276
|
+
// No recipient picked yet — send is gated.
|
|
277
|
+
const input = screen.getByPlaceholderText('Message…')
|
|
278
|
+
await userEvent.type(input, 'hi there{Enter}')
|
|
279
|
+
expect(onSend).not.toHaveBeenCalled()
|
|
280
|
+
|
|
281
|
+
// Pick Alice from the injected directory, then send.
|
|
282
|
+
await userEvent.click(screen.getByRole('combobox'))
|
|
283
|
+
await userEvent.click(await screen.findByRole('option', { name: 'Alice' }))
|
|
284
|
+
// The pick renders as a removable chip in the trigger.
|
|
285
|
+
expect(screen.getAllByRole('button', { name: 'Remove', hidden: true }).length).toBe(1)
|
|
286
|
+
// Click outside to dismiss the popover (Escape would reach the focused
|
|
287
|
+
// option and RAC-clear the fresh selection), then send via the button.
|
|
288
|
+
await userEvent.click(input)
|
|
289
|
+
await userEvent.click(screen.getByRole('button', { name: 'Send' }))
|
|
290
|
+
expect(onSend).toHaveBeenCalledWith({
|
|
291
|
+
text: 'hi there',
|
|
292
|
+
to: [{ kind: 'contact-address', external_id: 'alice@example.com' }],
|
|
293
|
+
})
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('controlled recipients are not cleared by a successful send', async () => {
|
|
297
|
+
const onSend = vi.fn().mockResolvedValue({})
|
|
298
|
+
const onToChange = vi.fn()
|
|
299
|
+
const searchRecipients = vi.fn().mockResolvedValue([contactAddress()])
|
|
300
|
+
render(
|
|
301
|
+
<Harness
|
|
302
|
+
mode="chat"
|
|
303
|
+
placeholder="Message…"
|
|
304
|
+
onSend={onSend}
|
|
305
|
+
connectionId="conn-1"
|
|
306
|
+
showToField
|
|
307
|
+
searchRecipients={searchRecipients}
|
|
308
|
+
to={[{ kind: 'contact-address', external_id: 'alice@example.com' }]}
|
|
309
|
+
onToChange={onToChange}
|
|
310
|
+
/>,
|
|
311
|
+
)
|
|
312
|
+
await userEvent.type(screen.getByPlaceholderText('Message…'), 'hi{Enter}')
|
|
313
|
+
expect(onSend).toHaveBeenCalledWith({
|
|
314
|
+
text: 'hi',
|
|
315
|
+
to: [{ kind: 'contact-address', external_id: 'alice@example.com' }],
|
|
316
|
+
})
|
|
317
|
+
// The controlling parent owns the reset — the composer never calls back.
|
|
318
|
+
expect(onToChange).not.toHaveBeenCalled()
|
|
319
|
+
})
|
|
320
|
+
})
|