@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.
- package/dist/ai/conversation-store.js +2 -0
- package/dist/ai/conversation-store.js.map +2 -2
- package/dist/ai/parts/MutationPreviewCard.js +4 -3
- package/dist/ai/parts/MutationPreviewCard.js.map +2 -2
- package/dist/ai/parts/MutationResultCard.js +9 -16
- package/dist/ai/parts/MutationResultCard.js.map +2 -2
- package/dist/backend/CrudForm.js +2 -1
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/FilterOverlay.js +1 -1
- package/dist/backend/FilterOverlay.js.map +1 -1
- package/dist/backend/dashboard/widgetRegistry.js +2 -1
- package/dist/backend/dashboard/widgetRegistry.js.map +2 -2
- package/dist/backend/date-range/dateRanges.js +16 -18
- package/dist/backend/date-range/dateRanges.js.map +2 -2
- package/dist/backend/detail/AttachmentMetadataDialog.js +3 -1
- package/dist/backend/detail/AttachmentMetadataDialog.js.map +2 -2
- package/dist/backend/detail/InlineEditors.js +1 -1
- package/dist/backend/detail/InlineEditors.js.map +1 -1
- package/dist/backend/injection/useAppEvent.js +2 -7
- package/dist/backend/injection/useAppEvent.js.map +2 -2
- package/dist/backend/injection/widgetRegistry.js +2 -1
- package/dist/backend/injection/widgetRegistry.js.map +2 -2
- package/dist/backend/messages/useMessageCompose.js +62 -5
- package/dist/backend/messages/useMessageCompose.js.map +2 -2
- package/dist/backend/messages/useMessageComposeOperations.js +64 -5
- package/dist/backend/messages/useMessageComposeOperations.js.map +2 -2
- package/dist/backend/schedule/ScheduleCalendar.js +11 -1
- package/dist/backend/schedule/ScheduleCalendar.js.map +2 -2
- package/dist/backend/schedule/ScheduleToolbar.js +11 -1
- package/dist/backend/schedule/ScheduleToolbar.js.map +2 -2
- package/dist/primitives/calendar.js +1 -1
- package/dist/primitives/calendar.js.map +1 -1
- package/dist/primitives/date-format.js +1 -1
- package/dist/primitives/date-format.js.map +1 -1
- package/dist/primitives/date-picker-helpers.js +7 -9
- package/dist/primitives/date-picker-helpers.js.map +2 -2
- package/dist/primitives/date-picker.js +1 -1
- package/dist/primitives/date-picker.js.map +2 -2
- package/dist/primitives/date-range-picker.js +1 -1
- package/dist/primitives/date-range-picker.js.map +2 -2
- package/jest.config.cjs +4 -1
- package/package.json +6 -7
- package/src/ai/__tests__/AiAssistantLauncher.test.tsx +1 -1
- package/src/ai/__tests__/AiChat.test.tsx +17 -2
- package/src/ai/__tests__/conversation-store.test.ts +29 -0
- package/src/ai/conversation-store.ts +2 -0
- package/src/ai/parts/MutationPreviewCard.tsx +25 -20
- package/src/ai/parts/MutationResultCard.tsx +15 -27
- package/src/ai/parts/__tests__/MutationPreviewCard.test.tsx +26 -1
- package/src/ai/parts/__tests__/MutationResultCard.test.tsx +16 -1
- package/src/backend/CrudForm.tsx +3 -2
- package/src/backend/FilterOverlay.tsx +1 -1
- package/src/backend/__tests__/AppShell.test.tsx +1 -1
- package/src/backend/__tests__/SidebarCustomizationEditor.test.tsx +8 -1
- package/src/backend/dashboard/widgetRegistry.ts +2 -1
- package/src/backend/date-range/dateRanges.ts +16 -18
- package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
- package/src/backend/detail/InlineEditors.tsx +1 -1
- package/src/backend/injection/__tests__/useAppEvent.test.ts +16 -0
- package/src/backend/injection/useAppEvent.ts +2 -9
- package/src/backend/injection/widgetRegistry.ts +2 -1
- package/src/backend/messages/__tests__/MessageComposer.test.tsx +77 -0
- package/src/backend/messages/useMessageCompose.ts +64 -5
- package/src/backend/messages/useMessageComposeOperations.ts +70 -4
- package/src/backend/schedule/ScheduleCalendar.tsx +11 -1
- package/src/backend/schedule/ScheduleToolbar.tsx +11 -1
- package/src/primitives/calendar.tsx +2 -2
- package/src/primitives/date-format.ts +2 -2
- package/src/primitives/date-picker-helpers.ts +7 -9
- package/src/primitives/date-picker.tsx +2 -2
- 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
|
|
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
|
|
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', () => {
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -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
|
|
81
|
-
import
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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[] }>(
|
|
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
|
|
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
|
-
|
|
197
|
-
|
|
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
|
|
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
|
|
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
|