@open-mercato/ui 0.6.2-develop.3467.1.2a1818709d → 0.6.2

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 (71) hide show
  1. package/dist/ai/conversation-store.js +2 -0
  2. package/dist/ai/conversation-store.js.map +2 -2
  3. package/dist/ai/parts/MutationPreviewCard.js +4 -3
  4. package/dist/ai/parts/MutationPreviewCard.js.map +2 -2
  5. package/dist/ai/parts/MutationResultCard.js +9 -16
  6. package/dist/ai/parts/MutationResultCard.js.map +2 -2
  7. package/dist/backend/CrudForm.js +2 -1
  8. package/dist/backend/CrudForm.js.map +2 -2
  9. package/dist/backend/FilterOverlay.js +1 -1
  10. package/dist/backend/FilterOverlay.js.map +1 -1
  11. package/dist/backend/dashboard/widgetRegistry.js +2 -1
  12. package/dist/backend/dashboard/widgetRegistry.js.map +2 -2
  13. package/dist/backend/date-range/dateRanges.js +16 -18
  14. package/dist/backend/date-range/dateRanges.js.map +2 -2
  15. package/dist/backend/detail/AttachmentMetadataDialog.js +3 -1
  16. package/dist/backend/detail/AttachmentMetadataDialog.js.map +2 -2
  17. package/dist/backend/detail/InlineEditors.js +1 -1
  18. package/dist/backend/detail/InlineEditors.js.map +1 -1
  19. package/dist/backend/injection/useAppEvent.js +2 -7
  20. package/dist/backend/injection/useAppEvent.js.map +2 -2
  21. package/dist/backend/injection/widgetRegistry.js +2 -1
  22. package/dist/backend/injection/widgetRegistry.js.map +2 -2
  23. package/dist/backend/messages/useMessageCompose.js +62 -5
  24. package/dist/backend/messages/useMessageCompose.js.map +2 -2
  25. package/dist/backend/messages/useMessageComposeOperations.js +64 -5
  26. package/dist/backend/messages/useMessageComposeOperations.js.map +2 -2
  27. package/dist/backend/schedule/ScheduleCalendar.js +11 -1
  28. package/dist/backend/schedule/ScheduleCalendar.js.map +2 -2
  29. package/dist/backend/schedule/ScheduleToolbar.js +11 -1
  30. package/dist/backend/schedule/ScheduleToolbar.js.map +2 -2
  31. package/dist/primitives/calendar.js +1 -1
  32. package/dist/primitives/calendar.js.map +1 -1
  33. package/dist/primitives/date-format.js +1 -1
  34. package/dist/primitives/date-format.js.map +1 -1
  35. package/dist/primitives/date-picker-helpers.js +7 -9
  36. package/dist/primitives/date-picker-helpers.js.map +2 -2
  37. package/dist/primitives/date-picker.js +1 -1
  38. package/dist/primitives/date-picker.js.map +2 -2
  39. package/dist/primitives/date-range-picker.js +1 -1
  40. package/dist/primitives/date-range-picker.js.map +2 -2
  41. package/jest.config.cjs +4 -1
  42. package/package.json +6 -7
  43. package/src/ai/__tests__/AiAssistantLauncher.test.tsx +1 -1
  44. package/src/ai/__tests__/AiChat.test.tsx +17 -2
  45. package/src/ai/__tests__/conversation-store.test.ts +29 -0
  46. package/src/ai/conversation-store.ts +2 -0
  47. package/src/ai/parts/MutationPreviewCard.tsx +25 -20
  48. package/src/ai/parts/MutationResultCard.tsx +15 -27
  49. package/src/ai/parts/__tests__/MutationPreviewCard.test.tsx +26 -1
  50. package/src/ai/parts/__tests__/MutationResultCard.test.tsx +16 -1
  51. package/src/backend/CrudForm.tsx +3 -2
  52. package/src/backend/FilterOverlay.tsx +1 -1
  53. package/src/backend/__tests__/AppShell.test.tsx +1 -1
  54. package/src/backend/__tests__/SidebarCustomizationEditor.test.tsx +8 -1
  55. package/src/backend/dashboard/widgetRegistry.ts +2 -1
  56. package/src/backend/date-range/dateRanges.ts +16 -18
  57. package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
  58. package/src/backend/detail/InlineEditors.tsx +1 -1
  59. package/src/backend/injection/__tests__/useAppEvent.test.ts +16 -0
  60. package/src/backend/injection/useAppEvent.ts +2 -9
  61. package/src/backend/injection/widgetRegistry.ts +2 -1
  62. package/src/backend/messages/__tests__/MessageComposer.test.tsx +77 -0
  63. package/src/backend/messages/useMessageCompose.ts +64 -5
  64. package/src/backend/messages/useMessageComposeOperations.ts +70 -4
  65. package/src/backend/schedule/ScheduleCalendar.tsx +11 -1
  66. package/src/backend/schedule/ScheduleToolbar.tsx +11 -1
  67. package/src/primitives/calendar.tsx +2 -2
  68. package/src/primitives/date-format.ts +2 -2
  69. package/src/primitives/date-picker-helpers.ts +7 -9
  70. package/src/primitives/date-picker.tsx +2 -2
  71. package/src/primitives/date-range-picker.tsx +2 -2
@@ -155,7 +155,7 @@ describe('MutationPreviewCard', () => {
155
155
  unmount()
156
156
  })
157
157
 
158
- it('Review details toggles the expanded diff section', async () => {
158
+ it('does not render Review details when the visible single-record diff is already complete', () => {
159
159
  installPollingMock(makeAction())
160
160
  renderWithProviders(
161
161
  <MutationPreviewCard
@@ -165,6 +165,31 @@ describe('MutationPreviewCard', () => {
165
165
  { dict },
166
166
  )
167
167
  expect(document.querySelector('[data-ai-mutation-preview-details]')).toBeNull()
168
+ expect(screen.queryByRole('button', { name: /Review details/i })).toBeNull()
169
+ })
170
+
171
+ it('Review details toggles the expanded batch diff section', async () => {
172
+ installPollingMock(
173
+ makeAction({
174
+ fieldDiff: [],
175
+ records: [
176
+ {
177
+ recordId: 'r-1',
178
+ entityType: 'customers.person',
179
+ label: 'Alice',
180
+ fieldDiff: [{ field: 'name', before: 'Alice', after: 'Alicia' }],
181
+ },
182
+ ],
183
+ }),
184
+ )
185
+ renderWithProviders(
186
+ <MutationPreviewCard
187
+ componentId="mutation-preview-card"
188
+ pendingActionId="pa-1"
189
+ />,
190
+ { dict },
191
+ )
192
+ expect(document.querySelector('[data-ai-mutation-preview-details]')).toBeNull()
168
193
  fireEvent.click(screen.getByRole('button', { name: /Review details/i }))
169
194
  await waitFor(() => {
170
195
  expect(document.querySelector('[data-ai-mutation-preview-details]')).not.toBeNull()
@@ -16,7 +16,7 @@ import type { AiPendingActionCardAction } from '../types'
16
16
 
17
17
  const dict = {
18
18
  'ai_assistant.chat.mutation_cards.result.successTitle': 'Action applied',
19
- 'ai_assistant.chat.mutation_cards.result.successBody': 'The mutation completed successfully.',
19
+ 'ai_assistant.chat.mutation_cards.result.successBody': 'The requested change was saved successfully.',
20
20
  'ai_assistant.chat.mutation_cards.result.successWithCommand': 'Completed',
21
21
  'ai_assistant.chat.mutation_cards.result.viewRecord': 'View record',
22
22
  'ai_assistant.chat.mutation_cards.result.partialTitle': 'Action applied with failures',
@@ -87,6 +87,21 @@ describe('MutationResultCard', () => {
87
87
  const link = document.querySelector('[data-ai-mutation-result-link]') as HTMLAnchorElement
88
88
  expect(link).not.toBeNull()
89
89
  expect(link.getAttribute('href')).toBe('/backend/customers/people/p-1')
90
+ expect(link.textContent).toBe('View record')
91
+ expect(screen.queryByText(/customers\.updatePerson/)).toBeNull()
92
+ expect(screen.queryByText(/p-1/)).toBeNull()
93
+ })
94
+
95
+ it('does not expose internal command names or record ids when no record link exists', () => {
96
+ installPollingMock(baseAction())
97
+ renderWithProviders(
98
+ <MutationResultCard componentId="mutation-result-card" pendingActionId="pa-1" />,
99
+ { dict },
100
+ )
101
+ expect(screen.getByText('The requested change was saved successfully.')).toBeInTheDocument()
102
+ expect(screen.queryByText(/customers\.updatePerson/)).toBeNull()
103
+ expect(screen.queryByText(/p-1/)).toBeNull()
104
+ expect(document.querySelector('[data-ai-mutation-result-record-id]')).toBeNull()
90
105
  })
91
106
 
92
107
  it('renders the partial success variant + list of failed records', () => {
@@ -77,8 +77,9 @@ import { buildFormFieldsFromCustomFields, buildFormFieldFromCustomFieldDef } fro
77
77
  import { useT } from '@open-mercato/shared/lib/i18n/context'
78
78
  import { TagsInput } from './inputs/TagsInput'
79
79
  import { ComboboxInput } from './inputs/ComboboxInput'
80
- import { format, parseISO } from 'date-fns'
81
- import type { Locale } from 'date-fns'
80
+ import { format } from 'date-fns/format'
81
+ import { parseISO } from 'date-fns/parseISO'
82
+ import type { Locale } from 'date-fns/locale'
82
83
  import { DateTimePicker } from './inputs/DateTimePicker'
83
84
  import { TimePicker } from './inputs/TimePicker'
84
85
  import { DatePicker } from './inputs/DatePicker'
@@ -1,6 +1,6 @@
1
1
  "use client"
2
2
  import * as React from 'react'
3
- import { format } from 'date-fns'
3
+ import { format } from 'date-fns/format'
4
4
  import { Button } from '../primitives/button'
5
5
  import { Checkbox } from '../primitives/checkbox'
6
6
  import { DateRangePicker } from '../primitives/date-range-picker'
@@ -600,7 +600,7 @@ describe('AppShell', () => {
600
600
 
601
601
  await waitFor(() => {
602
602
  expect(screen.getByText('Pay Links')).toBeInTheDocument()
603
- })
603
+ }, { timeout: 10_000 })
604
604
 
605
605
  const link = screen.getByRole('link', { name: 'Pay Links' })
606
606
  expect(link.querySelector('svg.lucide-ticket')).toBeTruthy()
@@ -55,6 +55,13 @@ jest.mock('../injection/mutationEvents', () => ({
55
55
  dispatchBackendMutationError: jest.fn(),
56
56
  }))
57
57
 
58
+ jest.mock('../injection/useGuardedMutation', () => ({
59
+ useGuardedMutation: () => ({
60
+ runMutation: async ({ operation }: { operation: () => Promise<unknown> }) => operation(),
61
+ retryLastMutation: jest.fn(async () => false),
62
+ }),
63
+ }))
64
+
58
65
  const fakeGroups = [
59
66
  {
60
67
  id: 'core',
@@ -218,7 +225,7 @@ describe('SidebarCustomizationEditor', () => {
218
225
  expect(screen.getByRole('dialog')).toBeInTheDocument()
219
226
 
220
227
  expect(screen.getAllByText(duplicateMessage)).toHaveLength(1)
221
- })
228
+ }, 15_000)
222
229
 
223
230
  it('clears the inline dialog error when the user edits the name', async () => {
224
231
  const duplicateMessage = 'A variant with this name already exists. Choose a different name.'
@@ -1,5 +1,6 @@
1
1
  import type { ModuleDashboardWidgetEntry } from '@open-mercato/shared/modules/registry'
2
2
  import type { DashboardWidgetModule } from '@open-mercato/shared/modules/dashboard/widgets'
3
+ import { applyDashboardWidgetOverridesToEntries } from '@open-mercato/shared/modules/overrides'
3
4
 
4
5
  type Entry = ModuleDashboardWidgetEntry
5
6
 
@@ -10,7 +11,7 @@ export function registerDashboardWidgets(entries: Entry[]) {
10
11
  if (_dashboardWidgetEntries !== null && process.env.NODE_ENV === 'development') {
11
12
  console.debug('[Bootstrap] Dashboard widgets re-registered (this may occur during HMR)')
12
13
  }
13
- _dashboardWidgetEntries = entries
14
+ _dashboardWidgetEntries = applyDashboardWidgetOverridesToEntries(entries)
14
15
  }
15
16
 
16
17
  export function getDashboardWidgets(): Entry[] {
@@ -1,21 +1,19 @@
1
- import {
2
- startOfDay,
3
- endOfDay,
4
- startOfWeek,
5
- endOfWeek,
6
- startOfMonth,
7
- endOfMonth,
8
- startOfQuarter,
9
- endOfQuarter,
10
- startOfYear,
11
- endOfYear,
12
- subDays,
13
- subWeeks,
14
- subMonths,
15
- subQuarters,
16
- subYears,
17
- differenceInDays,
18
- } from 'date-fns'
1
+ import { differenceInDays } from 'date-fns/differenceInDays'
2
+ import { endOfDay } from 'date-fns/endOfDay'
3
+ import { endOfMonth } from 'date-fns/endOfMonth'
4
+ import { endOfQuarter } from 'date-fns/endOfQuarter'
5
+ import { endOfWeek } from 'date-fns/endOfWeek'
6
+ import { endOfYear } from 'date-fns/endOfYear'
7
+ import { startOfDay } from 'date-fns/startOfDay'
8
+ import { startOfMonth } from 'date-fns/startOfMonth'
9
+ import { startOfQuarter } from 'date-fns/startOfQuarter'
10
+ import { startOfWeek } from 'date-fns/startOfWeek'
11
+ import { startOfYear } from 'date-fns/startOfYear'
12
+ import { subDays } from 'date-fns/subDays'
13
+ import { subMonths } from 'date-fns/subMonths'
14
+ import { subQuarters } from 'date-fns/subQuarters'
15
+ import { subWeeks } from 'date-fns/subWeeks'
16
+ import { subYears } from 'date-fns/subYears'
19
17
 
20
18
  export type DateRangePreset =
21
19
  | 'today'
@@ -533,7 +533,7 @@ export function AttachmentMetadataDialog({ open, onOpenChange, item, availableTa
533
533
  {item.fileName}
534
534
  </div>
535
535
  <div className="text-xs text-muted-foreground">
536
- {formatFileSize(item.fileSize)} \u2022 {item.partitionTitle ?? item.partitionCode}
536
+ {formatFileSize(item.fileSize)} {'\u2022'} {item.partitionTitle ?? item.partitionCode}
537
537
  </div>
538
538
  </div>
539
539
  {downloadUrl ? (
@@ -17,7 +17,7 @@ import { Textarea } from '@open-mercato/ui/primitives/textarea'
17
17
  import { DatePicker } from '@open-mercato/ui/primitives/date-picker'
18
18
  import { useT } from '@open-mercato/shared/lib/i18n/context'
19
19
  import { cn } from '@open-mercato/shared/lib/utils'
20
- import { format } from 'date-fns'
20
+ import { format } from 'date-fns/format'
21
21
  import { LoadingMessage } from './LoadingMessage'
22
22
  import { mapCrudServerErrorToFormErrors } from '../utils/serverErrors'
23
23
  import { MarkdownPreview } from '../markdown'
@@ -0,0 +1,16 @@
1
+ import { matchesPattern } from '../useAppEvent'
2
+
3
+ describe('matchesPattern', () => {
4
+ it('matches DOM event wildcard patterns without regex execution', () => {
5
+ expect(matchesPattern('*', 'customers.person.created')).toBe(true)
6
+ expect(matchesPattern('customers.person.*', 'customers.person.created')).toBe(true)
7
+ expect(matchesPattern('customers.*.created', 'customers.person.created')).toBe(true)
8
+ expect(matchesPattern('sales.*', 'customers.person.created')).toBe(false)
9
+ })
10
+
11
+ it('treats regex metacharacters as literal event characters', () => {
12
+ expect(matchesPattern('customers.(person).*', 'customers.(person).created')).toBe(true)
13
+ expect(matchesPattern('customers.+.*', 'customers.person.created')).toBe(false)
14
+ })
15
+ })
16
+
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
  import { useEffect, useRef } from 'react'
3
3
  import type { AppEventPayload } from '@open-mercato/shared/modules/widgets/injection'
4
+ import { matchWildcardPattern } from '@open-mercato/shared/lib/patterns/wildcard'
4
5
 
5
6
  /**
6
7
  * DOM Event Bridge event name.
@@ -23,15 +24,7 @@ export const APP_EVENT_DOM_NAME = 'om:event'
23
24
  * matchesPattern('*', 'anything.here') // true
24
25
  */
25
26
  export function matchesPattern(pattern: string, eventId: string): boolean {
26
- if (pattern === '*') return true
27
- if (!pattern.includes('*')) return pattern === eventId
28
- const escapedPattern = pattern
29
- .replace(/[|\\{}()[\]^$+?.]/g, '\\$&')
30
- .replace(/\*/g, '.*')
31
- const regex = new RegExp(
32
- '^' + escapedPattern + '$',
33
- )
34
- return regex.test(eventId)
27
+ return matchWildcardPattern(eventId, pattern)
35
28
  }
36
29
 
37
30
  /**
@@ -1,5 +1,6 @@
1
1
  import type { ModuleInjectionWidgetEntry } from '@open-mercato/shared/modules/registry'
2
2
  import type { InjectionWidgetModule } from '@open-mercato/shared/modules/widgets/injection'
3
+ import { applyInjectionWidgetOverridesToEntries } from '@open-mercato/shared/modules/overrides'
3
4
 
4
5
  type Entry = ModuleInjectionWidgetEntry
5
6
 
@@ -10,7 +11,7 @@ export function registerInjectionWidgets(entries: Entry[]) {
10
11
  if (_injectionWidgetEntries !== null && process.env.NODE_ENV === 'development') {
11
12
  console.debug('[Bootstrap] Injection widgets re-registered (this may occur during HMR)')
12
13
  }
13
- _injectionWidgetEntries = entries
14
+ _injectionWidgetEntries = applyInjectionWidgetOverridesToEntries(entries)
14
15
  }
15
16
 
16
17
  export function getInjectionWidgets(): Entry[] {
@@ -121,6 +121,78 @@ describe('MessageComposer draft flow', () => {
121
121
  expect(flash).toHaveBeenCalledWith('Draft saved.', 'success')
122
122
  })
123
123
 
124
+ it('sends an existing draft via PATCH with isDraft=false', async () => {
125
+ renderWithProviders(
126
+ <MessageComposer
127
+ inline
128
+ variant="compose"
129
+ messageId="draft-1"
130
+ defaultValues={{
131
+ recipients: ['11111111-1111-4111-8111-111111111111'],
132
+ subject: 'Existing draft',
133
+ body: 'Existing draft body',
134
+ }}
135
+ />,
136
+ { dict: {} },
137
+ )
138
+
139
+ fireEvent.click(await screen.findByRole('button', { name: 'Send' }))
140
+
141
+ await waitFor(() => {
142
+ expect(apiCall).toHaveBeenCalledWith(
143
+ '/api/messages/draft-1',
144
+ expect.objectContaining({
145
+ method: 'PATCH',
146
+ }),
147
+ )
148
+ })
149
+
150
+ const updateRequest = (apiCall as jest.Mock).mock.calls.find(
151
+ (call) => call[0] === '/api/messages/draft-1' && call[1]?.method === 'PATCH',
152
+ )
153
+ const payload = JSON.parse(updateRequest?.[1]?.body ?? '{}') as Record<string, unknown>
154
+ expect(payload.isDraft).toBe(false)
155
+ expect(payload.recipients).toEqual([{ userId: '11111111-1111-4111-8111-111111111111', type: 'to' }])
156
+ expect(flash).toHaveBeenCalledWith('Message sent.', 'success')
157
+ })
158
+
159
+ it('saves an existing draft via PATCH without a draft transition flag', async () => {
160
+ renderWithProviders(
161
+ <MessageComposer
162
+ inline
163
+ variant="compose"
164
+ messageId="draft-1"
165
+ defaultValues={{
166
+ subject: 'Existing draft',
167
+ body: 'Existing draft body',
168
+ }}
169
+ />,
170
+ { dict: {} },
171
+ )
172
+
173
+ fireEvent.click(await screen.findByRole('button', { name: 'Save draft' }))
174
+
175
+ await waitFor(() => {
176
+ expect(apiCall).toHaveBeenCalledWith(
177
+ '/api/messages/draft-1',
178
+ expect.objectContaining({
179
+ method: 'PATCH',
180
+ }),
181
+ )
182
+ })
183
+
184
+ const updateRequest = (apiCall as jest.Mock).mock.calls.find(
185
+ (call) => call[0] === '/api/messages/draft-1' && call[1]?.method === 'PATCH',
186
+ )
187
+ const payload = JSON.parse(updateRequest?.[1]?.body ?? '{}') as Record<string, unknown>
188
+ expect(payload).not.toHaveProperty('isDraft')
189
+ expect(payload).toEqual(expect.objectContaining({
190
+ subject: 'Existing draft',
191
+ body: 'Existing draft body',
192
+ }))
193
+ expect(flash).toHaveBeenCalledWith('Draft saved.', 'success')
194
+ })
195
+
124
196
  it('keeps the send action disabled after a successful compose submit until the composer resets', async () => {
125
197
  let resolveMessagePost!: (value: {
126
198
  ok: boolean
@@ -342,6 +414,11 @@ describe('MessageComposer draft flow', () => {
342
414
  await waitFor(() => {
343
415
  expect(apiCall).toHaveBeenCalledWith(
344
416
  expect.stringMatching(/^\/api\/auth\/users\?/),
417
+ expect.objectContaining({
418
+ headers: expect.objectContaining({
419
+ 'x-om-forbidden-redirect': '0',
420
+ }),
421
+ }),
345
422
  )
346
423
  })
347
424
  })
@@ -16,6 +16,8 @@ import {
16
16
  useComposeSendOperation,
17
17
  useForwardSubmitOperation,
18
18
  useReplySubmitOperation,
19
+ useSendDraftOperation,
20
+ useUpdateDraftOperation,
19
21
  } from './useMessageComposeOperations'
20
22
 
21
23
  function toErrorMessage(payload: unknown): string | null {
@@ -410,7 +412,17 @@ export function useMessageCompose({
410
412
  params.set('pageSize', '100')
411
413
  // Recipient lookup is filtered in TagsInput because incremental auth user search is unreliable.
412
414
 
413
- const call = await apiCall<{ items?: UserListItem[] }>(`/api/auth/users?${params.toString()}`)
415
+ const call = await apiCall<{ items?: UserListItem[] }>(
416
+ `/api/auth/users?${params.toString()}`,
417
+ {
418
+ headers: {
419
+ 'x-om-forbidden-redirect': '0',
420
+ },
421
+ },
422
+ ).catch(() => null)
423
+ if (!call) {
424
+ return []
425
+ }
414
426
  if (!call.ok) {
415
427
  return []
416
428
  }
@@ -477,6 +489,7 @@ export function useMessageCompose({
477
489
 
478
490
  const composeDraftOperation = useComposeDraftOperation({
479
491
  t,
492
+ messageId,
480
493
  messageType,
481
494
  priority,
482
495
  visibility,
@@ -514,16 +527,59 @@ export function useMessageCompose({
514
527
  sendViaEmail,
515
528
  })
516
529
 
530
+ const sendDraftOperation = useSendDraftOperation({
531
+ t,
532
+ messageId: messageId ?? '',
533
+ messageType,
534
+ priority,
535
+ visibility,
536
+ externalEmail,
537
+ recipientIds,
538
+ subject,
539
+ body,
540
+ bodyFormat,
541
+ sendViaEmail,
542
+ contextObject,
543
+ defaultValues,
544
+ contextActionOptions,
545
+ normalizedRequiredActionMode,
546
+ shouldShowContextActions,
547
+ contextActionRequired,
548
+ contextActionType,
549
+ })
550
+
551
+ const updateDraftOperation = useUpdateDraftOperation({
552
+ t,
553
+ messageId: messageId ?? '',
554
+ messageType,
555
+ priority,
556
+ visibility,
557
+ externalEmail,
558
+ recipientIds,
559
+ subject,
560
+ body,
561
+ bodyFormat,
562
+ sendViaEmail,
563
+ contextObject,
564
+ defaultValues,
565
+ contextActionOptions,
566
+ normalizedRequiredActionMode,
567
+ shouldShowContextActions,
568
+ contextActionRequired,
569
+ contextActionType,
570
+ })
571
+
517
572
  const handleSubmit = React.useCallback(async ({ saveAsDraft = false }: { saveAsDraft?: boolean } = {}) => {
518
573
  if (submitting) return false
519
574
 
520
575
  setSubmitError(null)
521
576
 
577
+ const isEditingExistingDraft = variant === 'compose' && Boolean(messageId)
522
578
  const isComposeDraftSubmit = saveAsDraft && variant === 'compose'
523
579
  const operation = isComposeDraftSubmit
524
- ? composeDraftOperation
580
+ ? (isEditingExistingDraft ? updateDraftOperation : composeDraftOperation)
525
581
  : variant === 'compose'
526
- ? composeSendOperation
582
+ ? (isEditingExistingDraft ? sendDraftOperation : composeSendOperation)
527
583
  : variant === 'reply'
528
584
  ? replyOperation
529
585
  : forwardOperation
@@ -556,10 +612,10 @@ export function useMessageCompose({
556
612
  }
557
613
 
558
614
  if (!shouldReturnFalse) {
559
- const { endpoint, payload } = operation.buildRequest({ attachmentIds: nextAttachmentIds })
615
+ const { endpoint, method, payload } = operation.buildRequest({ attachmentIds: nextAttachmentIds })
560
616
 
561
617
  const call = await apiCall<{ id?: string }>(endpoint, {
562
- method: 'POST',
618
+ method,
563
619
  headers: { 'Content-Type': 'application/json' },
564
620
  body: JSON.stringify(payload),
565
621
  })
@@ -612,11 +668,14 @@ export function useMessageCompose({
612
668
  forwardOperation,
613
669
  inline,
614
670
  loadAttachmentIds,
671
+ messageId,
615
672
  onOpenChange,
616
673
  onSuccess,
617
674
  replyOperation,
675
+ sendDraftOperation,
618
676
  submitting,
619
677
  t,
678
+ updateDraftOperation,
620
679
  variant,
621
680
  ])
622
681
 
@@ -16,6 +16,7 @@ type MessageRecipient = {
16
16
 
17
17
  type SubmitRequest = {
18
18
  endpoint: string
19
+ method: 'POST' | 'PATCH'
19
20
  payload: Record<string, unknown>
20
21
  }
21
22
 
@@ -67,6 +68,7 @@ type ForwardOperationParams = {
67
68
  }
68
69
 
69
70
  type DraftOperationParams = Omit<ComposeOperationParams, 'createableMessageTypes'>
71
+ & { messageId?: string }
70
72
 
71
73
  function isValidEmailAddress(value: string): boolean {
72
74
  const email = value.trim()
@@ -182,6 +184,7 @@ export function useComposeSendOperation(params: ComposeOperationParams): BaseOpe
182
184
  },
183
185
  buildRequest: ({ attachmentIds }) => ({
184
186
  endpoint: '/api/messages',
187
+ method: 'POST',
185
188
  payload: buildComposePayload(params, { attachmentIds, isDraft: false }),
186
189
  }),
187
190
  successMessage: params.t('messages.flash.sentSuccess', 'Message sent.'),
@@ -192,10 +195,24 @@ export function useComposeSendOperation(params: ComposeOperationParams): BaseOpe
192
195
  export function useComposeDraftOperation(params: DraftOperationParams): BaseOperation {
193
196
  return React.useMemo(() => ({
194
197
  validate: () => null,
195
- buildRequest: ({ attachmentIds }) => ({
196
- endpoint: '/api/messages',
197
- payload: buildComposePayload(params, { attachmentIds, isDraft: true }),
198
- }),
198
+ buildRequest: ({ attachmentIds }) => {
199
+ const payload = buildComposePayload(params, { attachmentIds, isDraft: true })
200
+ if (!params.messageId) {
201
+ return {
202
+ endpoint: '/api/messages',
203
+ method: 'POST',
204
+ payload,
205
+ }
206
+ }
207
+
208
+ const draftUpdatePayload = { ...payload }
209
+ delete draftUpdatePayload.isDraft
210
+ return {
211
+ endpoint: `/api/messages/${encodeURIComponent(params.messageId)}`,
212
+ method: 'PATCH',
213
+ payload: draftUpdatePayload,
214
+ }
215
+ },
199
216
  successMessage: params.t('messages.flash.draftSaved', 'Draft saved.'),
200
217
  requiresAttachmentRefresh: true,
201
218
  }), [params])
@@ -214,6 +231,7 @@ export function useReplySubmitOperation(params: ReplyOperationParams): BaseOpera
214
231
  },
215
232
  buildRequest: ({ attachmentIds }) => ({
216
233
  endpoint: `/api/messages/${encodeURIComponent(params.messageId ?? '')}/reply`,
234
+ method: 'POST',
217
235
  payload: {
218
236
  body: params.body,
219
237
  bodyFormat: params.bodyFormat,
@@ -244,6 +262,7 @@ export function useForwardSubmitOperation(params: ForwardOperationParams): BaseO
244
262
  },
245
263
  buildRequest: () => ({
246
264
  endpoint: `/api/messages/${encodeURIComponent(params.messageId ?? '')}/forward`,
265
+ method: 'POST',
247
266
  payload: {
248
267
  recipients: mapRecipients(params.recipientIds),
249
268
  body: params.body,
@@ -256,4 +275,51 @@ export function useForwardSubmitOperation(params: ForwardOperationParams): BaseO
256
275
  }), [params])
257
276
  }
258
277
 
278
+ type SendDraftOperationParams = DraftOperationParams & { messageId: string }
279
+
280
+ export function useSendDraftOperation(params: SendDraftOperationParams): BaseOperation {
281
+ return React.useMemo(() => ({
282
+ validate: () => {
283
+ if (params.visibility !== 'public' && params.recipientIds.length === 0) {
284
+ return params.t('messages.errors.noRecipients', 'Please add at least one recipient.')
285
+ }
286
+ if (params.visibility === 'public' && !isValidEmailAddress(params.externalEmail.trim())) {
287
+ return params.t('messages.errors.noExternalEmail', 'Please enter a valid external email.')
288
+ }
289
+ if (!params.subject.trim()) {
290
+ return params.t('messages.errors.noSubject', 'Please enter a subject.')
291
+ }
292
+ if (!params.body.trim()) {
293
+ return params.t('messages.errors.noBody', 'Please enter a message.')
294
+ }
295
+ return null
296
+ },
297
+ buildRequest: ({ attachmentIds }) => ({
298
+ endpoint: `/api/messages/${encodeURIComponent(params.messageId)}`,
299
+ method: 'PATCH' as const,
300
+ payload: buildComposePayload(params, { attachmentIds, isDraft: false }),
301
+ }),
302
+ successMessage: params.t('messages.flash.sentSuccess', 'Message sent.'),
303
+ requiresAttachmentRefresh: true,
304
+ }), [params])
305
+ }
306
+
307
+ type UpdateDraftOperationParams = DraftOperationParams & { messageId: string }
308
+
309
+ export function useUpdateDraftOperation(params: UpdateDraftOperationParams): BaseOperation {
310
+ return React.useMemo(() => ({
311
+ validate: () => null,
312
+ buildRequest: ({ attachmentIds }) => {
313
+ const { isDraft: _omit, ...payload } = buildComposePayload(params, { attachmentIds, isDraft: true })
314
+ return {
315
+ endpoint: `/api/messages/${encodeURIComponent(params.messageId)}`,
316
+ method: 'PATCH' as const,
317
+ payload,
318
+ }
319
+ },
320
+ successMessage: params.t('messages.flash.draftSaved', 'Draft saved.'),
321
+ requiresAttachmentRefresh: true,
322
+ }), [params])
323
+ }
324
+
259
325
  export type MessageComposeSubmitOperation = BaseOperation
@@ -2,7 +2,17 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import { Calendar, dateFnsLocalizer, type View, type SlotInfo } from 'react-big-calendar'
5
- import { addDays, differenceInCalendarDays, endOfDay, endOfMonth, endOfWeek, format, getDay, parse, startOfDay, startOfMonth, startOfWeek } from 'date-fns'
5
+ import { addDays } from 'date-fns/addDays'
6
+ import { differenceInCalendarDays } from 'date-fns/differenceInCalendarDays'
7
+ import { endOfDay } from 'date-fns/endOfDay'
8
+ import { endOfMonth } from 'date-fns/endOfMonth'
9
+ import { endOfWeek } from 'date-fns/endOfWeek'
10
+ import { format } from 'date-fns/format'
11
+ import { getDay } from 'date-fns/getDay'
12
+ import { parse } from 'date-fns/parse'
13
+ import { startOfDay } from 'date-fns/startOfDay'
14
+ import { startOfMonth } from 'date-fns/startOfMonth'
15
+ import { startOfWeek } from 'date-fns/startOfWeek'
6
16
  import { enUS } from 'date-fns/locale/en-US'
7
17
  import type { ScheduleItem, ScheduleRange, ScheduleSlot, ScheduleViewMode } from './types'
8
18
  import { Button } from '../../primitives/button'
@@ -6,7 +6,17 @@ import { Input } from '../../primitives/input'
6
6
  import type { ScheduleRange, ScheduleViewMode } from './types'
7
7
  import { cn } from '@open-mercato/shared/lib/utils'
8
8
  import { useT } from '@open-mercato/shared/lib/i18n/context'
9
- import { addDays, addMonths, addWeeks, differenceInCalendarDays, endOfDay, endOfMonth, endOfWeek, format, startOfDay, startOfMonth, startOfWeek } from 'date-fns'
9
+ import { addDays } from 'date-fns/addDays'
10
+ import { addMonths } from 'date-fns/addMonths'
11
+ import { addWeeks } from 'date-fns/addWeeks'
12
+ import { differenceInCalendarDays } from 'date-fns/differenceInCalendarDays'
13
+ import { endOfDay } from 'date-fns/endOfDay'
14
+ import { endOfMonth } from 'date-fns/endOfMonth'
15
+ import { endOfWeek } from 'date-fns/endOfWeek'
16
+ import { format } from 'date-fns/format'
17
+ import { startOfDay } from 'date-fns/startOfDay'
18
+ import { startOfMonth } from 'date-fns/startOfMonth'
19
+ import { startOfWeek } from 'date-fns/startOfWeek'
10
20
  import { enUS } from 'date-fns/locale/en-US'
11
21
  import { ChevronLeft, ChevronRight } from 'lucide-react'
12
22
 
@@ -4,8 +4,8 @@ import * as React from 'react'
4
4
  import { DayPicker, useDayPicker } from 'react-day-picker'
5
5
  import type { DayPickerProps, CalendarMonth } from 'react-day-picker'
6
6
  import { ChevronLeft, ChevronRight } from 'lucide-react'
7
- import { format } from 'date-fns'
8
- import type { Locale } from 'date-fns'
7
+ import { format } from 'date-fns/format'
8
+ import type { Locale } from 'date-fns/locale'
9
9
  import { cn } from '@open-mercato/shared/lib/utils'
10
10
 
11
11
  export type CalendarProps = DayPickerProps
@@ -1,5 +1,5 @@
1
- import { format as formatDateFns } from 'date-fns'
2
- import type { Locale } from 'date-fns'
1
+ import { format as formatDateFns } from 'date-fns/format'
2
+ import type { Locale } from 'date-fns/locale'
3
3
 
4
4
  type LocaleLike = Locale | string | null | undefined
5
5