@open-mercato/ui 0.8.1-develop.7296.1.2111d779db → 0.9.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/.turbo/turbo-build.log +1 -1
- package/dist/backend/CrudForm.js +19 -6
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +62 -10
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/RowActions.js +54 -5
- package/dist/backend/RowActions.js.map +2 -2
- package/dist/backend/charts/KpiCard.js +2 -1
- package/dist/backend/charts/KpiCard.js.map +2 -2
- package/dist/backend/detail/AttachmentsSection.js +17 -5
- package/dist/backend/detail/AttachmentsSection.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +38 -20
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js +11 -1
- package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +26 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/eventBridge.js +33 -0
- package/dist/backend/injection/eventBridge.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +20 -3
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/dist/backend/inputs/PhoneNumberField.js +1 -1
- package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
- package/dist/backend/inputs/TagsInput.js +20 -2
- package/dist/backend/inputs/TagsInput.js.map +2 -2
- package/dist/backend/messages/EmailThreadsPanel.js +2 -1
- package/dist/backend/messages/EmailThreadsPanel.js.map +2 -2
- package/dist/backend/messages/MessageComposer.js.map +2 -2
- package/dist/backend/messages/index.js.map +2 -2
- package/dist/backend/messages/message-compose-form-groups.js +24 -0
- package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
- package/dist/backend/messages/useMessageCompose.js +27 -0
- package/dist/backend/messages/useMessageCompose.js.map +2 -2
- package/dist/backend/messages/useMessageComposeOperations.js +1 -0
- package/dist/backend/messages/useMessageComposeOperations.js.map +2 -2
- package/dist/backend/notifications/NotificationItem.js +8 -1
- package/dist/backend/notifications/NotificationItem.js.map +2 -2
- package/dist/portal/PortalShell.js +2 -2
- package/dist/portal/PortalShell.js.map +2 -2
- package/dist/primitives/calendar.js +69 -14
- package/dist/primitives/calendar.js.map +2 -2
- package/package.json +4 -5
- package/src/backend/CrudForm.tsx +30 -8
- package/src/backend/DataTable.tsx +95 -20
- package/src/backend/RowActions.tsx +114 -6
- package/src/backend/__tests__/AttachmentsSection.upload.test.tsx +183 -0
- package/src/backend/__tests__/CrudForm.fieldInjection.test.tsx +139 -2
- package/src/backend/__tests__/CrudForm.hiddenGroups.test.tsx +1 -1
- package/src/backend/__tests__/DataTable.extensions.test.tsx +30 -0
- package/src/backend/__tests__/DataTable.perspectiveReconcile.test.tsx +320 -2
- package/src/backend/__tests__/DataTable.stickyResponsive.test.tsx +19 -0
- package/src/backend/__tests__/DevRuntimeDiagnosticsBanner.test.tsx +117 -0
- package/src/backend/__tests__/NotesSection.test.tsx +138 -0
- package/src/backend/__tests__/RowActions.test.tsx +202 -0
- package/src/backend/charts/KpiCard.tsx +1 -0
- package/src/backend/charts/__tests__/KpiCard.test.tsx +20 -0
- package/src/backend/detail/AttachmentsSection.tsx +17 -5
- package/src/backend/detail/NotesSection.tsx +122 -66
- package/src/backend/dev/DevRuntimeDiagnosticsBanner.tsx +24 -1
- package/src/backend/icons/lucideRegistry.generated.tsx +26 -0
- package/src/backend/injection/__tests__/eventBridge.test.tsx +47 -1
- package/src/backend/injection/eventBridge.ts +35 -0
- package/src/backend/inputs/LookupSelect.tsx +32 -3
- package/src/backend/inputs/PhoneNumberField.tsx +1 -1
- package/src/backend/inputs/TagsInput.tsx +24 -3
- package/src/backend/inputs/__tests__/LookupSelect.test.tsx +166 -1
- package/src/backend/inputs/__tests__/PhoneNumberField.typeahead.test.tsx +43 -0
- package/src/backend/inputs/__tests__/TagsInput.test.tsx +81 -0
- package/src/backend/messages/EmailThreadsPanel.tsx +21 -1
- package/src/backend/messages/MessageComposer.tsx +1 -0
- package/src/backend/messages/__tests__/EmailThreadsPanel.senderLabel.test.tsx +99 -0
- package/src/backend/messages/__tests__/MessageComposer.test.tsx +44 -0
- package/src/backend/messages/__tests__/useMessageCompose.test.tsx +69 -0
- package/src/backend/messages/__tests__/useMessageComposeSender.test.tsx +208 -0
- package/src/backend/messages/index.ts +1 -0
- package/src/backend/messages/message-compose-form-groups.tsx +41 -0
- package/src/backend/messages/message-composer.types.ts +26 -0
- package/src/backend/messages/useMessageCompose.ts +46 -0
- package/src/backend/messages/useMessageComposeOperations.ts +8 -0
- package/src/backend/notifications/NotificationItem.tsx +7 -0
- package/src/backend/notifications/__tests__/NotificationItem.customRenderer.test.tsx +139 -0
- package/src/portal/PortalShell.tsx +7 -4
- package/src/portal/__tests__/PortalShell.test.tsx +29 -1
- package/src/primitives/__tests__/calendar.test.tsx +174 -1
- package/src/primitives/calendar.tsx +168 -96
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { act, fireEvent, screen } from '@testing-library/react'
|
|
4
4
|
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
5
|
+
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../../../primitives/dialog'
|
|
5
6
|
import { TagsInput } from '../TagsInput'
|
|
6
7
|
|
|
8
|
+
const escapeSuggestions = [{ value: 'user-admin', label: 'admin@acme.com' }]
|
|
9
|
+
|
|
7
10
|
describe('TagsInput', () => {
|
|
8
11
|
it('does not add the typed query when selecting a suggestion', () => {
|
|
9
12
|
function Harness() {
|
|
@@ -112,4 +115,82 @@ describe('TagsInput', () => {
|
|
|
112
115
|
|
|
113
116
|
jest.useRealTimers()
|
|
114
117
|
})
|
|
118
|
+
|
|
119
|
+
describe('Escape on the suggestions list', () => {
|
|
120
|
+
it('closes only the suggestions and keeps Escape away from the host', () => {
|
|
121
|
+
const onHostKeyDown = jest.fn()
|
|
122
|
+
|
|
123
|
+
renderWithProviders(
|
|
124
|
+
<div onKeyDown={onHostKeyDown}>
|
|
125
|
+
<TagsInput value={[]} onChange={jest.fn()} suggestions={escapeSuggestions} />
|
|
126
|
+
</div>,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const input = screen.getByRole('textbox')
|
|
130
|
+
fireEvent.change(input, { target: { value: 'adm' } })
|
|
131
|
+
expect(screen.getByRole('button', { name: /admin@acme\.com/ })).toBeInTheDocument()
|
|
132
|
+
|
|
133
|
+
fireEvent.keyDown(input, { key: 'Escape' })
|
|
134
|
+
|
|
135
|
+
expect(screen.queryByRole('button', { name: /admin@acme\.com/ })).not.toBeInTheDocument()
|
|
136
|
+
expect(onHostKeyDown).not.toHaveBeenCalled()
|
|
137
|
+
expect(input).toHaveValue('adm')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('lets Escape reach the host once the suggestions are closed', () => {
|
|
141
|
+
const onHostKeyDown = jest.fn()
|
|
142
|
+
|
|
143
|
+
renderWithProviders(
|
|
144
|
+
<div onKeyDown={(event) => onHostKeyDown(event.key)}>
|
|
145
|
+
<TagsInput value={[]} onChange={jest.fn()} suggestions={escapeSuggestions} />
|
|
146
|
+
</div>,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
const input = screen.getByRole('textbox')
|
|
150
|
+
fireEvent.change(input, { target: { value: 'adm' } })
|
|
151
|
+
fireEvent.keyDown(input, { key: 'Escape' })
|
|
152
|
+
fireEvent.keyDown(input, { key: 'Escape' })
|
|
153
|
+
|
|
154
|
+
expect(onHostKeyDown).toHaveBeenCalledTimes(1)
|
|
155
|
+
expect(onHostKeyDown).toHaveBeenCalledWith('Escape')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('reopens the suggestions when the user keeps typing', () => {
|
|
159
|
+
renderWithProviders(
|
|
160
|
+
<TagsInput value={[]} onChange={jest.fn()} suggestions={escapeSuggestions} />,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
const input = screen.getByRole('textbox')
|
|
164
|
+
fireEvent.change(input, { target: { value: 'adm' } })
|
|
165
|
+
fireEvent.keyDown(input, { key: 'Escape' })
|
|
166
|
+
fireEvent.change(input, { target: { value: 'admi' } })
|
|
167
|
+
|
|
168
|
+
expect(screen.getByRole('button', { name: /admin@acme\.com/ })).toBeInTheDocument()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('keeps an enclosing dialog open', () => {
|
|
172
|
+
const onOpenChange = jest.fn()
|
|
173
|
+
|
|
174
|
+
renderWithProviders(
|
|
175
|
+
<Dialog open onOpenChange={onOpenChange}>
|
|
176
|
+
<DialogContent>
|
|
177
|
+
<DialogTitle>Compose</DialogTitle>
|
|
178
|
+
<DialogDescription>Compose a message.</DialogDescription>
|
|
179
|
+
<TagsInput value={[]} onChange={jest.fn()} suggestions={escapeSuggestions} />
|
|
180
|
+
</DialogContent>
|
|
181
|
+
</Dialog>,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
const input = screen.getByRole('textbox')
|
|
185
|
+
fireEvent.change(input, { target: { value: 'adm' } })
|
|
186
|
+
fireEvent.keyDown(input, { key: 'Escape' })
|
|
187
|
+
|
|
188
|
+
expect(screen.queryByRole('button', { name: /admin@acme\.com/ })).not.toBeInTheDocument()
|
|
189
|
+
expect(onOpenChange).not.toHaveBeenCalled()
|
|
190
|
+
|
|
191
|
+
fireEvent.keyDown(input, { key: 'Escape' })
|
|
192
|
+
|
|
193
|
+
expect(onOpenChange).toHaveBeenCalledWith(false)
|
|
194
|
+
})
|
|
195
|
+
})
|
|
115
196
|
})
|
|
@@ -27,6 +27,16 @@ export type EmailThreadMessage = {
|
|
|
27
27
|
direction: EmailThreadDirection
|
|
28
28
|
fromName: string | null
|
|
29
29
|
fromEmail: string | null
|
|
30
|
+
/**
|
|
31
|
+
* True when the viewer wrote this message — the only thing that earns a "You"
|
|
32
|
+
* label. Outbound direction alone does NOT imply authorship: a shared
|
|
33
|
+
* conversation or team mailbox puts a colleague's sent mail in this list, and
|
|
34
|
+
* labelling that "You" attributes their email to the reader. Optional and
|
|
35
|
+
* fail-safe: omitted (or false) renders the sender identity instead.
|
|
36
|
+
*/
|
|
37
|
+
authoredByViewer?: boolean
|
|
38
|
+
/** Display label of the sending colleague, when the viewer is not the author. */
|
|
39
|
+
authorName?: string | null
|
|
30
40
|
to: string[]
|
|
31
41
|
cc: string[]
|
|
32
42
|
subject: string | null
|
|
@@ -259,6 +269,16 @@ function EmailMessageCard({
|
|
|
259
269
|
const fromLabel = message.fromName
|
|
260
270
|
? `${message.fromName}${message.fromEmail ? ` <${message.fromEmail}>` : ''}`
|
|
261
271
|
: message.fromEmail ?? t('ui.email.threads.unknownParticipant', 'Unknown sender')
|
|
272
|
+
// Outbound mail carries no `from` address, so a colleague's message falls back
|
|
273
|
+
// to their resolved name and then to a generic label — never to "You", which
|
|
274
|
+
// would credit the reader with an email they did not send.
|
|
275
|
+
const senderLabel = isOutbound
|
|
276
|
+
? message.authoredByViewer
|
|
277
|
+
? t('ui.email.threads.you', 'You')
|
|
278
|
+
: message.authorName?.trim() ||
|
|
279
|
+
message.fromEmail ||
|
|
280
|
+
t('ui.email.threads.teammate', 'A teammate')
|
|
281
|
+
: fromLabel
|
|
262
282
|
return (
|
|
263
283
|
<div
|
|
264
284
|
className={`min-w-0 rounded-md border border-border p-3 ${isOutbound ? 'bg-accent' : 'bg-muted'}`}
|
|
@@ -272,7 +292,7 @@ function EmailMessageCard({
|
|
|
272
292
|
<ArrowDownLeft className="h-3.5 w-3.5 shrink-0" />
|
|
273
293
|
)}
|
|
274
294
|
<span className="min-w-0 break-words font-medium text-foreground">
|
|
275
|
-
{
|
|
295
|
+
{senderLabel}
|
|
276
296
|
</span>
|
|
277
297
|
</div>
|
|
278
298
|
{message.to.length > 0 ? (
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { screen } from '@testing-library/react'
|
|
7
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
8
|
+
import { EmailThreadsPanel, type EmailThread, type EmailThreadMessage } from '../EmailThreadsPanel'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Who an email is credited to in the thread view.
|
|
12
|
+
*
|
|
13
|
+
* Before conversation/channel sharing, the panel labelled every OUTBOUND message
|
|
14
|
+
* "You", because the only person who could read their own sent mail was its
|
|
15
|
+
* author. Sharing is precisely the feature that breaks that premise: a teammate
|
|
16
|
+
* reads a colleague's sent mail, and "You" would then attribute that colleague's
|
|
17
|
+
* email to the reader. Direction is therefore no longer a proxy for authorship.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function message(overrides: Partial<EmailThreadMessage> = {}): EmailThreadMessage {
|
|
21
|
+
return {
|
|
22
|
+
id: 'L1',
|
|
23
|
+
messageId: 'M1',
|
|
24
|
+
rfcMessageId: null,
|
|
25
|
+
references: [],
|
|
26
|
+
direction: 'outbound',
|
|
27
|
+
fromName: null,
|
|
28
|
+
fromEmail: null,
|
|
29
|
+
to: ['contact@example.com'],
|
|
30
|
+
cc: [],
|
|
31
|
+
subject: 'Quarterly review',
|
|
32
|
+
bodyText: 'Sending the numbers over.',
|
|
33
|
+
sentAt: '2026-05-20T11:00:00.000Z',
|
|
34
|
+
providerKey: 'gmail',
|
|
35
|
+
...overrides,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function thread(messages: EmailThreadMessage[]): EmailThread {
|
|
40
|
+
return {
|
|
41
|
+
threadKey: 'T1',
|
|
42
|
+
subject: 'Quarterly review',
|
|
43
|
+
preview: 'Sending the numbers over.',
|
|
44
|
+
participants: ['contact@example.com'],
|
|
45
|
+
lastMessageAt: '2026-05-20T11:00:00.000Z',
|
|
46
|
+
messageCount: messages.length,
|
|
47
|
+
providerKey: 'gmail',
|
|
48
|
+
lastDirection: messages[messages.length - 1]?.direction ?? 'outbound',
|
|
49
|
+
messages,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function renderPanel(messages: EmailThreadMessage[]) {
|
|
54
|
+
return renderWithProviders(<EmailThreadsPanel threads={[thread(messages)]} />)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('EmailThreadsPanel sender label', () => {
|
|
58
|
+
it('labels the viewer\'s own outbound message "You"', () => {
|
|
59
|
+
renderPanel([message({ authoredByViewer: true })])
|
|
60
|
+
expect(screen.getByText('You')).toBeInTheDocument()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('credits a colleague\'s shared outbound message to them, never to the reader', () => {
|
|
64
|
+
renderPanel([message({ authoredByViewer: false, authorName: 'Ada Owner' })])
|
|
65
|
+
expect(screen.getByText('Ada Owner')).toBeInTheDocument()
|
|
66
|
+
expect(screen.queryByText('You')).not.toBeInTheDocument()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('falls back to the sending address when the colleague has no resolved name', () => {
|
|
70
|
+
renderPanel([message({ authoredByViewer: false, authorName: null, fromEmail: 'ada@org.com' })])
|
|
71
|
+
expect(screen.getByText('ada@org.com')).toBeInTheDocument()
|
|
72
|
+
expect(screen.queryByText('You')).not.toBeInTheDocument()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('falls back to a generic label when neither name nor address is known', () => {
|
|
76
|
+
renderPanel([message({ authoredByViewer: false, authorName: null, fromEmail: null })])
|
|
77
|
+
expect(screen.getByText('A teammate')).toBeInTheDocument()
|
|
78
|
+
expect(screen.queryByText('You')).not.toBeInTheDocument()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('does not claim authorship when the flag is absent (fail-safe for older payloads)', () => {
|
|
82
|
+
renderPanel([message({ fromEmail: 'ada@org.com' })])
|
|
83
|
+
expect(screen.queryByText('You')).not.toBeInTheDocument()
|
|
84
|
+
expect(screen.getByText('ada@org.com')).toBeInTheDocument()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('names the external sender on inbound mail, ignoring the authorship flag', () => {
|
|
88
|
+
renderPanel([
|
|
89
|
+
message({
|
|
90
|
+
direction: 'inbound',
|
|
91
|
+
fromName: 'Casey Contact',
|
|
92
|
+
fromEmail: 'casey@example.com',
|
|
93
|
+
authoredByViewer: true,
|
|
94
|
+
}),
|
|
95
|
+
])
|
|
96
|
+
expect(screen.getByText('Casey Contact <casey@example.com>')).toBeInTheDocument()
|
|
97
|
+
expect(screen.queryByText('You')).not.toBeInTheDocument()
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -511,6 +511,50 @@ describe('MessageComposer draft flow', () => {
|
|
|
511
511
|
expect(composeRequest).toBeUndefined()
|
|
512
512
|
})
|
|
513
513
|
|
|
514
|
+
it('keeps the inline composer open when Escape closes a nested Select dropdown (#6447)', async () => {
|
|
515
|
+
const onCancel = jest.fn()
|
|
516
|
+
const pointerCaptureTarget = Element.prototype as Element & {
|
|
517
|
+
hasPointerCapture?: (pointerId: number) => boolean
|
|
518
|
+
releasePointerCapture?: (pointerId: number) => void
|
|
519
|
+
scrollIntoView?: () => void
|
|
520
|
+
}
|
|
521
|
+
pointerCaptureTarget.hasPointerCapture ??= () => false
|
|
522
|
+
pointerCaptureTarget.releasePointerCapture ??= () => undefined
|
|
523
|
+
pointerCaptureTarget.scrollIntoView ??= () => undefined
|
|
524
|
+
|
|
525
|
+
renderWithProviders(
|
|
526
|
+
<MessageComposer
|
|
527
|
+
inline
|
|
528
|
+
variant="compose"
|
|
529
|
+
onCancel={onCancel}
|
|
530
|
+
contextObject={{ entityModule: 'sales', entityType: 'order', entityId: 'order-1' }}
|
|
531
|
+
requiredActionConfig={{
|
|
532
|
+
mode: 'required',
|
|
533
|
+
options: [{ id: 'approve', label: 'Approve order' }],
|
|
534
|
+
}}
|
|
535
|
+
/>,
|
|
536
|
+
{ dict: {} },
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
const actionTypeTrigger = await screen.findByRole('combobox', { name: 'Action type' })
|
|
540
|
+
fireEvent.pointerDown(actionTypeTrigger, { button: 0, ctrlKey: false })
|
|
541
|
+
fireEvent.click(actionTypeTrigger)
|
|
542
|
+
const option = await screen.findByRole('option', { name: 'Approve order' })
|
|
543
|
+
|
|
544
|
+
fireEvent.keyDown(option, { key: 'Escape' })
|
|
545
|
+
|
|
546
|
+
await waitFor(() => {
|
|
547
|
+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
|
548
|
+
})
|
|
549
|
+
expect(onCancel).not.toHaveBeenCalled()
|
|
550
|
+
|
|
551
|
+
fireEvent.keyDown(screen.getByPlaceholderText('Search recipients...'), { key: 'Escape' })
|
|
552
|
+
|
|
553
|
+
await waitFor(() => {
|
|
554
|
+
expect(onCancel).toHaveBeenCalledTimes(1)
|
|
555
|
+
})
|
|
556
|
+
})
|
|
557
|
+
|
|
514
558
|
it('uses inline cancel action for embedded reply composer', async () => {
|
|
515
559
|
const onCancel = jest.fn()
|
|
516
560
|
|
|
@@ -58,3 +58,72 @@ describe('useMessageCompose recipient suggestions', () => {
|
|
|
58
58
|
expect(requestedUrl.searchParams.get('scopeToActiveOrganization')).toBe('1')
|
|
59
59
|
})
|
|
60
60
|
})
|
|
61
|
+
|
|
62
|
+
describe('useMessageCompose Escape handling', () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
jest.resetAllMocks()
|
|
65
|
+
;(apiCall as jest.Mock).mockResolvedValue({
|
|
66
|
+
ok: true,
|
|
67
|
+
status: 200,
|
|
68
|
+
result: { items: [] },
|
|
69
|
+
response: { status: 200 },
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
function buildEscapeEvent(options: { defaultPrevented?: boolean, targetInsideComposer?: boolean }) {
|
|
74
|
+
const composer = document.createElement('div')
|
|
75
|
+
const insideField = document.createElement('input')
|
|
76
|
+
composer.appendChild(insideField)
|
|
77
|
+
const portaledContent = document.createElement('div')
|
|
78
|
+
const preventDefault = jest.fn()
|
|
79
|
+
const event = {
|
|
80
|
+
key: 'Escape',
|
|
81
|
+
metaKey: false,
|
|
82
|
+
ctrlKey: false,
|
|
83
|
+
defaultPrevented: options.defaultPrevented ?? false,
|
|
84
|
+
currentTarget: composer,
|
|
85
|
+
target: options.targetInsideComposer === false ? portaledContent : insideField,
|
|
86
|
+
preventDefault,
|
|
87
|
+
} as unknown as React.KeyboardEvent<HTMLDivElement>
|
|
88
|
+
return { event, preventDefault }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function renderInlineCompose(onCancel: jest.Mock) {
|
|
92
|
+
return renderHook(() => useMessageCompose({ variant: 'compose', inline: true, onCancel }), {
|
|
93
|
+
wrapper: createWrapper(),
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
it('cancels on a plain Escape from inside the composer', () => {
|
|
98
|
+
const onCancel = jest.fn()
|
|
99
|
+
const { result } = renderInlineCompose(onCancel)
|
|
100
|
+
const { event, preventDefault } = buildEscapeEvent({})
|
|
101
|
+
|
|
102
|
+
result.current.handleKeyDown(event)
|
|
103
|
+
|
|
104
|
+
expect(preventDefault).toHaveBeenCalledTimes(1)
|
|
105
|
+
expect(onCancel).toHaveBeenCalledTimes(1)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('ignores an Escape a nested layer already handled', () => {
|
|
109
|
+
const onCancel = jest.fn()
|
|
110
|
+
const { result } = renderInlineCompose(onCancel)
|
|
111
|
+
const { event, preventDefault } = buildEscapeEvent({ defaultPrevented: true })
|
|
112
|
+
|
|
113
|
+
result.current.handleKeyDown(event)
|
|
114
|
+
|
|
115
|
+
expect(preventDefault).not.toHaveBeenCalled()
|
|
116
|
+
expect(onCancel).not.toHaveBeenCalled()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('ignores an Escape bubbling from portaled content outside the composer', () => {
|
|
120
|
+
const onCancel = jest.fn()
|
|
121
|
+
const { result } = renderInlineCompose(onCancel)
|
|
122
|
+
const { event, preventDefault } = buildEscapeEvent({ targetInsideComposer: false })
|
|
123
|
+
|
|
124
|
+
result.current.handleKeyDown(event)
|
|
125
|
+
|
|
126
|
+
expect(preventDefault).not.toHaveBeenCalled()
|
|
127
|
+
expect(onCancel).not.toHaveBeenCalled()
|
|
128
|
+
})
|
|
129
|
+
})
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { act, renderHook, waitFor } from '@testing-library/react'
|
|
7
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
8
|
+
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
9
|
+
import { useMessageCompose } from '../useMessageCompose'
|
|
10
|
+
import { apiCall, withScopedApiRequestHeaders } from '../../utils/apiCall'
|
|
11
|
+
import type { MessageSenderOption } from '../message-composer.types'
|
|
12
|
+
|
|
13
|
+
jest.mock('../../utils/apiCall', () => ({
|
|
14
|
+
apiCall: jest.fn(),
|
|
15
|
+
withScopedApiRequestHeaders: jest.fn((_headers: unknown, run: () => unknown) => run()),
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
jest.mock('../../FlashMessages', () => ({
|
|
19
|
+
flash: jest.fn(),
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
const senderOptions: MessageSenderOption[] = [
|
|
23
|
+
{ id: 'mailbox-primary', label: 'Primary inbox', description: 'me@example.com', isDefault: true },
|
|
24
|
+
{ id: 'mailbox-other', label: 'Other inbox', description: 'other@example.com' },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
function createWrapper() {
|
|
28
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
29
|
+
return function Wrapper({ children }: { children: React.ReactNode }) {
|
|
30
|
+
return (
|
|
31
|
+
<QueryClientProvider client={queryClient}>
|
|
32
|
+
{/* @ts-expect-error shared provider accepts a loose dict shape */}
|
|
33
|
+
<I18nProvider locale="en" dict={{}}>
|
|
34
|
+
{children}
|
|
35
|
+
</I18nProvider>
|
|
36
|
+
</QueryClientProvider>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function mockApiCall(overrides: (url: string) => unknown = () => null) {
|
|
42
|
+
;(withScopedApiRequestHeaders as jest.Mock).mockImplementation(
|
|
43
|
+
(_headers: unknown, run: () => unknown) => run(),
|
|
44
|
+
)
|
|
45
|
+
;(apiCall as jest.Mock).mockImplementation(async (url: string) => {
|
|
46
|
+
const override = overrides(url)
|
|
47
|
+
if (override) return override
|
|
48
|
+
if (url.startsWith('/api/messages/types')) {
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
status: 200,
|
|
52
|
+
result: { items: [{ type: 'default', module: 'messages', labelKey: 'default', icon: 'mail', allowReply: true, allowForward: true, isCreateableByUser: true }] },
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { ok: true, status: 200, result: { items: [] } }
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function findComposeCall(): [string, { method?: string; body?: string }] | undefined {
|
|
60
|
+
return (apiCall as jest.Mock).mock.calls.find(
|
|
61
|
+
([url, init]) => url === '/api/messages' && init?.method === 'POST',
|
|
62
|
+
) as [string, { method?: string; body?: string }] | undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A successful send parks `handleSubmit` on a promise the composer only resolves
|
|
67
|
+
* when it next closes, so the call is awaited through the request it made rather
|
|
68
|
+
* than through its return value.
|
|
69
|
+
*/
|
|
70
|
+
async function submitAndReadPayload(
|
|
71
|
+
submit: () => Promise<boolean>,
|
|
72
|
+
): Promise<Record<string, unknown>> {
|
|
73
|
+
await act(async () => {
|
|
74
|
+
void submit()
|
|
75
|
+
})
|
|
76
|
+
await waitFor(() => expect(findComposeCall()).toBeDefined())
|
|
77
|
+
return JSON.parse(findComposeCall()![1].body as string) as Record<string, unknown>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function fillExternalCompose(result: { current: ReturnType<typeof useMessageCompose> }) {
|
|
81
|
+
await waitFor(() => expect(result.current.createableMessageTypes.length).toBeGreaterThan(0))
|
|
82
|
+
act(() => {
|
|
83
|
+
result.current.setVisibility('public')
|
|
84
|
+
result.current.setExternalEmail('client@example.com')
|
|
85
|
+
result.current.setSubject('Quote')
|
|
86
|
+
result.current.setBody('Here it is')
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe('useMessageCompose sender selection', () => {
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
jest.resetAllMocks()
|
|
93
|
+
mockApiCall()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('offers no senders until the compose addresses an external recipient', async () => {
|
|
97
|
+
const { result } = renderHook(
|
|
98
|
+
() => useMessageCompose({ variant: 'compose', inline: true, senderOptions }),
|
|
99
|
+
{ wrapper: createWrapper() },
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
expect(result.current.senderOptions).toEqual([])
|
|
103
|
+
|
|
104
|
+
act(() => {
|
|
105
|
+
result.current.setVisibility('public')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
expect(result.current.senderOptions).toEqual(senderOptions)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('defaults to the platform sender and posts the unchanged payload', async () => {
|
|
112
|
+
const { result } = renderHook(
|
|
113
|
+
() => useMessageCompose({ variant: 'compose', inline: true, senderOptions }),
|
|
114
|
+
{ wrapper: createWrapper() },
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
await fillExternalCompose(result)
|
|
118
|
+
expect(result.current.senderChannelId).toBe('')
|
|
119
|
+
|
|
120
|
+
const payload = await submitAndReadPayload(() => result.current.handleSubmit())
|
|
121
|
+
|
|
122
|
+
expect(payload).not.toHaveProperty('senderChannelId')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('posts the chosen mailbox on the send', async () => {
|
|
126
|
+
const { result } = renderHook(
|
|
127
|
+
() => useMessageCompose({ variant: 'compose', inline: true, senderOptions }),
|
|
128
|
+
{ wrapper: createWrapper() },
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
await fillExternalCompose(result)
|
|
132
|
+
act(() => {
|
|
133
|
+
result.current.setSenderChannelId('mailbox-other')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const payload = await submitAndReadPayload(() => result.current.handleSubmit())
|
|
137
|
+
|
|
138
|
+
expect(payload.senderChannelId).toBe('mailbox-other')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('drops a mailbox that is no longer offered', async () => {
|
|
142
|
+
const { result } = renderHook(
|
|
143
|
+
() => useMessageCompose({ variant: 'compose', inline: true, senderOptions }),
|
|
144
|
+
{ wrapper: createWrapper() },
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
await fillExternalCompose(result)
|
|
148
|
+
act(() => {
|
|
149
|
+
result.current.setSenderChannelId('mailbox-other')
|
|
150
|
+
result.current.setVisibility('internal')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
expect(result.current.senderChannelId).toBe('')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('never routes a saved draft through a mailbox', async () => {
|
|
157
|
+
const { result } = renderHook(
|
|
158
|
+
() => useMessageCompose({ variant: 'compose', inline: true, senderOptions }),
|
|
159
|
+
{ wrapper: createWrapper() },
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
await fillExternalCompose(result)
|
|
163
|
+
act(() => {
|
|
164
|
+
result.current.setSenderChannelId('mailbox-other')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const payload = await submitAndReadPayload(() =>
|
|
168
|
+
result.current.handleSubmit({ saveAsDraft: true }),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
expect(payload).not.toHaveProperty('senderChannelId')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('surfaces a send-as-user 422 as a field error on the sender control', async () => {
|
|
175
|
+
mockApiCall((url) =>
|
|
176
|
+
url === '/api/messages'
|
|
177
|
+
? {
|
|
178
|
+
ok: false,
|
|
179
|
+
status: 422,
|
|
180
|
+
result: {
|
|
181
|
+
error: 'This channel needs reconnection before it can send messages.',
|
|
182
|
+
fieldErrors: {
|
|
183
|
+
senderChannelId: 'This channel needs reconnection before it can send messages.',
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
: null,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
const { result } = renderHook(
|
|
191
|
+
() => useMessageCompose({ variant: 'compose', inline: true, senderOptions }),
|
|
192
|
+
{ wrapper: createWrapper() },
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
await fillExternalCompose(result)
|
|
196
|
+
act(() => {
|
|
197
|
+
result.current.setSenderChannelId('mailbox-other')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
await act(async () => {
|
|
201
|
+
await result.current.handleSubmit()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
expect(result.current.submitFieldErrors).toEqual({
|
|
205
|
+
senderChannelId: 'This channel needs reconnection before it can send messages.',
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
})
|
|
@@ -184,6 +184,45 @@ function MarkdownBodySection({
|
|
|
184
184
|
)
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
const PLATFORM_SENDER_VALUE = '__platform__'
|
|
188
|
+
|
|
189
|
+
function SenderSelector({ compose }: ComposeProps) {
|
|
190
|
+
if (compose.senderOptions.length === 0) return null
|
|
191
|
+
|
|
192
|
+
const fieldError = compose.submitFieldErrors?.senderChannelId ?? null
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<div className="space-y-2">
|
|
196
|
+
<Label htmlFor="messages-compose-sender">{compose.t('messages.sendFrom', 'Send from')}</Label>
|
|
197
|
+
<Select
|
|
198
|
+
value={compose.senderChannelId || PLATFORM_SENDER_VALUE}
|
|
199
|
+
onValueChange={(value) => compose.setSenderChannelId(value === PLATFORM_SENDER_VALUE ? '' : value)}
|
|
200
|
+
>
|
|
201
|
+
<SelectTrigger id="messages-compose-sender" aria-invalid={fieldError ? true : undefined}>
|
|
202
|
+
<SelectValue />
|
|
203
|
+
</SelectTrigger>
|
|
204
|
+
<SelectContent>
|
|
205
|
+
<SelectItem value={PLATFORM_SENDER_VALUE}>
|
|
206
|
+
{compose.t('messages.sendFromPlatform', 'Platform sender (default)')}
|
|
207
|
+
</SelectItem>
|
|
208
|
+
{compose.senderOptions.map((option) => (
|
|
209
|
+
<SelectItem key={option.id} value={option.id}>
|
|
210
|
+
{option.description ? `${option.label} (${option.description})` : option.label}
|
|
211
|
+
</SelectItem>
|
|
212
|
+
))}
|
|
213
|
+
</SelectContent>
|
|
214
|
+
</Select>
|
|
215
|
+
{fieldError ? (
|
|
216
|
+
<p className="text-sm text-destructive" role="alert">{fieldError}</p>
|
|
217
|
+
) : (
|
|
218
|
+
<p className="text-xs text-muted-foreground">
|
|
219
|
+
{compose.t('messages.sendFromHint', 'Replies to a message sent from your own mailbox come back into this thread.')}
|
|
220
|
+
</p>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
187
226
|
function ComposeModeFields({ compose }: ComposeProps) {
|
|
188
227
|
return (
|
|
189
228
|
<>
|
|
@@ -213,6 +252,8 @@ function ComposeModeFields({ compose }: ComposeProps) {
|
|
|
213
252
|
</div>
|
|
214
253
|
</div>
|
|
215
254
|
|
|
255
|
+
<SenderSelector compose={compose} />
|
|
256
|
+
|
|
216
257
|
<ContextActionsSection compose={compose} />
|
|
217
258
|
</div>
|
|
218
259
|
|
|
@@ -26,6 +26,26 @@ export type AttachmentListResponse = {
|
|
|
26
26
|
|
|
27
27
|
export type MessageComposerVariant = 'compose' | 'reply' | 'forward'
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* One entry of the composer's "Send from" list, in provider-agnostic terms.
|
|
31
|
+
*
|
|
32
|
+
* The composer never learns what backs an option — the host module supplies the
|
|
33
|
+
* list and the API route decides how a selected id is routed. An empty list
|
|
34
|
+
* hides the control entirely, which is what a host without any configured
|
|
35
|
+
* sender renders.
|
|
36
|
+
*/
|
|
37
|
+
export type MessageSenderOption = {
|
|
38
|
+
id: string
|
|
39
|
+
label: string
|
|
40
|
+
description?: string | null
|
|
41
|
+
/**
|
|
42
|
+
* The option to reach for first when the user opts out of the platform
|
|
43
|
+
* sender. It orders the list; it does not preselect, because the platform
|
|
44
|
+
* sender stays the default.
|
|
45
|
+
*/
|
|
46
|
+
isDefault?: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
29
49
|
export type MessageComposerContextObject = {
|
|
30
50
|
entityModule: string
|
|
31
51
|
entityType: string
|
|
@@ -60,6 +80,12 @@ export type MessageComposerProps = {
|
|
|
60
80
|
contextObject?: MessageComposerContextObject | null
|
|
61
81
|
requiredActionConfig?: MessageComposerRequiredActionConfig | null
|
|
62
82
|
contextPreview?: React.ReactNode
|
|
83
|
+
/**
|
|
84
|
+
* Alternative senders offered next to the default platform sender. Rendered
|
|
85
|
+
* only in the compose variant addressing an external recipient, and only when
|
|
86
|
+
* non-empty.
|
|
87
|
+
*/
|
|
88
|
+
senderOptions?: MessageSenderOption[]
|
|
63
89
|
/**
|
|
64
90
|
* Expected `updated_at` of the existing draft being edited. When present, the
|
|
65
91
|
* composer attaches the OSS optimistic-lock header to the draft save/send
|