@open-mercato/ui 0.6.6-develop.6416.1.888fffc007 → 0.6.6-develop.6422.1.b3d024f05c
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/backend/CrudForm.js +16 -7
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/messages/useMessageCompose.js +26 -9
- package/dist/backend/messages/useMessageCompose.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +15 -4
- package/src/backend/__tests__/CrudForm.readonlyFields.test.tsx +120 -0
- package/src/backend/messages/__tests__/MessageComposer.test.tsx +174 -1
- package/src/backend/messages/message-composer.types.ts +6 -0
- package/src/backend/messages/useMessageCompose.ts +34 -9
|
@@ -5,12 +5,15 @@
|
|
|
5
5
|
import * as React from 'react'
|
|
6
6
|
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
|
7
7
|
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
8
|
+
import { OPTIMISTIC_LOCK_HEADER_NAME } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
|
|
8
9
|
import { MessageComposer } from '../MessageComposer'
|
|
9
|
-
import { apiCall } from '../../utils/apiCall'
|
|
10
|
+
import { apiCall, withScopedApiRequestHeaders } from '../../utils/apiCall'
|
|
10
11
|
import { flash } from '../../FlashMessages'
|
|
12
|
+
import { dismissRecordConflict, getRecordConflictForTest } from '../../conflicts'
|
|
11
13
|
|
|
12
14
|
jest.mock('../../utils/apiCall', () => ({
|
|
13
15
|
apiCall: jest.fn(),
|
|
16
|
+
withScopedApiRequestHeaders: jest.fn((_headers: Record<string, string>, run: () => unknown) => run()),
|
|
14
17
|
}))
|
|
15
18
|
|
|
16
19
|
jest.mock('../../FlashMessages', () => ({
|
|
@@ -59,6 +62,10 @@ describe('MessageComposer draft flow', () => {
|
|
|
59
62
|
|
|
60
63
|
beforeEach(() => {
|
|
61
64
|
jest.resetAllMocks()
|
|
65
|
+
dismissRecordConflict()
|
|
66
|
+
;(withScopedApiRequestHeaders as jest.Mock).mockImplementation(
|
|
67
|
+
(_headers: Record<string, string>, run: () => unknown) => run(),
|
|
68
|
+
)
|
|
62
69
|
;(apiCall as jest.Mock).mockImplementation((url: string, options?: { method?: string, body?: string }) => {
|
|
63
70
|
if (url.startsWith('/api/messages/types')) {
|
|
64
71
|
return Promise.resolve({
|
|
@@ -156,6 +163,172 @@ describe('MessageComposer draft flow', () => {
|
|
|
156
163
|
expect(flash).toHaveBeenCalledWith('Message sent.', 'success')
|
|
157
164
|
})
|
|
158
165
|
|
|
166
|
+
it('attaches the optimistic-lock header when sending an existing draft', async () => {
|
|
167
|
+
const expectedUpdatedAt = '2026-02-24T10:00:00.000Z'
|
|
168
|
+
|
|
169
|
+
renderWithProviders(
|
|
170
|
+
<MessageComposer
|
|
171
|
+
inline
|
|
172
|
+
variant="compose"
|
|
173
|
+
messageId="draft-1"
|
|
174
|
+
expectedUpdatedAt={expectedUpdatedAt}
|
|
175
|
+
defaultValues={{
|
|
176
|
+
recipients: ['11111111-1111-4111-8111-111111111111'],
|
|
177
|
+
subject: 'Existing draft',
|
|
178
|
+
body: 'Existing draft body',
|
|
179
|
+
}}
|
|
180
|
+
/>,
|
|
181
|
+
{ dict: {} },
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Send' }))
|
|
185
|
+
|
|
186
|
+
await waitFor(() => {
|
|
187
|
+
expect(apiCall).toHaveBeenCalledWith(
|
|
188
|
+
'/api/messages/draft-1',
|
|
189
|
+
expect.objectContaining({ method: 'PATCH' }),
|
|
190
|
+
)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
expect(withScopedApiRequestHeaders).toHaveBeenCalledWith(
|
|
194
|
+
{ [OPTIMISTIC_LOCK_HEADER_NAME]: expectedUpdatedAt },
|
|
195
|
+
expect.any(Function),
|
|
196
|
+
)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('surfaces a 409 optimistic-lock conflict on the shared banner instead of a generic flash', async () => {
|
|
200
|
+
const expectedUpdatedAt = '2026-02-24T10:00:00.000Z'
|
|
201
|
+
|
|
202
|
+
;(apiCall as jest.Mock).mockImplementation((url: string, options?: { method?: string }) => {
|
|
203
|
+
if (url.startsWith('/api/messages/types')) {
|
|
204
|
+
return Promise.resolve({
|
|
205
|
+
ok: true,
|
|
206
|
+
status: 200,
|
|
207
|
+
result: {
|
|
208
|
+
items: [{
|
|
209
|
+
type: 'default',
|
|
210
|
+
module: 'messages',
|
|
211
|
+
labelKey: 'messages.types.default',
|
|
212
|
+
icon: 'mail',
|
|
213
|
+
allowReply: true,
|
|
214
|
+
allowForward: true,
|
|
215
|
+
}],
|
|
216
|
+
},
|
|
217
|
+
response: { status: 200 },
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (url === '/api/messages/draft-1' && options?.method === 'PATCH') {
|
|
222
|
+
return Promise.resolve({
|
|
223
|
+
ok: false,
|
|
224
|
+
status: 409,
|
|
225
|
+
result: {
|
|
226
|
+
error: 'record_modified',
|
|
227
|
+
code: 'optimistic_lock_conflict',
|
|
228
|
+
currentUpdatedAt: '2026-02-24T11:00:00.000Z',
|
|
229
|
+
expectedUpdatedAt,
|
|
230
|
+
},
|
|
231
|
+
response: { status: 409 },
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return Promise.resolve({ ok: true, status: 200, result: { items: [] }, response: { status: 200 } })
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
renderWithProviders(
|
|
239
|
+
<MessageComposer
|
|
240
|
+
inline
|
|
241
|
+
variant="compose"
|
|
242
|
+
messageId="draft-1"
|
|
243
|
+
expectedUpdatedAt={expectedUpdatedAt}
|
|
244
|
+
defaultValues={{
|
|
245
|
+
recipients: ['11111111-1111-4111-8111-111111111111'],
|
|
246
|
+
subject: 'Existing draft',
|
|
247
|
+
body: 'Existing draft body',
|
|
248
|
+
}}
|
|
249
|
+
/>,
|
|
250
|
+
{ dict: {} },
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Send' }))
|
|
254
|
+
|
|
255
|
+
await waitFor(() => {
|
|
256
|
+
const conflict = getRecordConflictForTest()
|
|
257
|
+
expect(conflict).not.toBeNull()
|
|
258
|
+
expect(conflict?.currentUpdatedAt).toBe('2026-02-24T11:00:00.000Z')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
expect(flash).not.toHaveBeenCalledWith('Failed to send message.', 'error')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('closes the compose dialog on a 409 conflict so the shared banner is not hidden behind it (TC-007)', async () => {
|
|
265
|
+
const expectedUpdatedAt = '2026-02-24T10:00:00.000Z'
|
|
266
|
+
const onOpenChange = jest.fn()
|
|
267
|
+
|
|
268
|
+
;(apiCall as jest.Mock).mockImplementation((url: string, options?: { method?: string }) => {
|
|
269
|
+
if (url.startsWith('/api/messages/types')) {
|
|
270
|
+
return Promise.resolve({
|
|
271
|
+
ok: true,
|
|
272
|
+
status: 200,
|
|
273
|
+
result: {
|
|
274
|
+
items: [{
|
|
275
|
+
type: 'default',
|
|
276
|
+
module: 'messages',
|
|
277
|
+
labelKey: 'messages.types.default',
|
|
278
|
+
icon: 'mail',
|
|
279
|
+
allowReply: true,
|
|
280
|
+
allowForward: true,
|
|
281
|
+
}],
|
|
282
|
+
},
|
|
283
|
+
response: { status: 200 },
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (url === '/api/messages/draft-1' && options?.method === 'PATCH') {
|
|
288
|
+
return Promise.resolve({
|
|
289
|
+
ok: false,
|
|
290
|
+
status: 409,
|
|
291
|
+
result: {
|
|
292
|
+
error: 'record_modified',
|
|
293
|
+
code: 'optimistic_lock_conflict',
|
|
294
|
+
currentUpdatedAt: '2026-02-24T11:00:00.000Z',
|
|
295
|
+
expectedUpdatedAt,
|
|
296
|
+
},
|
|
297
|
+
response: { status: 409 },
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return Promise.resolve({ ok: true, status: 200, result: { items: [] }, response: { status: 200 } })
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
renderWithProviders(
|
|
305
|
+
<MessageComposer
|
|
306
|
+
open
|
|
307
|
+
variant="compose"
|
|
308
|
+
messageId="draft-1"
|
|
309
|
+
expectedUpdatedAt={expectedUpdatedAt}
|
|
310
|
+
onOpenChange={onOpenChange}
|
|
311
|
+
defaultValues={{
|
|
312
|
+
recipients: ['11111111-1111-4111-8111-111111111111'],
|
|
313
|
+
subject: 'Existing draft',
|
|
314
|
+
body: 'Existing draft body',
|
|
315
|
+
}}
|
|
316
|
+
/>,
|
|
317
|
+
{ dict: {} },
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
const dialog = await screen.findByRole('dialog')
|
|
321
|
+
const sendButtons = within(dialog).getAllByRole('button', { name: 'Send' })
|
|
322
|
+
fireEvent.click(sendButtons[sendButtons.length - 1] as HTMLButtonElement)
|
|
323
|
+
|
|
324
|
+
await waitFor(() => {
|
|
325
|
+
expect(getRecordConflictForTest()).not.toBeNull()
|
|
326
|
+
})
|
|
327
|
+
// The page-level conflict banner is hidden behind the modal, so the dialog
|
|
328
|
+
// must close to reveal it (and its Refresh action) to the user.
|
|
329
|
+
expect(onOpenChange).toHaveBeenCalledWith(false)
|
|
330
|
+
})
|
|
331
|
+
|
|
159
332
|
it('saves an existing draft via PATCH without a draft transition flag', async () => {
|
|
160
333
|
renderWithProviders(
|
|
161
334
|
<MessageComposer
|
|
@@ -60,6 +60,12 @@ export type MessageComposerProps = {
|
|
|
60
60
|
contextObject?: MessageComposerContextObject | null
|
|
61
61
|
requiredActionConfig?: MessageComposerRequiredActionConfig | null
|
|
62
62
|
contextPreview?: React.ReactNode
|
|
63
|
+
/**
|
|
64
|
+
* Expected `updated_at` of the existing draft being edited. When present, the
|
|
65
|
+
* composer attaches the OSS optimistic-lock header to the draft save/send
|
|
66
|
+
* PATCH so a stale tab cannot silently overwrite a concurrently-changed draft.
|
|
67
|
+
*/
|
|
68
|
+
expectedUpdatedAt?: string | null
|
|
63
69
|
defaultValues?: {
|
|
64
70
|
type?: string
|
|
65
71
|
recipients?: string[]
|
|
@@ -2,7 +2,9 @@ import * as React from 'react'
|
|
|
2
2
|
import { useQuery } from '@tanstack/react-query'
|
|
3
3
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
4
4
|
import { flash } from '../FlashMessages'
|
|
5
|
-
import { apiCall } from '../utils/apiCall'
|
|
5
|
+
import { apiCall, withScopedApiRequestHeaders } from '../utils/apiCall'
|
|
6
|
+
import { buildOptimisticLockHeader } from '../utils/optimisticLock'
|
|
7
|
+
import { surfaceRecordConflict } from '../conflicts'
|
|
6
8
|
import type {
|
|
7
9
|
AttachmentListResponse,
|
|
8
10
|
MessageComposerProps,
|
|
@@ -127,6 +129,7 @@ export function useMessageCompose({
|
|
|
127
129
|
requiredActionConfig = null,
|
|
128
130
|
contextPreview = null,
|
|
129
131
|
defaultValues,
|
|
132
|
+
expectedUpdatedAt = null,
|
|
130
133
|
onSuccess,
|
|
131
134
|
onCancel,
|
|
132
135
|
}: UseMessageComposeParams): UseMessageComposeResult {
|
|
@@ -614,16 +617,37 @@ export function useMessageCompose({
|
|
|
614
617
|
if (!shouldReturnFalse) {
|
|
615
618
|
const { endpoint, method, payload } = operation.buildRequest({ attachmentIds: nextAttachmentIds })
|
|
616
619
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
620
|
+
// Editing an existing draft is a PATCH on the message aggregate, so carry
|
|
621
|
+
// the OSS optimistic-lock header to reject a stale overwrite. New
|
|
622
|
+
// compose/reply/forward writes create fresh records and never lock.
|
|
623
|
+
const lockHeaders = isEditingExistingDraft
|
|
624
|
+
? buildOptimisticLockHeader(expectedUpdatedAt)
|
|
625
|
+
: {}
|
|
626
|
+
|
|
627
|
+
const call = await withScopedApiRequestHeaders(lockHeaders, () =>
|
|
628
|
+
apiCall<{ id?: string }>(endpoint, {
|
|
629
|
+
method,
|
|
630
|
+
headers: { 'Content-Type': 'application/json' },
|
|
631
|
+
body: JSON.stringify(payload),
|
|
632
|
+
}),
|
|
633
|
+
)
|
|
622
634
|
|
|
623
635
|
if (!call.ok) {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
636
|
+
if (surfaceRecordConflict({ status: call.status, body: call.result }, t)) {
|
|
637
|
+
setSubmitError(
|
|
638
|
+
t('ui.forms.flash.recordModified', 'This record was modified by someone else. Refresh and try again.'),
|
|
639
|
+
)
|
|
640
|
+
// The shared conflict banner renders at page level, so it is hidden
|
|
641
|
+
// behind the compose modal. Close the dialog to reveal it (with its
|
|
642
|
+
// Refresh action); the stale draft must be reloaded anyway (#3260 QA).
|
|
643
|
+
if (!inline) {
|
|
644
|
+
onOpenChange?.(false)
|
|
645
|
+
}
|
|
646
|
+
} else {
|
|
647
|
+
const message = toErrorMessage(call.result) ?? t('messages.errors.sendFailed', 'Failed to send message.')
|
|
648
|
+
setSubmitError(message)
|
|
649
|
+
flash(message, 'error')
|
|
650
|
+
}
|
|
627
651
|
shouldReturnFalse = true
|
|
628
652
|
} else {
|
|
629
653
|
flash(operation.successMessage, 'success')
|
|
@@ -665,6 +689,7 @@ export function useMessageCompose({
|
|
|
665
689
|
attachmentIds,
|
|
666
690
|
composeDraftOperation,
|
|
667
691
|
composeSendOperation,
|
|
692
|
+
expectedUpdatedAt,
|
|
668
693
|
forwardOperation,
|
|
669
694
|
inline,
|
|
670
695
|
loadAttachmentIds,
|