@open-mercato/ui 0.6.5-develop.4534.1.b459babe6d → 0.6.5-develop.4544.1.71c003c861

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 (41) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -0
  3. package/dist/backend/AppShell.js +6 -0
  4. package/dist/backend/AppShell.js.map +2 -2
  5. package/dist/backend/CrudForm.js +57 -11
  6. package/dist/backend/CrudForm.js.map +2 -2
  7. package/dist/backend/DataTable.js +20 -8
  8. package/dist/backend/DataTable.js.map +3 -3
  9. package/dist/backend/PerspectiveSidebar.js +8 -1
  10. package/dist/backend/PerspectiveSidebar.js.map +2 -2
  11. package/dist/backend/RowActions.js +1 -1
  12. package/dist/backend/RowActions.js.map +2 -2
  13. package/dist/backend/conflicts/RecordConflictBanner.js +73 -0
  14. package/dist/backend/conflicts/RecordConflictBanner.js.map +7 -0
  15. package/dist/backend/conflicts/index.js +32 -0
  16. package/dist/backend/conflicts/index.js.map +7 -0
  17. package/dist/backend/conflicts/store.js +50 -0
  18. package/dist/backend/conflicts/store.js.map +7 -0
  19. package/dist/backend/injection/useGuardedMutation.js +8 -1
  20. package/dist/backend/injection/useGuardedMutation.js.map +2 -2
  21. package/dist/backend/sidebar/SidebarCustomizationEditor.js +23 -7
  22. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  23. package/dist/backend/utils/optimisticLock.js +34 -0
  24. package/dist/backend/utils/optimisticLock.js.map +7 -0
  25. package/package.json +7 -3
  26. package/src/backend/AppShell.tsx +10 -0
  27. package/src/backend/CrudForm.tsx +99 -9
  28. package/src/backend/DataTable.tsx +22 -8
  29. package/src/backend/PerspectiveSidebar.tsx +8 -1
  30. package/src/backend/RowActions.tsx +1 -1
  31. package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +288 -0
  32. package/src/backend/conflicts/RecordConflictBanner.tsx +77 -0
  33. package/src/backend/conflicts/__tests__/RecordConflictBanner.test.tsx +61 -0
  34. package/src/backend/conflicts/__tests__/store.test.ts +78 -0
  35. package/src/backend/conflicts/index.ts +49 -0
  36. package/src/backend/conflicts/store.ts +94 -0
  37. package/src/backend/injection/__tests__/useGuardedMutation.optimisticLock.test.tsx +118 -0
  38. package/src/backend/injection/useGuardedMutation.ts +16 -1
  39. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +35 -11
  40. package/src/backend/utils/__tests__/optimisticLock.test.ts +126 -0
  41. package/src/backend/utils/optimisticLock.ts +70 -0
@@ -24,6 +24,8 @@ import { QueryProvider } from '../theme/QueryProvider'
24
24
  import { usePathname, useSearchParams } from 'next/navigation'
25
25
  import { apiCall } from './utils/apiCall'
26
26
  import { LastOperationBanner } from './operations/LastOperationBanner'
27
+ import { RecordConflictBanner } from './conflicts/RecordConflictBanner'
28
+ import { dismissRecordConflict } from './conflicts/store'
27
29
  import { ProgressTopBar } from './progress/ProgressTopBar'
28
30
  import { UpgradeActionBanner } from './upgrades/UpgradeActionBanner'
29
31
  import { PartialIndexBanner } from './indexes/PartialIndexBanner'
@@ -447,6 +449,13 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
447
449
  // section sidebar by default. Set to 'main' to force-show the main nav even
448
450
  // when the route is in a section context. Reset on close.
449
451
  const [mobileDrawerView, setMobileDrawerView] = React.useState<'auto' | 'main'>('auto')
452
+ // Clear the persistent record-conflict bar when the route changes. The
453
+ // conflict is scoped to the record the user was editing, so navigating to an
454
+ // unrelated page should dismiss it instead of carrying a stale "Record
455
+ // changed" bar across modules.
456
+ React.useEffect(() => {
457
+ dismissRecordConflict()
458
+ }, [pathname])
450
459
  React.useEffect(() => {
451
460
  if (!mobileOpen) setMobileDrawerView('auto')
452
461
  }, [mobileOpen])
@@ -1349,6 +1358,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1349
1358
  <PartialIndexBanner />
1350
1359
  {canManageUpgradeActions ? <UpgradeActionBanner /> : null}
1351
1360
  <LastOperationBanner />
1361
+ <RecordConflictBanner />
1352
1362
  <InjectionSpot spotId={BACKEND_RECORD_CURRENT_INJECTION_SPOT_ID} context={injectionContext} />
1353
1363
  <InjectionSpot
1354
1364
  spotId={LEGACY_GLOBAL_MUTATION_INJECTION_SPOT_ID}
@@ -35,6 +35,7 @@ import { FormFooter } from './forms/FormFooter'
35
35
  import { Button } from '../primitives/button'
36
36
  import { IconButton } from '../primitives/icon-button'
37
37
  import {
38
+ Check,
38
39
  Settings,
39
40
  Layers,
40
41
  Tag,
@@ -85,6 +86,8 @@ import { TimePicker } from './inputs/TimePicker'
85
86
  import { DatePicker } from './inputs/DatePicker'
86
87
  import { mapCrudServerErrorToFormErrors, parseServerMessage } from './utils/serverErrors'
87
88
  import { withScopedApiRequestHeaders } from './utils/apiCall'
89
+ import { buildOptimisticLockHeader, extractOptimisticLockConflict } from './utils/optimisticLock'
90
+ import { surfaceRecordConflict } from './conflicts'
88
91
  import type { CustomFieldDefLike } from '@open-mercato/shared/modules/entities/validation'
89
92
  import type { MDEditorProps as UiWMDEditorProps } from '@uiw/react-md-editor'
90
93
  import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../primitives/dialog'
@@ -307,6 +310,30 @@ export type CrudFormProps<TValues extends Record<string, unknown>> = {
307
310
  onDelete?: () => Promise<void> | void
308
311
  // When true, shows Delete button whenever onDelete is provided, even without an id
309
312
  deleteVisible?: boolean
313
+ /**
314
+ * OSS opt-in optimistic locking (spec: .ai/specs/2026-05-25-oss-optimistic-locking.md).
315
+ *
316
+ * When set to a non-empty ISO-8601 string (typically `record.updatedAt`
317
+ * from the API response), the form auto-injects the
318
+ * `x-om-ext-optimistic-lock-expected-updated-at` extension header on
319
+ * every PUT / PATCH / DELETE issued through the underlying `onSubmit` /
320
+ * `onDelete` callbacks (via `withScopedApiRequestHeaders`). When the
321
+ * server-side guard is opted in for the resource and the timestamp is
322
+ * stale, the API responds with a structured 409 conflict body.
323
+ *
324
+ * Strictly additive: when the prop is absent / null / empty, the form
325
+ * behaves exactly as before (no header injected).
326
+ */
327
+ optimisticLockUpdatedAt?: string | null
328
+ /**
329
+ * Opt out of optimistic locking entirely for this form. When `true`, the
330
+ * extension header is never attached, regardless of `optimisticLockUpdatedAt`
331
+ * or the value auto-derived from `initialValues.updatedAt`. Use for forms
332
+ * whose locking is owned elsewhere (e.g. sales document sub-resources guarded
333
+ * at the command layer against the parent aggregate) or for entities without
334
+ * an `updated_at` column.
335
+ */
336
+ disableOptimisticLock?: boolean
310
337
  // Legacy field-only grid toggle. Use `groups` for advanced layout.
311
338
  twoColumn?: boolean
312
339
  title?: string
@@ -665,6 +692,8 @@ export function CrudForm<TValues extends Record<string, unknown>>({
665
692
  onSubmit,
666
693
  onDelete,
667
694
  deleteVisible,
695
+ optimisticLockUpdatedAt,
696
+ disableOptimisticLock = false,
668
697
  twoColumn = false,
669
698
  title,
670
699
  backHref,
@@ -788,6 +817,28 @@ export function CrudForm<TValues extends Record<string, unknown>>({
788
817
  )
789
818
 
790
819
  const operation = recordId ? 'update' : 'create'
820
+
821
+ // Resolve the optimistic-lock version used to build the extension header.
822
+ // Explicit `optimisticLockUpdatedAt` (including `null`) always wins. When the
823
+ // prop is absent, auto-derive from the loaded record's `updatedAt`/`updated_at`
824
+ // so every edit CrudForm enforces optimistic locking by default without
825
+ // per-form wiring. Presence of a loaded `updatedAt` — NOT the create/update
826
+ // heuristic — drives this: some edit pages keep the record id outside form
827
+ // values (so `operation` reads as `create`), while a genuine create has no
828
+ // `updatedAt` in `initialValues`. Even a "duplicate"-style create that carries
829
+ // one is harmless — the server ignores the header on inserts (no candidate id).
830
+ // `disableOptimisticLock` always wins; an explicit prop (incl. `null`) wins next.
831
+ const resolvedOptimisticLockUpdatedAt = React.useMemo<string | null | undefined>(() => {
832
+ if (disableOptimisticLock) return null
833
+ if (optimisticLockUpdatedAt !== undefined) return optimisticLockUpdatedAt
834
+ const source = initialValues as Record<string, unknown> | undefined
835
+ const camel = source?.updatedAt
836
+ if (typeof camel === 'string' && camel.length > 0) return camel
837
+ const snake = source?.updated_at
838
+ if (typeof snake === 'string' && snake.length > 0) return snake
839
+ return null
840
+ }, [disableOptimisticLock, optimisticLockUpdatedAt, initialValues])
841
+
791
842
  const injectionContext = React.useMemo(() => ({
792
843
  formId,
793
844
  entityId: primaryEntityId,
@@ -1265,8 +1316,13 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1265
1316
  }
1266
1317
  }
1267
1318
 
1268
- if (injectionRequestHeaders && Object.keys(injectionRequestHeaders).length > 0) {
1269
- await withScopedApiRequestHeaders(injectionRequestHeaders, async () => {
1319
+ const optimisticLockHeader = buildOptimisticLockHeader(resolvedOptimisticLockUpdatedAt)
1320
+ const mergedDeleteHeaders = {
1321
+ ...(injectionRequestHeaders ?? {}),
1322
+ ...optimisticLockHeader,
1323
+ }
1324
+ if (Object.keys(mergedDeleteHeaders).length > 0) {
1325
+ await withScopedApiRequestHeaders(mergedDeleteHeaders, async () => {
1270
1326
  await onDelete()
1271
1327
  })
1272
1328
  } else {
@@ -1310,8 +1366,14 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1310
1366
  } catch {
1311
1367
  // ignore event dispatch failures
1312
1368
  }
1313
- const message = err instanceof Error && err.message ? err.message : deleteErrorMessage
1314
- try { flash(message, 'error') } catch {}
1369
+ const optimisticLockConflict = extractOptimisticLockConflict(err)
1370
+ if (optimisticLockConflict) {
1371
+ // Surface on the unified, persistent conflict bar instead of a toast.
1372
+ try { surfaceRecordConflict(err, t) } catch {}
1373
+ } else {
1374
+ const message = err instanceof Error && err.message ? err.message : deleteErrorMessage
1375
+ try { flash(message, 'error') } catch {}
1376
+ }
1315
1377
  } finally {
1316
1378
  deletingRef.current = false
1317
1379
  setPending(false)
@@ -2631,8 +2693,13 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2631
2693
 
2632
2694
  try {
2633
2695
  submitNavigationBypassRef.current = true
2634
- if (injectionRequestHeaders && Object.keys(injectionRequestHeaders).length > 0) {
2635
- await withScopedApiRequestHeaders(injectionRequestHeaders, async () => {
2696
+ const optimisticLockHeader = buildOptimisticLockHeader(resolvedOptimisticLockUpdatedAt)
2697
+ const mergedSubmitHeaders = {
2698
+ ...(injectionRequestHeaders ?? {}),
2699
+ ...optimisticLockHeader,
2700
+ }
2701
+ if (Object.keys(mergedSubmitHeaders).length > 0) {
2702
+ await withScopedApiRequestHeaders(mergedSubmitHeaders, async () => {
2636
2703
  await onSubmit?.(coreSubmitValues, submitContext)
2637
2704
  })
2638
2705
  } else {
@@ -2690,7 +2757,10 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2690
2757
  }
2691
2758
  }
2692
2759
 
2693
- let displayMessage = typeof helperMessage === 'string' && helperMessage.trim() ? helperMessage.trim() : ''
2760
+ const optimisticLockConflict = extractOptimisticLockConflict(err)
2761
+ let displayMessage = optimisticLockConflict
2762
+ ? t('ui.forms.flash.recordModified', 'This record was modified by someone else. Refresh and try again.')
2763
+ : typeof helperMessage === 'string' && helperMessage.trim() ? helperMessage.trim() : ''
2694
2764
  if (hasFieldErrors) {
2695
2765
  const lowered = displayMessage.toLowerCase()
2696
2766
  const highlightedLower = highlightedMessage.toLowerCase()
@@ -2705,7 +2775,12 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2705
2775
  displayMessage = hasFieldErrors ? highlightedMessage : saveErrorMessage
2706
2776
  }
2707
2777
  displayMessage = parseServerMessage(displayMessage)
2708
- if (!hasFieldErrors) {
2778
+ if (optimisticLockConflict) {
2779
+ // Primary surface for the conflict is the persistent, error-styled
2780
+ // RecordConflictBanner (unified across all forms). Keep the inline
2781
+ // form error too, but suppress the redundant transient toast.
2782
+ try { surfaceRecordConflict(err, t) } catch {}
2783
+ } else if (!hasFieldErrors) {
2709
2784
  flash(displayMessage, 'error')
2710
2785
  }
2711
2786
  setFormError(displayMessage)
@@ -3989,7 +4064,22 @@ const ListboxMultiSelect = React.memo(function ListboxMultiSelect({
3989
4064
  className={`w-full justify-start rounded-none font-normal px-3 py-2 ${isSel ? 'bg-muted' : ''}`}
3990
4065
  >
3991
4066
  <span className="inline-flex items-center gap-2">
3992
- <Checkbox checked={isSel} disabled tabIndex={-1} className="pointer-events-none" />
4067
+ {/* Decorative selection indicator — NOT an interactive Checkbox.
4068
+ The enclosing Button owns the click; a real Radix Checkbox here
4069
+ nests a <button> inside a <button> (hydration error) and its
4070
+ Presence/ref lifecycle drives an infinite update loop when the
4071
+ listbox renders selected values. A plain span mirrors the DS
4072
+ checkbox look without any of that. */}
4073
+ <span
4074
+ aria-hidden="true"
4075
+ className={`inline-flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-colors ${
4076
+ isSel
4077
+ ? 'border-accent-indigo bg-accent-indigo text-accent-indigo-foreground'
4078
+ : 'border-input bg-background'
4079
+ }`}
4080
+ >
4081
+ {isSel ? <Check className="size-3.5" aria-hidden="true" /> : null}
4082
+ </span>
3993
4083
  <span>{opt.label}</span>
3994
4084
  </span>
3995
4085
  </Button>
@@ -31,7 +31,9 @@ import { useAppEvent } from './injection/useAppEvent'
31
31
  import { useInjectionDataWidgets } from './injection/useInjectionDataWidgets'
32
32
  import { resolveInjectedIcon } from './injection/resolveInjectedIcon'
33
33
  import { serializeExport, defaultExportFilename, type PreparedExport } from '@open-mercato/shared/lib/crud/exporters'
34
- import { apiCall } from './utils/apiCall'
34
+ import { apiCall, withScopedApiRequestHeaders } from './utils/apiCall'
35
+ import { buildOptimisticLockHeader } from './utils/optimisticLock'
36
+ import { surfaceRecordConflict } from './conflicts'
35
37
  import { raiseCrudError } from './utils/serverErrors'
36
38
  import { PerspectiveSidebar } from './PerspectiveSidebar'
37
39
  import { Popover, PopoverTrigger, PopoverContent } from '../primitives/popover'
@@ -1716,13 +1718,19 @@ export function DataTable<T>({
1716
1718
  // eslint-disable-next-line no-console
1717
1719
  console.debug('[DataTable] perspective payload', payload)
1718
1720
  }
1719
- const call = await apiCall<PerspectiveSaveResponse>(
1720
- `/api/perspectives/${encodeURIComponent(perspectiveTableId)}`,
1721
- {
1722
- method: 'POST',
1723
- headers: { 'Content-Type': 'application/json' },
1724
- body: JSON.stringify(payload),
1725
- },
1721
+ const existing = input.perspectiveId
1722
+ ? perspectiveData?.perspectives.find((p) => p.id === input.perspectiveId) ?? null
1723
+ : null
1724
+ const call = await withScopedApiRequestHeaders(
1725
+ buildOptimisticLockHeader(existing?.updatedAt ?? null),
1726
+ () => apiCall<PerspectiveSaveResponse>(
1727
+ `/api/perspectives/${encodeURIComponent(perspectiveTableId)}`,
1728
+ {
1729
+ method: 'POST',
1730
+ headers: { 'Content-Type': 'application/json' },
1731
+ body: JSON.stringify(payload),
1732
+ },
1733
+ ),
1726
1734
  )
1727
1735
  if (call.status === 404) {
1728
1736
  throw new Error(t('ui.dataTable.perspectives.error.apiUnavailable', 'Perspectives API is not available. Run `yarn generate` to regenerate module routes and restart the dev server.'))
@@ -1742,6 +1750,12 @@ export function DataTable<T>({
1742
1750
  applyPerspectiveSettings(data.perspective.settings, data.perspective.id)
1743
1751
  }
1744
1752
  },
1753
+ onError: (error) => {
1754
+ if (perspectiveTableId) {
1755
+ void queryClient.invalidateQueries({ queryKey: perspectiveQueryKey })
1756
+ }
1757
+ surfaceRecordConflict(error, t)
1758
+ },
1745
1759
  })
1746
1760
 
1747
1761
  const resolveColumnLabel = React.useCallback((column: TableColumn<T, unknown>): string => {
@@ -7,6 +7,7 @@ import { Checkbox } from '../primitives/checkbox'
7
7
  import { Spinner } from '../primitives/spinner'
8
8
  import { useConfirmDialog } from './confirm-dialog'
9
9
  import { flash } from './FlashMessages'
10
+ import { surfaceRecordConflict } from './conflicts'
10
11
  import { useT } from '@open-mercato/shared/lib/i18n/context'
11
12
  import type {
12
13
  PerspectiveDto,
@@ -133,7 +134,8 @@ export function PerspectiveSidebar({
133
134
  perspectiveId: targetId,
134
135
  })
135
136
  flash(t('ui.perspectives.autosave.success', 'View saved'), 'success')
136
- } catch {
137
+ } catch (err: unknown) {
138
+ if (surfaceRecordConflict(err, t)) return
137
139
  flash(t('ui.perspectives.autosave.error', 'Failed to save view'), 'error')
138
140
  }
139
141
  }, 400)
@@ -199,6 +201,7 @@ export function PerspectiveSidebar({
199
201
  await onSave({ name: name.trim(), isDefault, applyToRoles: [], setRoleDefault: false, perspectiveId: null })
200
202
  resetMode()
201
203
  } catch (err: unknown) {
204
+ if (surfaceRecordConflict(err, t)) return
202
205
  setError(err instanceof Error ? err.message : 'Failed to save view')
203
206
  }
204
207
  }
@@ -237,6 +240,7 @@ export function PerspectiveSidebar({
237
240
  })
238
241
  setRenamingId(null)
239
242
  } catch (err: unknown) {
243
+ if (surfaceRecordConflict(err, t)) return
240
244
  setError(err instanceof Error ? err.message : 'Failed to rename view')
241
245
  }
242
246
  }
@@ -282,6 +286,7 @@ export function PerspectiveSidebar({
282
286
  const msg = errMessage.toLowerCase()
283
287
  const isDuplicate = msg.includes('duplicate key') || msg.includes('unique constraint')
284
288
  if (!isDuplicate) {
289
+ if (surfaceRecordConflict(err, t)) return
285
290
  setError(errMessage || 'Failed to clone view')
286
291
  return
287
292
  }
@@ -289,6 +294,7 @@ export function PerspectiveSidebar({
289
294
  counter += 1
290
295
  }
291
296
  }
297
+ if (surfaceRecordConflict(lastErr, t)) return
292
298
  setError(lastErr instanceof Error ? lastErr.message : 'Failed to clone view')
293
299
  }
294
300
 
@@ -333,6 +339,7 @@ export function PerspectiveSidebar({
333
339
  setSharedIds((prev) => new Set([...prev, mode.perspectiveId]))
334
340
  resetMode()
335
341
  } catch (err: unknown) {
342
+ if (surfaceRecordConflict(err, t)) return
336
343
  setError(err instanceof Error ? err.message : 'Failed to share view')
337
344
  }
338
345
  }
@@ -100,7 +100,7 @@ export function RowActions({ items = [] }: { items?: RowActionItem[] }) {
100
100
  variant="ghost"
101
101
  aria-haspopup="menu"
102
102
  aria-expanded={open}
103
- onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); requestAnimationFrame(updatePosition) }}
103
+ onClick={(e) => { e.stopPropagation(); setOpen(true); requestAnimationFrame(updatePosition) }}
104
104
  >
105
105
  <span aria-hidden="true">⋯</span>
106
106
  <span className="sr-only">{t('ui.rowActions.openActions', 'Open actions')}</span>
@@ -0,0 +1,288 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ import {
4
+ OPTIMISTIC_LOCK_CONFLICT_CODE,
5
+ OPTIMISTIC_LOCK_HEADER_NAME,
6
+ } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
7
+
8
+ const buildFormFieldFromCustomFieldDefMock = jest.fn()
9
+ const fetchCustomFieldFormStructureMock = jest.fn()
10
+ const withScopedApiRequestHeadersMock = jest.fn(async (_headers: Record<string, string>, fn: () => Promise<unknown>) => fn())
11
+ const flashMock = jest.fn()
12
+
13
+ jest.mock('next/navigation', () => ({
14
+ useRouter: () => ({ push: jest.fn(), refresh: jest.fn() }),
15
+ usePathname: () => '/',
16
+ useSearchParams: () => new URLSearchParams(),
17
+ }))
18
+ jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
19
+ jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
20
+ jest.mock('../confirm-dialog', () => ({
21
+ useConfirmDialog: () => ({
22
+ confirm: jest.fn().mockResolvedValue(true),
23
+ ConfirmDialogElement: null,
24
+ }),
25
+ }))
26
+ jest.mock('../custom-fields/FieldDefinitionsManager', () => {
27
+ const React = require('react')
28
+ return {
29
+ __esModule: true,
30
+ FieldDefinitionsManager: React.forwardRef(() => <div>Field definitions manager</div>),
31
+ }
32
+ })
33
+ jest.mock('../utils/customFieldForms', () => ({
34
+ __esModule: true,
35
+ buildFormFieldFromCustomFieldDef: (...args: unknown[]) => buildFormFieldFromCustomFieldDefMock(...args),
36
+ buildFormFieldsFromCustomFields: jest.fn(() => []),
37
+ fetchCustomFieldFormStructure: (...args: unknown[]) => fetchCustomFieldFormStructureMock(...args),
38
+ }))
39
+ jest.mock('../utils/apiCall', () => {
40
+ const actual = jest.requireActual('../utils/apiCall')
41
+ return {
42
+ ...actual,
43
+ withScopedApiRequestHeaders: (...args: unknown[]) =>
44
+ withScopedApiRequestHeadersMock(
45
+ args[0] as Record<string, string>,
46
+ args[1] as () => Promise<unknown>,
47
+ ),
48
+ }
49
+ })
50
+ jest.mock('../FlashMessages', () => ({
51
+ flash: (...args: unknown[]) => flashMock(...args),
52
+ }))
53
+
54
+ import * as React from 'react'
55
+ import { act, fireEvent, waitFor } from '@testing-library/react'
56
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
57
+ import { CrudForm, type CrudField } from '../CrudForm'
58
+ import { dismissRecordConflict, getRecordConflictForTest } from '../conflicts'
59
+
60
+ const dict = {
61
+ 'ui.forms.actions.save': 'Save',
62
+ 'ui.forms.flash.recordModified': 'This record was modified by someone else. Refresh and try again.',
63
+ }
64
+
65
+ const fields: CrudField[] = [
66
+ { id: 'displayName', label: 'Display name', type: 'text' },
67
+ ]
68
+
69
+ function renderForm(opts: {
70
+ optimisticLockUpdatedAt?: string | null
71
+ onSubmit: jest.Mock
72
+ }) {
73
+ return renderWithProviders(
74
+ <CrudForm<{ displayName: string; id?: string }>
75
+ fields={fields}
76
+ initialValues={{ id: 'rec-1', displayName: 'old' }}
77
+ onSubmit={opts.onSubmit}
78
+ optimisticLockUpdatedAt={opts.optimisticLockUpdatedAt}
79
+ />,
80
+ { dict },
81
+ )
82
+ }
83
+
84
+ describe('CrudForm — optimisticLockUpdatedAt prop wiring', () => {
85
+ beforeEach(() => {
86
+ flashMock.mockClear()
87
+ withScopedApiRequestHeadersMock.mockClear()
88
+ fetchCustomFieldFormStructureMock.mockResolvedValue({
89
+ fields: [],
90
+ definitions: [],
91
+ metadata: { items: [], fieldsetsByEntity: {}, entitySettings: {} },
92
+ })
93
+ })
94
+
95
+ it('wraps onSubmit in withScopedApiRequestHeaders carrying the extension header when prop is set', async () => {
96
+ const onSubmit = jest.fn(async () => undefined)
97
+ const { container } = renderForm({ optimisticLockUpdatedAt: '2026-05-25T08:42:18.123Z', onSubmit })
98
+ const form = container.querySelector('form') as HTMLFormElement
99
+ expect(form).not.toBeNull()
100
+ await act(async () => {
101
+ fireEvent.submit(form)
102
+ })
103
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
104
+ expect(withScopedApiRequestHeadersMock).toHaveBeenCalledTimes(1)
105
+ const headerArg = withScopedApiRequestHeadersMock.mock.calls[0][0]
106
+ expect(headerArg).toEqual({
107
+ [OPTIMISTIC_LOCK_HEADER_NAME]: '2026-05-25T08:42:18.123Z',
108
+ })
109
+ })
110
+
111
+ it('does NOT wrap when optimisticLockUpdatedAt is omitted (no injection headers either)', async () => {
112
+ const onSubmit = jest.fn(async () => undefined)
113
+ const { container } = renderForm({ optimisticLockUpdatedAt: undefined, onSubmit })
114
+ const form = container.querySelector('form') as HTMLFormElement
115
+ await act(async () => {
116
+ fireEvent.submit(form)
117
+ })
118
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
119
+ expect(withScopedApiRequestHeadersMock).not.toHaveBeenCalled()
120
+ })
121
+
122
+ it('does NOT wrap when optimisticLockUpdatedAt is an empty string', async () => {
123
+ const onSubmit = jest.fn(async () => undefined)
124
+ const { container } = renderForm({ optimisticLockUpdatedAt: '', onSubmit })
125
+ const form = container.querySelector('form') as HTMLFormElement
126
+ await act(async () => {
127
+ fireEvent.submit(form)
128
+ })
129
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
130
+ expect(withScopedApiRequestHeadersMock).not.toHaveBeenCalled()
131
+ })
132
+
133
+ it('does NOT wrap when optimisticLockUpdatedAt is null', async () => {
134
+ const onSubmit = jest.fn(async () => undefined)
135
+ const { container } = renderForm({ optimisticLockUpdatedAt: null, onSubmit })
136
+ const form = container.querySelector('form') as HTMLFormElement
137
+ await act(async () => {
138
+ fireEvent.submit(form)
139
+ })
140
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
141
+ expect(withScopedApiRequestHeadersMock).not.toHaveBeenCalled()
142
+ })
143
+
144
+ it('surfaces the localized record-modified message on the unified conflict bar (not a raw-token toast) on stale saves', async () => {
145
+ dismissRecordConflict()
146
+ const conflict = Object.assign(new Error('record_modified'), {
147
+ status: 409,
148
+ error: 'record_modified',
149
+ code: OPTIMISTIC_LOCK_CONFLICT_CODE,
150
+ currentUpdatedAt: '2026-05-25T08:42:19.000Z',
151
+ expectedUpdatedAt: '2026-05-25T08:42:18.000Z',
152
+ })
153
+ const onSubmit = jest.fn(async () => {
154
+ throw conflict
155
+ })
156
+ const { container } = renderForm({ optimisticLockUpdatedAt: '2026-05-25T08:42:18.000Z', onSubmit })
157
+ const form = container.querySelector('form') as HTMLFormElement
158
+
159
+ await act(async () => {
160
+ fireEvent.submit(form)
161
+ })
162
+
163
+ await waitFor(() => {
164
+ const entry = getRecordConflictForTest()
165
+ expect(entry).not.toBeNull()
166
+ expect(entry?.message).toBe('This record was modified by someone else. Refresh and try again.')
167
+ })
168
+ // The conflict bar is the surface — no transient toast for the conflict.
169
+ expect(flashMock).not.toHaveBeenCalledWith('record_modified', 'error')
170
+ expect(flashMock).not.toHaveBeenCalledWith(
171
+ 'This record was modified by someone else. Refresh and try again.',
172
+ 'error',
173
+ )
174
+ })
175
+ })
176
+
177
+ type AutoFormValues = { displayName: string; id?: string; updatedAt?: string | null; updated_at?: string | null }
178
+
179
+ function renderAutoForm(opts: {
180
+ initialValues: Partial<AutoFormValues>
181
+ onSubmit: jest.Mock
182
+ optimisticLockUpdatedAt?: string | null
183
+ disableOptimisticLock?: boolean
184
+ passProp?: boolean
185
+ }) {
186
+ const props: Record<string, unknown> = {
187
+ fields,
188
+ initialValues: opts.initialValues,
189
+ onSubmit: opts.onSubmit,
190
+ }
191
+ if (opts.passProp) props.optimisticLockUpdatedAt = opts.optimisticLockUpdatedAt
192
+ if (opts.disableOptimisticLock !== undefined) props.disableOptimisticLock = opts.disableOptimisticLock
193
+ return renderWithProviders(
194
+ <CrudForm<AutoFormValues> {...(props as any)} />,
195
+ { dict },
196
+ )
197
+ }
198
+
199
+ async function submitAuto(container: HTMLElement) {
200
+ const form = container.querySelector('form') as HTMLFormElement
201
+ expect(form).not.toBeNull()
202
+ await act(async () => { fireEvent.submit(form) })
203
+ }
204
+
205
+ function sentLockHeaderValue(): string | undefined {
206
+ const call = withScopedApiRequestHeadersMock.mock.calls.find(
207
+ (c) => c[0] && Object.prototype.hasOwnProperty.call(c[0], OPTIMISTIC_LOCK_HEADER_NAME),
208
+ )
209
+ return call ? call[0][OPTIMISTIC_LOCK_HEADER_NAME] : undefined
210
+ }
211
+
212
+ describe('CrudForm — optimistic-lock auto-derive from initialValues.updatedAt (no prop)', () => {
213
+ beforeEach(() => {
214
+ flashMock.mockClear()
215
+ withScopedApiRequestHeadersMock.mockClear()
216
+ fetchCustomFieldFormStructureMock.mockResolvedValue({
217
+ fields: [], definitions: [], metadata: { items: [], fieldsetsByEntity: {}, entitySettings: {} },
218
+ })
219
+ })
220
+
221
+ it('edit mode: derives the header from initialValues.updatedAt when the prop is absent', async () => {
222
+ const onSubmit = jest.fn(async () => undefined)
223
+ const { container } = renderAutoForm({ initialValues: { id: 'rec-1', displayName: 'old', updatedAt: '2026-05-25T08:00:00.000Z' }, onSubmit })
224
+ await submitAuto(container)
225
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
226
+ expect(sentLockHeaderValue()).toBe('2026-05-25T08:00:00.000Z')
227
+ })
228
+
229
+ it('edit mode: falls back to snake_case updated_at', async () => {
230
+ const onSubmit = jest.fn(async () => undefined)
231
+ const { container } = renderAutoForm({ initialValues: { id: 'rec-1', displayName: 'old', updated_at: '2026-05-25T09:00:00.000Z' }, onSubmit })
232
+ await submitAuto(container)
233
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
234
+ expect(sentLockHeaderValue()).toBe('2026-05-25T09:00:00.000Z')
235
+ })
236
+
237
+ it('edit mode: no updatedAt anywhere → no header attached (no crash)', async () => {
238
+ const onSubmit = jest.fn(async () => undefined)
239
+ const { container } = renderAutoForm({ initialValues: { id: 'rec-1', displayName: 'old' }, onSubmit })
240
+ await submitAuto(container)
241
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
242
+ expect(sentLockHeaderValue()).toBeUndefined()
243
+ })
244
+
245
+ it('genuine create (no id, no updatedAt): never attaches', async () => {
246
+ const onSubmit = jest.fn(async () => undefined)
247
+ const { container } = renderAutoForm({ initialValues: { displayName: 'new' }, onSubmit })
248
+ await submitAuto(container)
249
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
250
+ expect(sentLockHeaderValue()).toBeUndefined()
251
+ })
252
+
253
+ it('attaches from updatedAt even when form values lack an id (edit page tracks the record id outside form values, e.g. catalog product)', async () => {
254
+ // Regression guard: presence of a loaded updatedAt — not CrudForm's
255
+ // create/update heuristic — drives the header. The catalog product edit page
256
+ // keeps productId out of form values, so a previous operation-gated version
257
+ // silently dropped the header here.
258
+ const onSubmit = jest.fn(async () => undefined)
259
+ const { container } = renderAutoForm({ initialValues: { displayName: 'old', updatedAt: '2026-05-25T08:00:00.000Z' }, onSubmit })
260
+ await submitAuto(container)
261
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
262
+ expect(sentLockHeaderValue()).toBe('2026-05-25T08:00:00.000Z')
263
+ })
264
+
265
+ it('explicit optimisticLockUpdatedAt={null} wins over auto-derive (no header)', async () => {
266
+ const onSubmit = jest.fn(async () => undefined)
267
+ const { container } = renderAutoForm({ initialValues: { id: 'rec-1', displayName: 'old', updatedAt: '2026-05-25T08:00:00.000Z' }, onSubmit, passProp: true, optimisticLockUpdatedAt: null })
268
+ await submitAuto(container)
269
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
270
+ expect(sentLockHeaderValue()).toBeUndefined()
271
+ })
272
+
273
+ it('explicit prop value wins over initialValues.updatedAt', async () => {
274
+ const onSubmit = jest.fn(async () => undefined)
275
+ const { container } = renderAutoForm({ initialValues: { id: 'rec-1', displayName: 'old', updatedAt: '2026-05-25T08:00:00.000Z' }, onSubmit, passProp: true, optimisticLockUpdatedAt: '2026-05-25T10:00:00.000Z' })
276
+ await submitAuto(container)
277
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
278
+ expect(sentLockHeaderValue()).toBe('2026-05-25T10:00:00.000Z')
279
+ })
280
+
281
+ it('disableOptimisticLock never attaches even when updatedAt present', async () => {
282
+ const onSubmit = jest.fn(async () => undefined)
283
+ const { container } = renderAutoForm({ initialValues: { id: 'rec-1', displayName: 'old', updatedAt: '2026-05-25T08:00:00.000Z' }, onSubmit, disableOptimisticLock: true })
284
+ await submitAuto(container)
285
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
286
+ expect(sentLockHeaderValue()).toBeUndefined()
287
+ })
288
+ })