@open-mercato/ui 0.6.4-develop.4382.1.6b4f656b77 → 0.6.4

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 (133) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/AGENTS.md +2 -1
  3. package/dist/backend/AppShell.js +6 -0
  4. package/dist/backend/AppShell.js.map +2 -2
  5. package/dist/backend/CrudForm.js +212 -85
  6. package/dist/backend/CrudForm.js.map +3 -3
  7. package/dist/backend/DataTable.js +77 -26
  8. package/dist/backend/DataTable.js.map +3 -3
  9. package/dist/backend/FlashMessages.js +21 -2
  10. package/dist/backend/FlashMessages.js.map +2 -2
  11. package/dist/backend/JsonBuilder.js +64 -41
  12. package/dist/backend/JsonBuilder.js.map +2 -2
  13. package/dist/backend/PerspectiveSidebar.js +8 -1
  14. package/dist/backend/PerspectiveSidebar.js.map +2 -2
  15. package/dist/backend/RowActions.js +1 -1
  16. package/dist/backend/RowActions.js.map +2 -2
  17. package/dist/backend/conflicts/RecordConflictBanner.js +73 -0
  18. package/dist/backend/conflicts/RecordConflictBanner.js.map +7 -0
  19. package/dist/backend/conflicts/index.js +32 -0
  20. package/dist/backend/conflicts/index.js.map +7 -0
  21. package/dist/backend/conflicts/store.js +50 -0
  22. package/dist/backend/conflicts/store.js.map +7 -0
  23. package/dist/backend/detail/ActivitiesSection.js +1 -0
  24. package/dist/backend/detail/ActivitiesSection.js.map +2 -2
  25. package/dist/backend/detail/InlineEditors.js +2 -51
  26. package/dist/backend/detail/InlineEditors.js.map +3 -3
  27. package/dist/backend/detail/RecordNotFoundState.js +10 -5
  28. package/dist/backend/detail/RecordNotFoundState.js.map +2 -2
  29. package/dist/backend/filters/AdvancedFilterPanel.js +14 -0
  30. package/dist/backend/filters/AdvancedFilterPanel.js.map +2 -2
  31. package/dist/backend/filters/FilteredEmptyResults.js +12 -4
  32. package/dist/backend/filters/FilteredEmptyResults.js.map +2 -2
  33. package/dist/backend/filters/ListEmptyState.js +44 -0
  34. package/dist/backend/filters/ListEmptyState.js.map +7 -0
  35. package/dist/backend/filters/SearchEmptyResults.js +28 -0
  36. package/dist/backend/filters/SearchEmptyResults.js.map +7 -0
  37. package/dist/backend/icons/lucideRegistry.generated.js +8 -0
  38. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  39. package/dist/backend/injection/useGuardedMutation.js +8 -1
  40. package/dist/backend/injection/useGuardedMutation.js.map +2 -2
  41. package/dist/backend/inputs/MarkdownField.js +20 -0
  42. package/dist/backend/inputs/MarkdownField.js.map +7 -0
  43. package/dist/backend/inputs/MdxEditorImpl.js +98 -0
  44. package/dist/backend/inputs/MdxEditorImpl.js.map +7 -0
  45. package/dist/backend/inputs/SwitchableMarkdownInput.js +6 -38
  46. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
  47. package/dist/backend/markdown/markdownToPlainText.js +29 -0
  48. package/dist/backend/markdown/markdownToPlainText.js.map +2 -2
  49. package/dist/backend/messages/EmailThreadsPanel.js +212 -0
  50. package/dist/backend/messages/EmailThreadsPanel.js.map +7 -0
  51. package/dist/backend/messages/index.js +4 -0
  52. package/dist/backend/messages/index.js.map +2 -2
  53. package/dist/backend/messages/mergeOptimisticEmailThreads.js +55 -0
  54. package/dist/backend/messages/mergeOptimisticEmailThreads.js.map +7 -0
  55. package/dist/backend/notifications/NotificationDispatcher.js +1 -0
  56. package/dist/backend/notifications/NotificationDispatcher.js.map +2 -2
  57. package/dist/backend/notifications/useNotificationsPoll.js +7 -0
  58. package/dist/backend/notifications/useNotificationsPoll.js.map +2 -2
  59. package/dist/backend/notifications/useNotificationsSse.js +8 -0
  60. package/dist/backend/notifications/useNotificationsSse.js.map +2 -2
  61. package/dist/backend/sidebar/SidebarCustomizationEditor.js +23 -7
  62. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  63. package/dist/backend/utils/optimisticLock.js +34 -0
  64. package/dist/backend/utils/optimisticLock.js.map +7 -0
  65. package/dist/portal/hooks/useTenantContext.js +1 -1
  66. package/dist/portal/hooks/useTenantContext.js.map +2 -2
  67. package/dist/primitives/alert.js.map +1 -1
  68. package/dist/primitives/checkbox.js +2 -1
  69. package/dist/primitives/checkbox.js.map +2 -2
  70. package/dist/types/css.d.js +1 -0
  71. package/dist/types/css.d.js.map +7 -0
  72. package/package.json +17 -13
  73. package/src/backend/AppShell.tsx +10 -0
  74. package/src/backend/CrudForm.tsx +320 -97
  75. package/src/backend/DataTable.tsx +113 -34
  76. package/src/backend/FlashMessages.tsx +31 -2
  77. package/src/backend/JsonBuilder.tsx +79 -26
  78. package/src/backend/PerspectiveSidebar.tsx +8 -1
  79. package/src/backend/RowActions.tsx +1 -1
  80. package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +0 -1
  81. package/src/backend/__tests__/CrudForm.datetime.test.tsx +0 -1
  82. package/src/backend/__tests__/CrudForm.defaults.test.tsx +0 -1
  83. package/src/backend/__tests__/CrudForm.dotPath.test.tsx +311 -0
  84. package/src/backend/__tests__/CrudForm.doubleSubmit.test.tsx +0 -1
  85. package/src/backend/__tests__/CrudForm.navigation.test.tsx +120 -1
  86. package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +287 -0
  87. package/src/backend/__tests__/CrudForm.render.test.tsx +74 -2
  88. package/src/backend/__tests__/CrudForm.sortable.test.tsx +0 -1
  89. package/src/backend/__tests__/CrudForm.transformData.test.tsx +0 -1
  90. package/src/backend/__tests__/CrudForm.unsavedNavigation.test.tsx +51 -3
  91. package/src/backend/__tests__/CrudForm.validation.test.tsx +0 -1
  92. package/src/backend/__tests__/CustomDataSection.test.tsx +0 -1
  93. package/src/backend/__tests__/FlashMessages.test.tsx +47 -0
  94. package/src/backend/__tests__/JsonBuilder.test.tsx +135 -0
  95. package/src/backend/__tests__/lazy-heavy-libraries.test.ts +16 -3
  96. package/src/backend/conflicts/RecordConflictBanner.tsx +77 -0
  97. package/src/backend/conflicts/__tests__/RecordConflictBanner.test.tsx +61 -0
  98. package/src/backend/conflicts/__tests__/store.test.ts +78 -0
  99. package/src/backend/conflicts/index.ts +49 -0
  100. package/src/backend/conflicts/store.ts +94 -0
  101. package/src/backend/detail/ActivitiesSection.tsx +1 -0
  102. package/src/backend/detail/InlineEditors.tsx +14 -76
  103. package/src/backend/detail/RecordNotFoundState.tsx +13 -6
  104. package/src/backend/detail/__tests__/RecordNotFoundState.test.tsx +80 -0
  105. package/src/backend/filters/AdvancedFilterPanel.tsx +27 -0
  106. package/src/backend/filters/FilteredEmptyResults.tsx +23 -10
  107. package/src/backend/filters/ListEmptyState.tsx +69 -0
  108. package/src/backend/filters/SearchEmptyResults.tsx +42 -0
  109. package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +98 -0
  110. package/src/backend/filters/__tests__/ListEmptyState.test.tsx +53 -0
  111. package/src/backend/icons/lucideRegistry.generated.tsx +8 -0
  112. package/src/backend/injection/__tests__/useGuardedMutation.optimisticLock.test.tsx +118 -0
  113. package/src/backend/injection/useGuardedMutation.ts +16 -1
  114. package/src/backend/inputs/MarkdownField.tsx +34 -0
  115. package/src/backend/inputs/MdxEditorImpl.tsx +113 -0
  116. package/src/backend/inputs/SwitchableMarkdownInput.tsx +5 -54
  117. package/src/backend/markdown/__tests__/markdownToPlainText.test.ts +13 -0
  118. package/src/backend/markdown/markdownToPlainText.ts +41 -0
  119. package/src/backend/messages/EmailThreadsPanel.tsx +351 -0
  120. package/src/backend/messages/__tests__/mergeOptimisticEmailThreads.test.ts +126 -0
  121. package/src/backend/messages/index.ts +12 -0
  122. package/src/backend/messages/mergeOptimisticEmailThreads.ts +84 -0
  123. package/src/backend/notifications/NotificationDispatcher.ts +2 -0
  124. package/src/backend/notifications/useNotificationsPoll.ts +5 -0
  125. package/src/backend/notifications/useNotificationsSse.ts +6 -0
  126. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +35 -11
  127. package/src/backend/utils/__tests__/optimisticLock.test.ts +126 -0
  128. package/src/backend/utils/optimisticLock.ts +70 -0
  129. package/src/portal/hooks/useTenantContext.ts +7 -2
  130. package/src/primitives/__tests__/checkbox.test.tsx +28 -0
  131. package/src/primitives/alert.tsx +1 -1
  132. package/src/primitives/checkbox.tsx +2 -1
  133. package/src/types/css.d.ts +5 -0
@@ -29,12 +29,12 @@ import {
29
29
  SelectValue,
30
30
  } from '../primitives/select'
31
31
  import { flash } from './FlashMessages'
32
- import dynamic from 'next/dynamic'
33
32
  import { FormHeader } from './forms/FormHeader'
34
33
  import { FormFooter } from './forms/FormFooter'
35
34
  import { Button } from '../primitives/button'
36
35
  import { IconButton } from '../primitives/icon-button'
37
36
  import {
37
+ Check,
38
38
  Settings,
39
39
  Layers,
40
40
  Tag,
@@ -85,8 +85,9 @@ import { TimePicker } from './inputs/TimePicker'
85
85
  import { DatePicker } from './inputs/DatePicker'
86
86
  import { mapCrudServerErrorToFormErrors, parseServerMessage } from './utils/serverErrors'
87
87
  import { withScopedApiRequestHeaders } from './utils/apiCall'
88
+ import { buildOptimisticLockHeader, extractOptimisticLockConflict } from './utils/optimisticLock'
89
+ import { surfaceRecordConflict } from './conflicts'
88
90
  import type { CustomFieldDefLike } from '@open-mercato/shared/modules/entities/validation'
89
- import type { MDEditorProps as UiWMDEditorProps } from '@uiw/react-md-editor'
90
91
  import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../primitives/dialog'
91
92
  import { FieldDefinitionsManager, type FieldDefinitionsManagerHandle } from './custom-fields/FieldDefinitionsManager'
92
93
  import { useConfirmDialog } from './confirm-dialog'
@@ -104,6 +105,7 @@ import type { InjectionFieldDefinition, FieldContext } from '@open-mercato/share
104
105
  import { evaluateInjectedVisibility } from './injection/visibility-utils'
105
106
  import { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry'
106
107
  import { RichEditor, type RichEditorLabels } from '../primitives/rich-editor'
108
+ import MarkdownField from './inputs/MarkdownField'
107
109
 
108
110
  // Stable empty options array to avoid creating a new [] every render
109
111
  const EMPTY_OPTIONS: CrudFieldOption[] = []
@@ -307,6 +309,30 @@ export type CrudFormProps<TValues extends Record<string, unknown>> = {
307
309
  onDelete?: () => Promise<void> | void
308
310
  // When true, shows Delete button whenever onDelete is provided, even without an id
309
311
  deleteVisible?: boolean
312
+ /**
313
+ * OSS opt-in optimistic locking (spec: .ai/specs/2026-05-25-oss-optimistic-locking.md).
314
+ *
315
+ * When set to a non-empty ISO-8601 string (typically `record.updatedAt`
316
+ * from the API response), the form auto-injects the
317
+ * `x-om-ext-optimistic-lock-expected-updated-at` extension header on
318
+ * every PUT / PATCH / DELETE issued through the underlying `onSubmit` /
319
+ * `onDelete` callbacks (via `withScopedApiRequestHeaders`). When the
320
+ * server-side guard is opted in for the resource and the timestamp is
321
+ * stale, the API responds with a structured 409 conflict body.
322
+ *
323
+ * Strictly additive: when the prop is absent / null / empty, the form
324
+ * behaves exactly as before (no header injected).
325
+ */
326
+ optimisticLockUpdatedAt?: string | null
327
+ /**
328
+ * Opt out of optimistic locking entirely for this form. When `true`, the
329
+ * extension header is never attached, regardless of `optimisticLockUpdatedAt`
330
+ * or the value auto-derived from `initialValues.updatedAt`. Use for forms
331
+ * whose locking is owned elsewhere (e.g. sales document sub-resources guarded
332
+ * at the command layer against the parent aggregate) or for entities without
333
+ * an `updated_at` column.
334
+ */
335
+ disableOptimisticLock?: boolean
310
336
  // Legacy field-only grid toggle. Use `groups` for advanced layout.
311
337
  twoColumn?: boolean
312
338
  title?: string
@@ -407,6 +433,62 @@ function readByDotPath(source: Record<string, unknown> | undefined, path: string
407
433
  return current
408
434
  }
409
435
 
436
+ // Keys that would mutate the prototype chain if used as dot-path segments.
437
+ // Guard against them so a declared field id such as `__proto__.polluted` can
438
+ // never reach into Object.prototype (prototype-pollution hardening).
439
+ const PROTO_POLLUTING_KEYS = new Set(['__proto__', 'prototype', 'constructor'])
440
+
441
+ function isProtoPollutingKey(key: string): boolean {
442
+ return PROTO_POLLUTING_KEYS.has(key)
443
+ }
444
+
445
+ function writeByDotPath(target: Record<string, unknown>, path: string, value: unknown): void {
446
+ const segments = path.split('.').filter(Boolean)
447
+ if (segments.length === 0) return
448
+ if (segments.some(isProtoPollutingKey)) return
449
+ let current: Record<string, unknown> = target
450
+ for (let index = 0; index < segments.length - 1; index++) {
451
+ const segment = segments[index]
452
+ const existing = current[segment]
453
+ const isSafeObject =
454
+ !!existing &&
455
+ typeof existing === 'object' &&
456
+ !Array.isArray(existing) &&
457
+ (Object.getPrototypeOf(existing) === Object.prototype || Object.getPrototypeOf(existing) === null)
458
+
459
+ if (!isSafeObject) {
460
+ const created = Object.create(null) as Record<string, unknown>
461
+ current[segment] = created
462
+ current = created
463
+ } else {
464
+ current = existing as Record<string, unknown>
465
+ }
466
+ }
467
+ const finalSegment = segments[segments.length - 1]
468
+ if (isProtoPollutingKey(finalSegment)) return
469
+ current[finalSegment] = value
470
+ }
471
+
472
+ // Collapse flat dot-path keys (e.g. `metadata.category`) declared as base form
473
+ // fields into the nested object the schema expects (`{ metadata: { category } }`).
474
+ // The flat keys are preserved alongside the nested projection so that forms whose
475
+ // schema declares the flat keys directly keep working — CrudForm's non-strict
476
+ // schema.safeParse retains whichever representation the schema declares and strips
477
+ // the other. See issue #2503 ("Latent framework gap").
478
+ function collapseDotPathFields(
479
+ source: Record<string, unknown>,
480
+ dotPathFieldIds: ReadonlySet<string>,
481
+ ): Record<string, unknown> {
482
+ if (!dotPathFieldIds.size) return source
483
+ let result: Record<string, unknown> | null = null
484
+ for (const fieldId of dotPathFieldIds) {
485
+ if (!Object.prototype.hasOwnProperty.call(source, fieldId)) continue
486
+ if (!result) result = { ...source }
487
+ writeByDotPath(result, fieldId, source[fieldId])
488
+ }
489
+ return result ?? source
490
+ }
491
+
410
492
  function readInitialCustomFieldValue(
411
493
  source: Record<string, unknown> | undefined,
412
494
  key: string,
@@ -451,6 +533,36 @@ function serializeIssuePath(path: ReadonlyArray<string | number | symbol>): stri
451
533
  return segments.length > 0 ? segments.join('.') : null
452
534
  }
453
535
 
536
+ function normalizeDirtySnapshotValue(value: unknown): unknown {
537
+ if (value === undefined || value === null || value === '') return undefined
538
+ if (Array.isArray(value)) {
539
+ const normalized = value
540
+ .map((entry) => normalizeDirtySnapshotValue(entry))
541
+ .filter((entry) => entry !== undefined)
542
+ return normalized.length ? normalized : undefined
543
+ }
544
+ if (typeof value !== 'object') return value
545
+
546
+ const prototype = Object.getPrototypeOf(value)
547
+ if (prototype !== Object.prototype && prototype !== null) return value
548
+
549
+ const normalized: Record<string, unknown> = {}
550
+ const record = value as Record<string, unknown>
551
+ for (const key of Object.keys(record).sort()) {
552
+ const nextValue = normalizeDirtySnapshotValue(record[key])
553
+ if (nextValue !== undefined) normalized[key] = nextValue
554
+ }
555
+ return Object.keys(normalized).length ? normalized : undefined
556
+ }
557
+
558
+ function createDirtySnapshot(source: Record<string, unknown>): string {
559
+ return JSON.stringify(normalizeDirtySnapshotValue(source) ?? {})
560
+ }
561
+
562
+ function createDirtyValueSnapshot(value: unknown): string {
563
+ return JSON.stringify(normalizeDirtySnapshotValue(value) ?? null)
564
+ }
565
+
454
566
  const FIELDSET_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
455
567
  layers: Layers,
456
568
  tag: Tag,
@@ -583,6 +695,8 @@ export function CrudForm<TValues extends Record<string, unknown>>({
583
695
  onSubmit,
584
696
  onDelete,
585
697
  deleteVisible,
698
+ optimisticLockUpdatedAt,
699
+ disableOptimisticLock = false,
586
700
  twoColumn = false,
587
701
  title,
588
702
  backHref,
@@ -706,6 +820,28 @@ export function CrudForm<TValues extends Record<string, unknown>>({
706
820
  )
707
821
 
708
822
  const operation = recordId ? 'update' : 'create'
823
+
824
+ // Resolve the optimistic-lock version used to build the extension header.
825
+ // Explicit `optimisticLockUpdatedAt` (including `null`) always wins. When the
826
+ // prop is absent, auto-derive from the loaded record's `updatedAt`/`updated_at`
827
+ // so every edit CrudForm enforces optimistic locking by default without
828
+ // per-form wiring. Presence of a loaded `updatedAt` — NOT the create/update
829
+ // heuristic — drives this: some edit pages keep the record id outside form
830
+ // values (so `operation` reads as `create`), while a genuine create has no
831
+ // `updatedAt` in `initialValues`. Even a "duplicate"-style create that carries
832
+ // one is harmless — the server ignores the header on inserts (no candidate id).
833
+ // `disableOptimisticLock` always wins; an explicit prop (incl. `null`) wins next.
834
+ const resolvedOptimisticLockUpdatedAt = React.useMemo<string | null | undefined>(() => {
835
+ if (disableOptimisticLock) return null
836
+ if (optimisticLockUpdatedAt !== undefined) return optimisticLockUpdatedAt
837
+ const source = initialValues as Record<string, unknown> | undefined
838
+ const camel = source?.updatedAt
839
+ if (typeof camel === 'string' && camel.length > 0) return camel
840
+ const snake = source?.updated_at
841
+ if (typeof snake === 'string' && snake.length > 0) return snake
842
+ return null
843
+ }, [disableOptimisticLock, optimisticLockUpdatedAt, initialValues])
844
+
709
845
  const injectionContext = React.useMemo(() => ({
710
846
  formId,
711
847
  entityId: primaryEntityId,
@@ -750,7 +886,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
750
886
  setHasUnsavedChanges(false)
751
887
  return
752
888
  }
753
- const currentSnapshot = JSON.stringify(values)
889
+ const currentSnapshot = createDirtySnapshot(values as Record<string, unknown>)
754
890
  const dirty = currentSnapshot !== snapshot
755
891
  isDirtyRef.current = dirty
756
892
  setHasUnsavedChanges(dirty)
@@ -797,7 +933,9 @@ export function CrudForm<TValues extends Record<string, unknown>>({
797
933
 
798
934
  const clearDirtyState = React.useCallback((snapshotSource?: Record<string, unknown>) => {
799
935
  const source = snapshotSource ?? (valuesRef.current as Record<string, unknown>)
800
- dirtyBaselineSnapshotRef.current = JSON.stringify(source)
936
+ dirtyBaselineSnapshotRef.current = createDirtySnapshot(source)
937
+ dirtyBaselineValuesRef.current = { ...source }
938
+ userEditedFieldIdsRef.current.clear()
801
939
  isDirtyRef.current = false
802
940
  setHasUnsavedChanges(false)
803
941
  }, [])
@@ -842,7 +980,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
842
980
  if (!target) return
843
981
  if (shouldBypassUnsavedChangesGuardRef.current?.(target)) return
844
982
  const baselineSnapshot = dirtyBaselineSnapshotRef.current
845
- if (baselineSnapshot && JSON.stringify(valuesRef.current) === baselineSnapshot) {
983
+ if (baselineSnapshot && createDirtySnapshot(valuesRef.current as Record<string, unknown>) === baselineSnapshot) {
846
984
  isDirtyRef.current = false
847
985
  setHasUnsavedChanges(false)
848
986
  return
@@ -874,7 +1012,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
874
1012
  return original(data, unused, url)
875
1013
  }
876
1014
  const baselineSnapshot = dirtyBaselineSnapshotRef.current
877
- if (baselineSnapshot && JSON.stringify(valuesRef.current) === baselineSnapshot) {
1015
+ if (baselineSnapshot && createDirtySnapshot(valuesRef.current as Record<string, unknown>) === baselineSnapshot) {
878
1016
  isDirtyRef.current = false
879
1017
  setHasUnsavedChanges(false)
880
1018
  return original(data, unused, url)
@@ -1183,8 +1321,13 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1183
1321
  }
1184
1322
  }
1185
1323
 
1186
- if (injectionRequestHeaders && Object.keys(injectionRequestHeaders).length > 0) {
1187
- await withScopedApiRequestHeaders(injectionRequestHeaders, async () => {
1324
+ const optimisticLockHeader = buildOptimisticLockHeader(resolvedOptimisticLockUpdatedAt)
1325
+ const mergedDeleteHeaders = {
1326
+ ...(injectionRequestHeaders ?? {}),
1327
+ ...optimisticLockHeader,
1328
+ }
1329
+ if (Object.keys(mergedDeleteHeaders).length > 0) {
1330
+ await withScopedApiRequestHeaders(mergedDeleteHeaders, async () => {
1188
1331
  await onDelete()
1189
1332
  })
1190
1333
  } else {
@@ -1228,8 +1371,14 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1228
1371
  } catch {
1229
1372
  // ignore event dispatch failures
1230
1373
  }
1231
- const message = err instanceof Error && err.message ? err.message : deleteErrorMessage
1232
- try { flash(message, 'error') } catch {}
1374
+ const optimisticLockConflict = extractOptimisticLockConflict(err)
1375
+ if (optimisticLockConflict) {
1376
+ // Surface on the unified, persistent conflict bar instead of a toast.
1377
+ try { surfaceRecordConflict(err, t) } catch {}
1378
+ } else {
1379
+ const message = err instanceof Error && err.message ? err.message : deleteErrorMessage
1380
+ try { flash(message, 'error') } catch {}
1381
+ }
1233
1382
  } finally {
1234
1383
  deletingRef.current = false
1235
1384
  setPending(false)
@@ -1634,6 +1783,25 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1634
1783
  return new globalThis.Map(allFields.map((f) => [f.id, f]))
1635
1784
  }, [allFields])
1636
1785
 
1786
+ // Declared base fields whose id is a dot-path (e.g. `metadata.category`).
1787
+ // Injected and custom (cf) fields already get dot-path/cf-path hydration and
1788
+ // are excluded here so their dedicated handling is preserved. CrudForm hydrates
1789
+ // these flat keys from nested initial values on load and projects them back into
1790
+ // nested objects before schema.safeParse on submit. See issue #2503.
1791
+ const dotPathBaseFieldIds = React.useMemo(() => {
1792
+ const ids = new Set<string>()
1793
+ const cfFieldIds = new Set(cfFields.map((field) => field.id))
1794
+ for (const field of allFields) {
1795
+ const fieldId = field.id
1796
+ if (!fieldId.includes('.')) continue
1797
+ if (injectedFieldIdSet.has(fieldId)) continue
1798
+ if (fieldId.startsWith('cf_') || fieldId.startsWith('cf:')) continue
1799
+ if (cfFieldIds.has(fieldId)) continue
1800
+ ids.add(fieldId)
1801
+ }
1802
+ return ids
1803
+ }, [allFields, injectedFieldIdSet, cfFields])
1804
+
1637
1805
  const validateFieldOnBlur = React.useCallback(async (
1638
1806
  fieldId: string,
1639
1807
  sourceValues?: CrudFormValues<TValues>,
@@ -1706,7 +1874,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1706
1874
  for (const injectedId of injectedFieldIdSet) {
1707
1875
  delete coreValues[injectedId]
1708
1876
  }
1709
- const result = schema.safeParse(coreValues)
1877
+ const result = schema.safeParse(collapseDotPathFields(coreValues, dotPathBaseFieldIds))
1710
1878
  if (!result.success) {
1711
1879
  const schemaFieldErrors: Record<string, string> = {}
1712
1880
  result.error.issues.forEach((issue) => {
@@ -1746,6 +1914,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1746
1914
  }, [
1747
1915
  cfDefinitions,
1748
1916
  customEntity,
1917
+ dotPathBaseFieldIds,
1749
1918
  fieldById,
1750
1919
  formReadOnly,
1751
1920
  hiddenBaseFieldIds,
@@ -2045,14 +2214,46 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2045
2214
  }
2046
2215
  }, [errors, formId])
2047
2216
 
2217
+ const updateEditedFieldMarker = React.useCallback((
2218
+ id: string,
2219
+ nextValue: unknown,
2220
+ baselineSource: Record<string, unknown> | undefined = dirtyBaselineValuesRef.current,
2221
+ ) => {
2222
+ if (
2223
+ baselineSource &&
2224
+ createDirtyValueSnapshot(baselineSource[id]) === createDirtyValueSnapshot(nextValue)
2225
+ ) {
2226
+ userEditedFieldIdsRef.current.delete(id)
2227
+ return
2228
+ }
2229
+ userEditedFieldIdsRef.current.add(id)
2230
+ }, [])
2231
+
2048
2232
  const setValue = React.useCallback((id: string, nextValue: unknown) => {
2049
2233
  let nextData: CrudFormValues<TValues> | null = null
2234
+ let nextDirty: boolean | null = null
2235
+ const currentValue = (valuesRef.current as Record<string, unknown>)[id]
2236
+ if (!Object.is(currentValue, nextValue)) {
2237
+ updateEditedFieldMarker(id, nextValue)
2238
+ }
2050
2239
  setValues((prev) => {
2051
2240
  if (Object.is(prev[id], nextValue)) return prev
2241
+ const baselineSource = dirtyBaselineValuesRef.current ?? (prev as Record<string, unknown>)
2242
+ updateEditedFieldMarker(id, nextValue, baselineSource)
2052
2243
  nextData = { ...prev, [id]: nextValue } as CrudFormValues<TValues>
2053
2244
  valuesRef.current = nextData
2245
+ if (!(embedded && !trackDirtyWhenEmbedded)) {
2246
+ const baseline = dirtyBaselineSnapshotRef.current ?? createDirtySnapshot(prev as Record<string, unknown>)
2247
+ dirtyBaselineSnapshotRef.current = baseline
2248
+ dirtyBaselineValuesRef.current = baselineSource
2249
+ nextDirty = createDirtySnapshot(nextData as Record<string, unknown>) !== baseline
2250
+ }
2054
2251
  return nextData
2055
2252
  })
2253
+ if (nextDirty !== null) {
2254
+ isDirtyRef.current = nextDirty
2255
+ setHasUnsavedChanges(nextDirty)
2256
+ }
2056
2257
  const clearedMessages: string[] = []
2057
2258
  setErrors((prev) => {
2058
2259
  if (!Object.keys(prev).length) return prev
@@ -2117,7 +2318,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2117
2318
  }).catch((err) => {
2118
2319
  console.error('[CrudForm] Error in onFieldChange:', err)
2119
2320
  })
2120
- }, [extendedInjectionEventsEnabled, flash, t, translateValidationMessage, triggerInjectionEvent])
2321
+ }, [embedded, extendedInjectionEventsEnabled, flash, t, trackDirtyWhenEmbedded, translateValidationMessage, triggerInjectionEvent, updateEditedFieldMarker])
2121
2322
 
2122
2323
  const onBlurRequest = React.useCallback((fieldId: string) => {
2123
2324
  void validateFieldOnBlur(fieldId)
@@ -2145,19 +2346,40 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2145
2346
 
2146
2347
  const appliedInitialValuesSnapshotRef = React.useRef<string | undefined>(undefined)
2147
2348
  const dirtyBaselineSnapshotRef = React.useRef<string | undefined>(undefined)
2349
+ const dirtyBaselineValuesRef = React.useRef<Record<string, unknown> | undefined>(undefined)
2350
+ const userEditedFieldIdsRef = React.useRef<Set<string>>(new Set())
2148
2351
  React.useLayoutEffect(() => {
2149
2352
  if (!initialValues) return
2150
2353
  const snapshot = JSON.stringify({
2151
2354
  initialValues,
2152
2355
  injectedFieldIds: injectedFieldDefinitions.map((definition) => definition.id),
2153
2356
  customFieldMappings: cfDefinitions.map((definition) => definition.key),
2357
+ dotPathBaseFieldIds: Array.from(dotPathBaseFieldIds),
2154
2358
  })
2155
2359
  if (appliedInitialValuesSnapshotRef.current === snapshot) return
2156
2360
  appliedInitialValuesSnapshotRef.current = snapshot
2157
2361
  const initialRecord = initialValues as Record<string, unknown>
2158
2362
  let mergedValues: CrudFormValues<TValues> | null = null
2363
+ // Whether the user already has unsaved edits relative to the load-time baseline,
2364
+ // computed from `prev` (React's latest committed values) — NOT from isDirtyRef or
2365
+ // valuesRef, which are both updated in post-render effects and therefore still
2366
+ // hold stale values when this layout effect runs in the SAME render that async
2367
+ // custom-field / injected-field definitions arrive. That lag was the residual
2368
+ // race that let the baseline absorb an in-progress edit under load.
2369
+ let hadUnsavedEdits = false
2159
2370
  setValues((prev) => {
2160
- const merged = { ...prev, ...initialValues } as CrudFormValues<TValues>
2371
+ const priorBaseline = dirtyBaselineSnapshotRef.current
2372
+ const editedFieldIds = userEditedFieldIdsRef.current
2373
+ hadUnsavedEdits =
2374
+ editedFieldIds.size > 0 ||
2375
+ priorBaseline !== undefined &&
2376
+ createDirtySnapshot(prev as Record<string, unknown>) !== priorBaseline
2377
+ const merged = { ...prev } as CrudFormValues<TValues>
2378
+ const mergedRecord = merged as Record<string, unknown>
2379
+ for (const [key, value] of Object.entries(initialValues as Record<string, unknown>)) {
2380
+ if (editedFieldIds.has(key)) continue
2381
+ mergedRecord[key] = value
2382
+ }
2161
2383
  for (const definition of injectedFieldDefinitions) {
2162
2384
  if (merged[definition.id] !== undefined) continue
2163
2385
  const extracted = readByDotPath(initialRecord, definition.id)
@@ -2173,11 +2395,29 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2173
2395
  ;(merged as Record<string, unknown>)[targetId] = extracted
2174
2396
  }
2175
2397
  }
2398
+ for (const fieldId of dotPathBaseFieldIds) {
2399
+ if (merged[fieldId] !== undefined) continue
2400
+ const extracted = readByDotPath(initialRecord, fieldId)
2401
+ if (extracted !== undefined) {
2402
+ ;(merged as Record<string, unknown>)[fieldId] = extracted
2403
+ }
2404
+ }
2176
2405
  mergedValues = merged
2177
2406
  return mergedValues
2178
2407
  })
2179
- if (mergedValues) {
2180
- dirtyBaselineSnapshotRef.current = JSON.stringify(mergedValues)
2408
+ if (mergedValues && !hadUnsavedEdits) {
2409
+ // Do not absorb an in-progress edit into the pristine baseline. This effect
2410
+ // re-runs whenever the snapshot changes — which includes custom-field
2411
+ // definitions and injected fields loading ASYNCHRONOUSLY after mount. If the
2412
+ // user has already started editing by the time they arrive, recomputing the
2413
+ // baseline from the current (merged) values would set the baseline equal to
2414
+ // the edited values, silently clearing dirty (header Save disables, the edit
2415
+ // looks pristine) and discarding the unsaved change. Re-establish the baseline
2416
+ // only when there are no pending edits; while dirty, keep the load-time
2417
+ // baseline so the edit stays dirty until save/discard. Root cause of the flaky
2418
+ // optimistic-lock stale-edit conflicts (#2055 / TC-LOCK-OSS-015 / TC-LOCK-OSS-029).
2419
+ dirtyBaselineSnapshotRef.current = createDirtySnapshot(mergedValues as Record<string, unknown>)
2420
+ dirtyBaselineValuesRef.current = { ...(mergedValues as Record<string, unknown>) }
2181
2421
  }
2182
2422
  if (!extendedInjectionEventsEnabled || !mergedValues) return
2183
2423
  let cancelled = false
@@ -2190,7 +2430,11 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2190
2430
  )
2191
2431
  const transformed = result.data
2192
2432
  if (cancelled || !transformed) return
2193
- dirtyBaselineSnapshotRef.current = JSON.stringify(transformed as Record<string, unknown>)
2433
+ // As above: never re-apply transformed display data over an in-progress
2434
+ // edit — it would overwrite the user's unsaved changes and reset dirty.
2435
+ if (isDirtyRef.current) return
2436
+ dirtyBaselineSnapshotRef.current = createDirtySnapshot(transformed as Record<string, unknown>)
2437
+ dirtyBaselineValuesRef.current = { ...(transformed as Record<string, unknown>) }
2194
2438
  setValues(transformed as CrudFormValues<TValues>)
2195
2439
  } catch (err) {
2196
2440
  console.error('[CrudForm] Error in transformDisplayData:', err)
@@ -2203,6 +2447,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2203
2447
  }, [
2204
2448
  cfDefinitions,
2205
2449
  customEntity,
2450
+ dotPathBaseFieldIds,
2206
2451
  extendedInjectionEventsEnabled,
2207
2452
  initialValues,
2208
2453
  injectedFieldDefinitions,
@@ -2259,7 +2504,8 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2259
2504
 
2260
2505
  // Update the dirty baseline so the form doesn't appear dirty from defaults
2261
2506
  if (mergedValues) {
2262
- dirtyBaselineSnapshotRef.current = JSON.stringify(mergedValues)
2507
+ dirtyBaselineSnapshotRef.current = createDirtySnapshot(mergedValues as Record<string, unknown>)
2508
+ dirtyBaselineValuesRef.current = { ...(mergedValues as Record<string, unknown>) }
2263
2509
  }
2264
2510
  }, [isLoading, initialValuesHasId, cfDefinitions, customEntity])
2265
2511
 
@@ -2400,7 +2646,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2400
2646
 
2401
2647
  let parsedValues: TValues
2402
2648
  if (schema) {
2403
- const res = schema.safeParse(coreValues)
2649
+ const res = schema.safeParse(collapseDotPathFields(coreValues, dotPathBaseFieldIds))
2404
2650
  if (!res.success) {
2405
2651
  const fieldErrors: Record<string, string> = {}
2406
2652
  res.error.issues.forEach((issue) => {
@@ -2430,7 +2676,9 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2430
2676
  for (const injectedId of injectedFieldIdSet) {
2431
2677
  delete projectedCoreValues[injectedId]
2432
2678
  }
2433
- coreSubmitValues = schema ? schema.parse(projectedCoreValues) : (projectedCoreValues as TValues)
2679
+ coreSubmitValues = schema
2680
+ ? schema.parse(collapseDotPathFields(projectedCoreValues, dotPathBaseFieldIds))
2681
+ : (projectedCoreValues as TValues)
2434
2682
  if (result.applyToForm) {
2435
2683
  setValues(result.data as CrudFormValues<TValues>)
2436
2684
  }
@@ -2518,8 +2766,13 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2518
2766
 
2519
2767
  try {
2520
2768
  submitNavigationBypassRef.current = true
2521
- if (injectionRequestHeaders && Object.keys(injectionRequestHeaders).length > 0) {
2522
- await withScopedApiRequestHeaders(injectionRequestHeaders, async () => {
2769
+ const optimisticLockHeader = buildOptimisticLockHeader(resolvedOptimisticLockUpdatedAt)
2770
+ const mergedSubmitHeaders = {
2771
+ ...(injectionRequestHeaders ?? {}),
2772
+ ...optimisticLockHeader,
2773
+ }
2774
+ if (Object.keys(mergedSubmitHeaders).length > 0) {
2775
+ await withScopedApiRequestHeaders(mergedSubmitHeaders, async () => {
2523
2776
  await onSubmit?.(coreSubmitValues, submitContext)
2524
2777
  })
2525
2778
  } else {
@@ -2577,7 +2830,10 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2577
2830
  }
2578
2831
  }
2579
2832
 
2580
- let displayMessage = typeof helperMessage === 'string' && helperMessage.trim() ? helperMessage.trim() : ''
2833
+ const optimisticLockConflict = extractOptimisticLockConflict(err)
2834
+ let displayMessage = optimisticLockConflict
2835
+ ? t('ui.forms.flash.recordModified', 'This record was modified by someone else. Refresh and try again.')
2836
+ : typeof helperMessage === 'string' && helperMessage.trim() ? helperMessage.trim() : ''
2581
2837
  if (hasFieldErrors) {
2582
2838
  const lowered = displayMessage.toLowerCase()
2583
2839
  const highlightedLower = highlightedMessage.toLowerCase()
@@ -2592,7 +2848,12 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2592
2848
  displayMessage = hasFieldErrors ? highlightedMessage : saveErrorMessage
2593
2849
  }
2594
2850
  displayMessage = parseServerMessage(displayMessage)
2595
- if (!hasFieldErrors) {
2851
+ if (optimisticLockConflict) {
2852
+ // Primary surface for the conflict is the persistent, error-styled
2853
+ // RecordConflictBanner (unified across all forms). Keep the inline
2854
+ // form error too, but suppress the redundant transient toast.
2855
+ try { surfaceRecordConflict(err, t) } catch {}
2856
+ } else if (!hasFieldErrors) {
2596
2857
  flash(displayMessage, 'error')
2597
2858
  }
2598
2859
  setFormError(displayMessage)
@@ -3632,74 +3893,11 @@ function TextAreaInput({
3632
3893
  )
3633
3894
  }
3634
3895
 
3635
- // Markdown editor using @uiw/react-md-editor (client-only)
3896
+ // Markdown editor — WYSIWYG MDXEditor (Lexical) wired to the CrudForm value/onChange contract.
3636
3897
  type MDProps = { value?: string; onChange: (md: string) => void }
3637
- const MDEditor = dynamic(async () => {
3638
- const mod = await import('@uiw/react-md-editor')
3639
- return mod.default
3640
- }, { ssr: false }) as React.ComponentType<UiWMDEditorProps>
3641
-
3642
- type MarkdownPreviewOptions = NonNullable<UiWMDEditorProps['previewOptions']>
3643
-
3644
- let markdownPreviewOptionsPromise: Promise<MarkdownPreviewOptions> | null = null
3645
-
3646
- async function loadMarkdownPreviewOptions(): Promise<MarkdownPreviewOptions> {
3647
- if (!markdownPreviewOptionsPromise) {
3648
- markdownPreviewOptionsPromise = import('remark-gfm')
3649
- .then((mod) => ({ remarkPlugins: [mod.default ?? mod] } as MarkdownPreviewOptions))
3650
- .catch(() => ({} as MarkdownPreviewOptions))
3651
- }
3652
- return markdownPreviewOptionsPromise
3653
- }
3654
-
3655
- const EMPTY_PREVIEW_OPTIONS: MarkdownPreviewOptions = {}
3656
3898
 
3657
3899
  const MarkdownEditor = React.memo(function MarkdownEditor({ value = '', onChange }: MDProps) {
3658
- const containerRef = React.useRef<HTMLDivElement | null>(null)
3659
- const [local, setLocal] = React.useState<string>(value)
3660
- const [previewOptions, setPreviewOptions] = React.useState<MarkdownPreviewOptions>(EMPTY_PREVIEW_OPTIONS)
3661
- const typingRef = React.useRef(false)
3662
-
3663
- React.useEffect(() => {
3664
- if (!typingRef.current) setLocal(value)
3665
- }, [value])
3666
-
3667
- React.useEffect(() => {
3668
- let mounted = true
3669
- void loadMarkdownPreviewOptions().then((resolved) => {
3670
- if (!mounted) return
3671
- setPreviewOptions(resolved)
3672
- })
3673
- return () => {
3674
- mounted = false
3675
- }
3676
- }, [])
3677
-
3678
- const handleChange = React.useCallback((v?: string) => {
3679
- typingRef.current = true
3680
- setLocal(v ?? '')
3681
- }, [])
3682
-
3683
- const commit = React.useCallback(() => {
3684
- if (!typingRef.current) return
3685
- typingRef.current = false
3686
- onChange(local)
3687
- requestAnimationFrame(() => {
3688
- const ta = containerRef.current?.querySelector('textarea') as HTMLTextAreaElement | null
3689
- ta?.focus()
3690
- })
3691
- }, [local, onChange])
3692
-
3693
- return (
3694
- <div ref={containerRef} data-color-mode="light" className="w-full" onBlur={() => commit()}>
3695
- <MDEditor
3696
- value={local}
3697
- height={220}
3698
- onChange={handleChange}
3699
- previewOptions={previewOptions}
3700
- />
3701
- </div>
3702
- )
3900
+ return <MarkdownField value={value} onChange={onChange} />
3703
3901
  }, (prev, next) => prev.value === next.value)
3704
3902
 
3705
3903
  // HTML Rich Text editor wrapper for the CrudForm builtin `editor: 'html'`.
@@ -3870,13 +4068,29 @@ const ListboxMultiSelect = React.memo(function ListboxMultiSelect({
3870
4068
  return (
3871
4069
  <Button
3872
4070
  key={opt.value}
4071
+ type="button"
3873
4072
  variant="ghost"
3874
4073
  size="sm"
3875
4074
  onClick={() => toggle(opt.value)}
3876
4075
  className={`w-full justify-start rounded-none font-normal px-3 py-2 ${isSel ? 'bg-muted' : ''}`}
3877
4076
  >
3878
4077
  <span className="inline-flex items-center gap-2">
3879
- <Checkbox checked={isSel} disabled tabIndex={-1} className="pointer-events-none" />
4078
+ {/* Decorative selection indicator — NOT an interactive Checkbox.
4079
+ The enclosing Button owns the click; a real Radix Checkbox here
4080
+ nests a <button> inside a <button> (hydration error) and its
4081
+ Presence/ref lifecycle drives an infinite update loop when the
4082
+ listbox renders selected values. A plain span mirrors the DS
4083
+ checkbox look without any of that. */}
4084
+ <span
4085
+ aria-hidden="true"
4086
+ className={`inline-flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-colors ${
4087
+ isSel
4088
+ ? 'border-accent-indigo bg-accent-indigo text-accent-indigo-foreground'
4089
+ : 'border-input bg-background'
4090
+ }`}
4091
+ >
4092
+ {isSel ? <Check className="size-3.5" aria-hidden="true" /> : null}
4093
+ </span>
3880
4094
  <span>{opt.label}</span>
3881
4095
  </span>
3882
4096
  </Button>
@@ -3928,6 +4142,18 @@ const FieldControl = React.memo(function FieldControlImpl({
3928
4142
  const placeholder = builtin?.placeholder
3929
4143
  const rootClassName = wrapperClassName ? `space-y-1 ${wrapperClassName}` : 'space-y-1'
3930
4144
  const validateOnWrapperBlur = supportsWrapperBlurValidation(field)
4145
+ const singleSelectValue = Array.isArray(value)
4146
+ ? String(value[0] ?? '')
4147
+ : value == null
4148
+ ? ''
4149
+ : String(value)
4150
+ const singleSelectOptionsKey = React.useMemo(
4151
+ () => options.map((opt) => `${opt.value}:${opt.label}`).join('\0'),
4152
+ [options],
4153
+ )
4154
+ const singleSelectLabel = singleSelectValue
4155
+ ? options.find((option) => option.value === singleSelectValue)?.label
4156
+ : undefined
3931
4157
 
3932
4158
  return (
3933
4159
  <div
@@ -4126,19 +4352,14 @@ const FieldControl = React.memo(function FieldControlImpl({
4126
4352
  )}
4127
4353
  {field.type === 'select' && !builtin?.multiple && (
4128
4354
  <Select
4355
+ key={`${field.id}:${singleSelectValue}:${singleSelectOptionsKey}`}
4129
4356
  // Radix Select MUST be either always-controlled or always-uncontrolled.
4130
4357
  // Passing `value={undefined}` on first render and a string later trips
4131
4358
  // React's "uncontrolled → controlled" warning and breaks Radix's
4132
4359
  // internal state (dropdown flashes / selections no-op). Use empty
4133
4360
  // string for "no selection" instead — Radix treats it the same as
4134
4361
  // undefined for matching SelectItems but keeps the prop type stable.
4135
- value={
4136
- Array.isArray(value)
4137
- ? String(value[0] ?? '')
4138
- : value == null
4139
- ? ''
4140
- : String(value)
4141
- }
4362
+ value={singleSelectValue}
4142
4363
  onValueChange={(next) => {
4143
4364
  // Custom field clears must be explicit nulls; normal optional
4144
4365
  // fields keep the existing undefined clear semantics.
@@ -4152,7 +4373,9 @@ const FieldControl = React.memo(function FieldControlImpl({
4152
4373
  disabled={disabled}
4153
4374
  >
4154
4375
  <SelectTrigger data-crud-focus-target="">
4155
- <SelectValue placeholder={t('ui.forms.select.emptyOption', '—')} />
4376
+ <SelectValue placeholder={t('ui.forms.select.emptyOption', '—')}>
4377
+ {singleSelectLabel}
4378
+ </SelectValue>
4156
4379
  </SelectTrigger>
4157
4380
  <SelectContent>
4158
4381
  {!field.required && value != null && value !== '' && (