@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.
Files changed (85) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/CrudForm.js +19 -6
  3. package/dist/backend/CrudForm.js.map +2 -2
  4. package/dist/backend/DataTable.js +62 -10
  5. package/dist/backend/DataTable.js.map +2 -2
  6. package/dist/backend/RowActions.js +54 -5
  7. package/dist/backend/RowActions.js.map +2 -2
  8. package/dist/backend/charts/KpiCard.js +2 -1
  9. package/dist/backend/charts/KpiCard.js.map +2 -2
  10. package/dist/backend/detail/AttachmentsSection.js +17 -5
  11. package/dist/backend/detail/AttachmentsSection.js.map +2 -2
  12. package/dist/backend/detail/NotesSection.js +38 -20
  13. package/dist/backend/detail/NotesSection.js.map +2 -2
  14. package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js +11 -1
  15. package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js.map +2 -2
  16. package/dist/backend/icons/lucideRegistry.generated.js +26 -0
  17. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  18. package/dist/backend/injection/eventBridge.js +33 -0
  19. package/dist/backend/injection/eventBridge.js.map +2 -2
  20. package/dist/backend/inputs/LookupSelect.js +20 -3
  21. package/dist/backend/inputs/LookupSelect.js.map +2 -2
  22. package/dist/backend/inputs/PhoneNumberField.js +1 -1
  23. package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
  24. package/dist/backend/inputs/TagsInput.js +20 -2
  25. package/dist/backend/inputs/TagsInput.js.map +2 -2
  26. package/dist/backend/messages/EmailThreadsPanel.js +2 -1
  27. package/dist/backend/messages/EmailThreadsPanel.js.map +2 -2
  28. package/dist/backend/messages/MessageComposer.js.map +2 -2
  29. package/dist/backend/messages/index.js.map +2 -2
  30. package/dist/backend/messages/message-compose-form-groups.js +24 -0
  31. package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
  32. package/dist/backend/messages/useMessageCompose.js +27 -0
  33. package/dist/backend/messages/useMessageCompose.js.map +2 -2
  34. package/dist/backend/messages/useMessageComposeOperations.js +1 -0
  35. package/dist/backend/messages/useMessageComposeOperations.js.map +2 -2
  36. package/dist/backend/notifications/NotificationItem.js +8 -1
  37. package/dist/backend/notifications/NotificationItem.js.map +2 -2
  38. package/dist/portal/PortalShell.js +2 -2
  39. package/dist/portal/PortalShell.js.map +2 -2
  40. package/dist/primitives/calendar.js +69 -14
  41. package/dist/primitives/calendar.js.map +2 -2
  42. package/package.json +4 -5
  43. package/src/backend/CrudForm.tsx +30 -8
  44. package/src/backend/DataTable.tsx +95 -20
  45. package/src/backend/RowActions.tsx +114 -6
  46. package/src/backend/__tests__/AttachmentsSection.upload.test.tsx +183 -0
  47. package/src/backend/__tests__/CrudForm.fieldInjection.test.tsx +139 -2
  48. package/src/backend/__tests__/CrudForm.hiddenGroups.test.tsx +1 -1
  49. package/src/backend/__tests__/DataTable.extensions.test.tsx +30 -0
  50. package/src/backend/__tests__/DataTable.perspectiveReconcile.test.tsx +320 -2
  51. package/src/backend/__tests__/DataTable.stickyResponsive.test.tsx +19 -0
  52. package/src/backend/__tests__/DevRuntimeDiagnosticsBanner.test.tsx +117 -0
  53. package/src/backend/__tests__/NotesSection.test.tsx +138 -0
  54. package/src/backend/__tests__/RowActions.test.tsx +202 -0
  55. package/src/backend/charts/KpiCard.tsx +1 -0
  56. package/src/backend/charts/__tests__/KpiCard.test.tsx +20 -0
  57. package/src/backend/detail/AttachmentsSection.tsx +17 -5
  58. package/src/backend/detail/NotesSection.tsx +122 -66
  59. package/src/backend/dev/DevRuntimeDiagnosticsBanner.tsx +24 -1
  60. package/src/backend/icons/lucideRegistry.generated.tsx +26 -0
  61. package/src/backend/injection/__tests__/eventBridge.test.tsx +47 -1
  62. package/src/backend/injection/eventBridge.ts +35 -0
  63. package/src/backend/inputs/LookupSelect.tsx +32 -3
  64. package/src/backend/inputs/PhoneNumberField.tsx +1 -1
  65. package/src/backend/inputs/TagsInput.tsx +24 -3
  66. package/src/backend/inputs/__tests__/LookupSelect.test.tsx +166 -1
  67. package/src/backend/inputs/__tests__/PhoneNumberField.typeahead.test.tsx +43 -0
  68. package/src/backend/inputs/__tests__/TagsInput.test.tsx +81 -0
  69. package/src/backend/messages/EmailThreadsPanel.tsx +21 -1
  70. package/src/backend/messages/MessageComposer.tsx +1 -0
  71. package/src/backend/messages/__tests__/EmailThreadsPanel.senderLabel.test.tsx +99 -0
  72. package/src/backend/messages/__tests__/MessageComposer.test.tsx +44 -0
  73. package/src/backend/messages/__tests__/useMessageCompose.test.tsx +69 -0
  74. package/src/backend/messages/__tests__/useMessageComposeSender.test.tsx +208 -0
  75. package/src/backend/messages/index.ts +1 -0
  76. package/src/backend/messages/message-compose-form-groups.tsx +41 -0
  77. package/src/backend/messages/message-composer.types.ts +26 -0
  78. package/src/backend/messages/useMessageCompose.ts +46 -0
  79. package/src/backend/messages/useMessageComposeOperations.ts +8 -0
  80. package/src/backend/notifications/NotificationItem.tsx +7 -0
  81. package/src/backend/notifications/__tests__/NotificationItem.customRenderer.test.tsx +139 -0
  82. package/src/portal/PortalShell.tsx +7 -4
  83. package/src/portal/__tests__/PortalShell.test.tsx +29 -1
  84. package/src/primitives/__tests__/calendar.test.tsx +174 -1
  85. 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
- {isOutbound ? t('ui.email.threads.you', 'You') : fromLabel}
295
+ {senderLabel}
276
296
  </span>
277
297
  </div>
278
298
  {message.to.length > 0 ? (
@@ -18,6 +18,7 @@ export type {
18
18
  MessageComposerRequiredActionConfig,
19
19
  MessageComposerRequiredActionOption,
20
20
  MessageComposerVariant,
21
+ MessageSenderOption,
21
22
  MessageTypeItem,
22
23
  } from './message-composer.types'
23
24
 
@@ -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
+ })
@@ -3,6 +3,7 @@ export type {
3
3
  MessageComposerContextObject,
4
4
  MessageComposerProps,
5
5
  MessageComposerVariant,
6
+ MessageSenderOption,
6
7
  MessageTypeItem,
7
8
  } from './MessageComposer'
8
9
 
@@ -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