@open-mercato/ui 0.6.4-develop.4371.1.8f3030407e → 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
@@ -26,12 +26,12 @@ import {
26
26
  SelectValue
27
27
  } from "../primitives/select.js";
28
28
  import { flash } from "./FlashMessages.js";
29
- import dynamic from "next/dynamic";
30
29
  import { FormHeader } from "./forms/FormHeader.js";
31
30
  import { FormFooter } from "./forms/FormFooter.js";
32
31
  import { Button } from "../primitives/button.js";
33
32
  import { IconButton } from "../primitives/icon-button.js";
34
33
  import {
34
+ Check,
35
35
  Settings,
36
36
  Layers,
37
37
  Tag,
@@ -80,6 +80,8 @@ import { TimePicker } from "./inputs/TimePicker.js";
80
80
  import { DatePicker } from "./inputs/DatePicker.js";
81
81
  import { mapCrudServerErrorToFormErrors, parseServerMessage } from "./utils/serverErrors.js";
82
82
  import { withScopedApiRequestHeaders } from "./utils/apiCall.js";
83
+ import { buildOptimisticLockHeader, extractOptimisticLockConflict } from "./utils/optimisticLock.js";
84
+ import { surfaceRecordConflict } from "./conflicts/index.js";
83
85
  import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../primitives/dialog.js";
84
86
  import { FieldDefinitionsManager } from "./custom-fields/FieldDefinitionsManager.js";
85
87
  import { useConfirmDialog } from "./confirm-dialog/index.js";
@@ -96,6 +98,7 @@ import { InjectedField } from "./injection/InjectedField.js";
96
98
  import { evaluateInjectedVisibility } from "./injection/visibility-utils.js";
97
99
  import { ComponentReplacementHandles } from "@open-mercato/shared/modules/widgets/component-registry";
98
100
  import { RichEditor } from "../primitives/rich-editor.js";
101
+ import MarkdownField from "./inputs/MarkdownField.js";
99
102
  const EMPTY_OPTIONS = [];
100
103
  const SELECT_CLEAR_SENTINEL = "__crudform_select_clear__";
101
104
  const FOCUSABLE_SELECTOR = '[data-crud-focus-target], input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
@@ -170,6 +173,41 @@ function readByDotPath(source, path) {
170
173
  }
171
174
  return current;
172
175
  }
176
+ const PROTO_POLLUTING_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
177
+ function isProtoPollutingKey(key) {
178
+ return PROTO_POLLUTING_KEYS.has(key);
179
+ }
180
+ function writeByDotPath(target, path, value) {
181
+ const segments = path.split(".").filter(Boolean);
182
+ if (segments.length === 0) return;
183
+ if (segments.some(isProtoPollutingKey)) return;
184
+ let current = target;
185
+ for (let index = 0; index < segments.length - 1; index++) {
186
+ const segment = segments[index];
187
+ const existing = current[segment];
188
+ const isSafeObject = !!existing && typeof existing === "object" && !Array.isArray(existing) && (Object.getPrototypeOf(existing) === Object.prototype || Object.getPrototypeOf(existing) === null);
189
+ if (!isSafeObject) {
190
+ const created = /* @__PURE__ */ Object.create(null);
191
+ current[segment] = created;
192
+ current = created;
193
+ } else {
194
+ current = existing;
195
+ }
196
+ }
197
+ const finalSegment = segments[segments.length - 1];
198
+ if (isProtoPollutingKey(finalSegment)) return;
199
+ current[finalSegment] = value;
200
+ }
201
+ function collapseDotPathFields(source, dotPathFieldIds) {
202
+ if (!dotPathFieldIds.size) return source;
203
+ let result = null;
204
+ for (const fieldId of dotPathFieldIds) {
205
+ if (!Object.prototype.hasOwnProperty.call(source, fieldId)) continue;
206
+ if (!result) result = { ...source };
207
+ writeByDotPath(result, fieldId, source[fieldId]);
208
+ }
209
+ return result ?? source;
210
+ }
173
211
  function readInitialCustomFieldValue(source, key) {
174
212
  if (!source || !key) return void 0;
175
213
  const candidates = [`cf_${key}`, `cf:${key}`, key];
@@ -203,6 +241,29 @@ function serializeIssuePath(path) {
203
241
  }).filter((segment) => segment !== null);
204
242
  return segments.length > 0 ? segments.join(".") : null;
205
243
  }
244
+ function normalizeDirtySnapshotValue(value) {
245
+ if (value === void 0 || value === null || value === "") return void 0;
246
+ if (Array.isArray(value)) {
247
+ const normalized2 = value.map((entry) => normalizeDirtySnapshotValue(entry)).filter((entry) => entry !== void 0);
248
+ return normalized2.length ? normalized2 : void 0;
249
+ }
250
+ if (typeof value !== "object") return value;
251
+ const prototype = Object.getPrototypeOf(value);
252
+ if (prototype !== Object.prototype && prototype !== null) return value;
253
+ const normalized = {};
254
+ const record = value;
255
+ for (const key of Object.keys(record).sort()) {
256
+ const nextValue = normalizeDirtySnapshotValue(record[key]);
257
+ if (nextValue !== void 0) normalized[key] = nextValue;
258
+ }
259
+ return Object.keys(normalized).length ? normalized : void 0;
260
+ }
261
+ function createDirtySnapshot(source) {
262
+ return JSON.stringify(normalizeDirtySnapshotValue(source) ?? {});
263
+ }
264
+ function createDirtyValueSnapshot(value) {
265
+ return JSON.stringify(normalizeDirtySnapshotValue(value) ?? null);
266
+ }
206
267
  const FIELDSET_ICON_COMPONENTS = {
207
268
  layers: Layers,
208
269
  tag: Tag,
@@ -296,6 +357,8 @@ function CrudForm({
296
357
  onSubmit,
297
358
  onDelete,
298
359
  deleteVisible,
360
+ optimisticLockUpdatedAt,
361
+ disableOptimisticLock = false,
299
362
  twoColumn = false,
300
363
  title,
301
364
  backHref,
@@ -403,6 +466,16 @@ function CrudForm({
403
466
  }, [values]);
404
467
  const fallbackRecordId = recordId || (versionHistory?.resourceId === void 0 || versionHistory.resourceId === null ? void 0 : String(versionHistory.resourceId).trim() || void 0);
405
468
  const operation = recordId ? "update" : "create";
469
+ const resolvedOptimisticLockUpdatedAt = React.useMemo(() => {
470
+ if (disableOptimisticLock) return null;
471
+ if (optimisticLockUpdatedAt !== void 0) return optimisticLockUpdatedAt;
472
+ const source = initialValues;
473
+ const camel = source?.updatedAt;
474
+ if (typeof camel === "string" && camel.length > 0) return camel;
475
+ const snake = source?.updated_at;
476
+ if (typeof snake === "string" && snake.length > 0) return snake;
477
+ return null;
478
+ }, [disableOptimisticLock, optimisticLockUpdatedAt, initialValues]);
406
479
  const injectionContext = React.useMemo(() => ({
407
480
  formId,
408
481
  entityId: primaryEntityId,
@@ -443,7 +516,7 @@ function CrudForm({
443
516
  setHasUnsavedChanges(false);
444
517
  return;
445
518
  }
446
- const currentSnapshot = JSON.stringify(values);
519
+ const currentSnapshot = createDirtySnapshot(values);
447
520
  const dirty = currentSnapshot !== snapshot;
448
521
  isDirtyRef.current = dirty;
449
522
  setHasUnsavedChanges(dirty);
@@ -484,7 +557,9 @@ function CrudForm({
484
557
  }, [clearSubmitNavigationBypass]);
485
558
  const clearDirtyState = React.useCallback((snapshotSource) => {
486
559
  const source = snapshotSource ?? valuesRef.current;
487
- dirtyBaselineSnapshotRef.current = JSON.stringify(source);
560
+ dirtyBaselineSnapshotRef.current = createDirtySnapshot(source);
561
+ dirtyBaselineValuesRef.current = { ...source };
562
+ userEditedFieldIdsRef.current.clear();
488
563
  isDirtyRef.current = false;
489
564
  setHasUnsavedChanges(false);
490
565
  }, []);
@@ -523,7 +598,7 @@ function CrudForm({
523
598
  if (!target) return;
524
599
  if (shouldBypassUnsavedChangesGuardRef.current?.(target)) return;
525
600
  const baselineSnapshot = dirtyBaselineSnapshotRef.current;
526
- if (baselineSnapshot && JSON.stringify(valuesRef.current) === baselineSnapshot) {
601
+ if (baselineSnapshot && createDirtySnapshot(valuesRef.current) === baselineSnapshot) {
527
602
  isDirtyRef.current = false;
528
603
  setHasUnsavedChanges(false);
529
604
  return;
@@ -552,7 +627,7 @@ function CrudForm({
552
627
  return original(data, unused, url);
553
628
  }
554
629
  const baselineSnapshot = dirtyBaselineSnapshotRef.current;
555
- if (baselineSnapshot && JSON.stringify(valuesRef.current) === baselineSnapshot) {
630
+ if (baselineSnapshot && createDirtySnapshot(valuesRef.current) === baselineSnapshot) {
556
631
  isDirtyRef.current = false;
557
632
  setHasUnsavedChanges(false);
558
633
  return original(data, unused, url);
@@ -828,8 +903,13 @@ function CrudForm({
828
903
  return;
829
904
  }
830
905
  }
831
- if (injectionRequestHeaders && Object.keys(injectionRequestHeaders).length > 0) {
832
- await withScopedApiRequestHeaders(injectionRequestHeaders, async () => {
906
+ const optimisticLockHeader = buildOptimisticLockHeader(resolvedOptimisticLockUpdatedAt);
907
+ const mergedDeleteHeaders = {
908
+ ...injectionRequestHeaders ?? {},
909
+ ...optimisticLockHeader
910
+ };
911
+ if (Object.keys(mergedDeleteHeaders).length > 0) {
912
+ await withScopedApiRequestHeaders(mergedDeleteHeaders, async () => {
833
913
  await onDelete();
834
914
  });
835
915
  } else {
@@ -873,10 +953,18 @@ function CrudForm({
873
953
  }
874
954
  } catch {
875
955
  }
876
- const message = err instanceof Error && err.message ? err.message : deleteErrorMessage;
877
- try {
878
- flash(message, "error");
879
- } catch {
956
+ const optimisticLockConflict = extractOptimisticLockConflict(err);
957
+ if (optimisticLockConflict) {
958
+ try {
959
+ surfaceRecordConflict(err, t);
960
+ } catch {
961
+ }
962
+ } else {
963
+ const message = err instanceof Error && err.message ? err.message : deleteErrorMessage;
964
+ try {
965
+ flash(message, "error");
966
+ } catch {
967
+ }
880
968
  }
881
969
  } finally {
882
970
  deletingRef.current = false;
@@ -1237,6 +1325,19 @@ function CrudForm({
1237
1325
  const fieldById = React.useMemo(() => {
1238
1326
  return new globalThis.Map(allFields.map((f) => [f.id, f]));
1239
1327
  }, [allFields]);
1328
+ const dotPathBaseFieldIds = React.useMemo(() => {
1329
+ const ids = /* @__PURE__ */ new Set();
1330
+ const cfFieldIds = new Set(cfFields.map((field) => field.id));
1331
+ for (const field of allFields) {
1332
+ const fieldId = field.id;
1333
+ if (!fieldId.includes(".")) continue;
1334
+ if (injectedFieldIdSet.has(fieldId)) continue;
1335
+ if (fieldId.startsWith("cf_") || fieldId.startsWith("cf:")) continue;
1336
+ if (cfFieldIds.has(fieldId)) continue;
1337
+ ids.add(fieldId);
1338
+ }
1339
+ return ids;
1340
+ }, [allFields, injectedFieldIdSet, cfFields]);
1240
1341
  const validateFieldOnBlur = React.useCallback(async (fieldId, sourceValues) => {
1241
1342
  if (formReadOnly) return;
1242
1343
  const field = fieldById.get(fieldId);
@@ -1287,7 +1388,7 @@ function CrudForm({
1287
1388
  for (const injectedId of injectedFieldIdSet) {
1288
1389
  delete coreValues[injectedId];
1289
1390
  }
1290
- const result = schema.safeParse(coreValues);
1391
+ const result = schema.safeParse(collapseDotPathFields(coreValues, dotPathBaseFieldIds));
1291
1392
  if (!result.success) {
1292
1393
  const schemaFieldErrors = {};
1293
1394
  result.error.issues.forEach((issue) => {
@@ -1316,6 +1417,7 @@ function CrudForm({
1316
1417
  }, [
1317
1418
  cfDefinitions,
1318
1419
  customEntity,
1420
+ dotPathBaseFieldIds,
1319
1421
  fieldById,
1320
1422
  formReadOnly,
1321
1423
  hiddenBaseFieldIds,
@@ -1554,14 +1656,38 @@ function CrudForm({
1554
1656
  lastErrorFieldRef.current = fieldId;
1555
1657
  }
1556
1658
  }, [errors, formId]);
1659
+ const updateEditedFieldMarker = React.useCallback((id, nextValue, baselineSource = dirtyBaselineValuesRef.current) => {
1660
+ if (baselineSource && createDirtyValueSnapshot(baselineSource[id]) === createDirtyValueSnapshot(nextValue)) {
1661
+ userEditedFieldIdsRef.current.delete(id);
1662
+ return;
1663
+ }
1664
+ userEditedFieldIdsRef.current.add(id);
1665
+ }, []);
1557
1666
  const setValue = React.useCallback((id, nextValue) => {
1558
1667
  let nextData = null;
1668
+ let nextDirty = null;
1669
+ const currentValue = valuesRef.current[id];
1670
+ if (!Object.is(currentValue, nextValue)) {
1671
+ updateEditedFieldMarker(id, nextValue);
1672
+ }
1559
1673
  setValues((prev) => {
1560
1674
  if (Object.is(prev[id], nextValue)) return prev;
1675
+ const baselineSource = dirtyBaselineValuesRef.current ?? prev;
1676
+ updateEditedFieldMarker(id, nextValue, baselineSource);
1561
1677
  nextData = { ...prev, [id]: nextValue };
1562
1678
  valuesRef.current = nextData;
1679
+ if (!(embedded && !trackDirtyWhenEmbedded)) {
1680
+ const baseline = dirtyBaselineSnapshotRef.current ?? createDirtySnapshot(prev);
1681
+ dirtyBaselineSnapshotRef.current = baseline;
1682
+ dirtyBaselineValuesRef.current = baselineSource;
1683
+ nextDirty = createDirtySnapshot(nextData) !== baseline;
1684
+ }
1563
1685
  return nextData;
1564
1686
  });
1687
+ if (nextDirty !== null) {
1688
+ isDirtyRef.current = nextDirty;
1689
+ setHasUnsavedChanges(nextDirty);
1690
+ }
1565
1691
  const clearedMessages = [];
1566
1692
  setErrors((prev) => {
1567
1693
  if (!Object.keys(prev).length) return prev;
@@ -1621,7 +1747,7 @@ function CrudForm({
1621
1747
  }).catch((err) => {
1622
1748
  console.error("[CrudForm] Error in onFieldChange:", err);
1623
1749
  });
1624
- }, [extendedInjectionEventsEnabled, flash, t, translateValidationMessage, triggerInjectionEvent]);
1750
+ }, [embedded, extendedInjectionEventsEnabled, flash, t, trackDirtyWhenEmbedded, translateValidationMessage, triggerInjectionEvent, updateEditedFieldMarker]);
1625
1751
  const onBlurRequest = React.useCallback((fieldId) => {
1626
1752
  void validateFieldOnBlur(fieldId);
1627
1753
  }, [validateFieldOnBlur]);
@@ -1645,19 +1771,31 @@ function CrudForm({
1645
1771
  );
1646
1772
  const appliedInitialValuesSnapshotRef = React.useRef(void 0);
1647
1773
  const dirtyBaselineSnapshotRef = React.useRef(void 0);
1774
+ const dirtyBaselineValuesRef = React.useRef(void 0);
1775
+ const userEditedFieldIdsRef = React.useRef(/* @__PURE__ */ new Set());
1648
1776
  React.useLayoutEffect(() => {
1649
1777
  if (!initialValues) return;
1650
1778
  const snapshot = JSON.stringify({
1651
1779
  initialValues,
1652
1780
  injectedFieldIds: injectedFieldDefinitions.map((definition) => definition.id),
1653
- customFieldMappings: cfDefinitions.map((definition) => definition.key)
1781
+ customFieldMappings: cfDefinitions.map((definition) => definition.key),
1782
+ dotPathBaseFieldIds: Array.from(dotPathBaseFieldIds)
1654
1783
  });
1655
1784
  if (appliedInitialValuesSnapshotRef.current === snapshot) return;
1656
1785
  appliedInitialValuesSnapshotRef.current = snapshot;
1657
1786
  const initialRecord = initialValues;
1658
1787
  let mergedValues = null;
1788
+ let hadUnsavedEdits = false;
1659
1789
  setValues((prev) => {
1660
- const merged = { ...prev, ...initialValues };
1790
+ const priorBaseline = dirtyBaselineSnapshotRef.current;
1791
+ const editedFieldIds = userEditedFieldIdsRef.current;
1792
+ hadUnsavedEdits = editedFieldIds.size > 0 || priorBaseline !== void 0 && createDirtySnapshot(prev) !== priorBaseline;
1793
+ const merged = { ...prev };
1794
+ const mergedRecord = merged;
1795
+ for (const [key, value] of Object.entries(initialValues)) {
1796
+ if (editedFieldIds.has(key)) continue;
1797
+ mergedRecord[key] = value;
1798
+ }
1661
1799
  for (const definition of injectedFieldDefinitions) {
1662
1800
  if (merged[definition.id] !== void 0) continue;
1663
1801
  const extracted = readByDotPath(initialRecord, definition.id);
@@ -1675,11 +1813,20 @@ function CrudForm({
1675
1813
  merged[targetId] = extracted;
1676
1814
  }
1677
1815
  }
1816
+ for (const fieldId of dotPathBaseFieldIds) {
1817
+ if (merged[fieldId] !== void 0) continue;
1818
+ const extracted = readByDotPath(initialRecord, fieldId);
1819
+ if (extracted !== void 0) {
1820
+ ;
1821
+ merged[fieldId] = extracted;
1822
+ }
1823
+ }
1678
1824
  mergedValues = merged;
1679
1825
  return mergedValues;
1680
1826
  });
1681
- if (mergedValues) {
1682
- dirtyBaselineSnapshotRef.current = JSON.stringify(mergedValues);
1827
+ if (mergedValues && !hadUnsavedEdits) {
1828
+ dirtyBaselineSnapshotRef.current = createDirtySnapshot(mergedValues);
1829
+ dirtyBaselineValuesRef.current = { ...mergedValues };
1683
1830
  }
1684
1831
  if (!extendedInjectionEventsEnabled || !mergedValues) return;
1685
1832
  let cancelled = false;
@@ -1692,7 +1839,9 @@ function CrudForm({
1692
1839
  );
1693
1840
  const transformed = result.data;
1694
1841
  if (cancelled || !transformed) return;
1695
- dirtyBaselineSnapshotRef.current = JSON.stringify(transformed);
1842
+ if (isDirtyRef.current) return;
1843
+ dirtyBaselineSnapshotRef.current = createDirtySnapshot(transformed);
1844
+ dirtyBaselineValuesRef.current = { ...transformed };
1696
1845
  setValues(transformed);
1697
1846
  } catch (err) {
1698
1847
  console.error("[CrudForm] Error in transformDisplayData:", err);
@@ -1705,6 +1854,7 @@ function CrudForm({
1705
1854
  }, [
1706
1855
  cfDefinitions,
1707
1856
  customEntity,
1857
+ dotPathBaseFieldIds,
1708
1858
  extendedInjectionEventsEnabled,
1709
1859
  initialValues,
1710
1860
  injectedFieldDefinitions,
@@ -1744,7 +1894,8 @@ function CrudForm({
1744
1894
  return mergedValues;
1745
1895
  });
1746
1896
  if (mergedValues) {
1747
- dirtyBaselineSnapshotRef.current = JSON.stringify(mergedValues);
1897
+ dirtyBaselineSnapshotRef.current = createDirtySnapshot(mergedValues);
1898
+ dirtyBaselineValuesRef.current = { ...mergedValues };
1748
1899
  }
1749
1900
  }, [isLoading, initialValuesHasId, cfDefinitions, customEntity]);
1750
1901
  const markFormAsClean = React.useCallback((snapshotSource) => {
@@ -1861,7 +2012,7 @@ function CrudForm({
1861
2012
  }
1862
2013
  let parsedValues;
1863
2014
  if (schema) {
1864
- const res = schema.safeParse(coreValues);
2015
+ const res = schema.safeParse(collapseDotPathFields(coreValues, dotPathBaseFieldIds));
1865
2016
  if (!res.success) {
1866
2017
  const fieldErrors = {};
1867
2018
  res.error.issues.forEach((issue) => {
@@ -1891,7 +2042,7 @@ function CrudForm({
1891
2042
  for (const injectedId of injectedFieldIdSet) {
1892
2043
  delete projectedCoreValues[injectedId];
1893
2044
  }
1894
- coreSubmitValues = schema ? schema.parse(projectedCoreValues) : projectedCoreValues;
2045
+ coreSubmitValues = schema ? schema.parse(collapseDotPathFields(projectedCoreValues, dotPathBaseFieldIds)) : projectedCoreValues;
1895
2046
  if (result.applyToForm) {
1896
2047
  setValues(result.data);
1897
2048
  }
@@ -1962,8 +2113,13 @@ function CrudForm({
1962
2113
  }
1963
2114
  try {
1964
2115
  submitNavigationBypassRef.current = true;
1965
- if (injectionRequestHeaders && Object.keys(injectionRequestHeaders).length > 0) {
1966
- await withScopedApiRequestHeaders(injectionRequestHeaders, async () => {
2116
+ const optimisticLockHeader = buildOptimisticLockHeader(resolvedOptimisticLockUpdatedAt);
2117
+ const mergedSubmitHeaders = {
2118
+ ...injectionRequestHeaders ?? {},
2119
+ ...optimisticLockHeader
2120
+ };
2121
+ if (Object.keys(mergedSubmitHeaders).length > 0) {
2122
+ await withScopedApiRequestHeaders(mergedSubmitHeaders, async () => {
1967
2123
  await onSubmit?.(coreSubmitValues, submitContext);
1968
2124
  });
1969
2125
  } else {
@@ -2014,7 +2170,8 @@ function CrudForm({
2014
2170
  console.debug("[crud-form] Submission failed with field errors", transformedErrors);
2015
2171
  }
2016
2172
  }
2017
- let displayMessage = typeof helperMessage === "string" && helperMessage.trim() ? helperMessage.trim() : "";
2173
+ const optimisticLockConflict = extractOptimisticLockConflict(err);
2174
+ let displayMessage = optimisticLockConflict ? t("ui.forms.flash.recordModified", "This record was modified by someone else. Refresh and try again.") : typeof helperMessage === "string" && helperMessage.trim() ? helperMessage.trim() : "";
2018
2175
  if (hasFieldErrors) {
2019
2176
  const lowered = displayMessage.toLowerCase();
2020
2177
  const highlightedLower = highlightedMessage.toLowerCase();
@@ -2029,7 +2186,12 @@ function CrudForm({
2029
2186
  displayMessage = hasFieldErrors ? highlightedMessage : saveErrorMessage;
2030
2187
  }
2031
2188
  displayMessage = parseServerMessage(displayMessage);
2032
- if (!hasFieldErrors) {
2189
+ if (optimisticLockConflict) {
2190
+ try {
2191
+ surfaceRecordConflict(err, t);
2192
+ } catch {
2193
+ }
2194
+ } else if (!hasFieldErrors) {
2033
2195
  flash(displayMessage, "error");
2034
2196
  }
2035
2197
  setFormError(displayMessage);
@@ -2093,8 +2255,8 @@ function CrudForm({
2093
2255
  if (!("type" in f) || f.type === "custom") continue;
2094
2256
  const builtin = f;
2095
2257
  const staticOptions = builtin.options ?? EMPTY_OPTIONS;
2096
- const dynamic2 = dynamicOptions[f.id];
2097
- if (dynamic2 && dynamic2.length) {
2258
+ const dynamic = dynamicOptions[f.id];
2259
+ if (dynamic && dynamic.length) {
2098
2260
  const merged = [];
2099
2261
  const seen = /* @__PURE__ */ new Set();
2100
2262
  for (const opt of staticOptions) {
@@ -2102,7 +2264,7 @@ function CrudForm({
2102
2264
  seen.add(opt.value);
2103
2265
  merged.push(opt);
2104
2266
  }
2105
- for (const opt of dynamic2) {
2267
+ for (const opt of dynamic) {
2106
2268
  if (seen.has(opt.value)) continue;
2107
2269
  seen.add(opt.value);
2108
2270
  merged.push(opt);
@@ -2110,8 +2272,8 @@ function CrudForm({
2110
2272
  map.set(f.id, merged);
2111
2273
  } else if (staticOptions.length) {
2112
2274
  map.set(f.id, staticOptions);
2113
- } else if (dynamic2) {
2114
- map.set(f.id, dynamic2);
2275
+ } else if (dynamic) {
2276
+ map.set(f.id, dynamic);
2115
2277
  }
2116
2278
  }
2117
2279
  return map;
@@ -2916,58 +3078,8 @@ function TextAreaInput({
2916
3078
  }
2917
3079
  );
2918
3080
  }
2919
- const MDEditor = dynamic(async () => {
2920
- const mod = await import("@uiw/react-md-editor");
2921
- return mod.default;
2922
- }, { ssr: false });
2923
- let markdownPreviewOptionsPromise = null;
2924
- async function loadMarkdownPreviewOptions() {
2925
- if (!markdownPreviewOptionsPromise) {
2926
- markdownPreviewOptionsPromise = import("remark-gfm").then((mod) => ({ remarkPlugins: [mod.default ?? mod] })).catch(() => ({}));
2927
- }
2928
- return markdownPreviewOptionsPromise;
2929
- }
2930
- const EMPTY_PREVIEW_OPTIONS = {};
2931
3081
  const MarkdownEditor = React.memo(function MarkdownEditor2({ value = "", onChange }) {
2932
- const containerRef = React.useRef(null);
2933
- const [local, setLocal] = React.useState(value);
2934
- const [previewOptions, setPreviewOptions] = React.useState(EMPTY_PREVIEW_OPTIONS);
2935
- const typingRef = React.useRef(false);
2936
- React.useEffect(() => {
2937
- if (!typingRef.current) setLocal(value);
2938
- }, [value]);
2939
- React.useEffect(() => {
2940
- let mounted = true;
2941
- void loadMarkdownPreviewOptions().then((resolved) => {
2942
- if (!mounted) return;
2943
- setPreviewOptions(resolved);
2944
- });
2945
- return () => {
2946
- mounted = false;
2947
- };
2948
- }, []);
2949
- const handleChange = React.useCallback((v) => {
2950
- typingRef.current = true;
2951
- setLocal(v ?? "");
2952
- }, []);
2953
- const commit = React.useCallback(() => {
2954
- if (!typingRef.current) return;
2955
- typingRef.current = false;
2956
- onChange(local);
2957
- requestAnimationFrame(() => {
2958
- const ta = containerRef.current?.querySelector("textarea");
2959
- ta?.focus();
2960
- });
2961
- }, [local, onChange]);
2962
- return /* @__PURE__ */ jsx("div", { ref: containerRef, "data-color-mode": "light", className: "w-full", onBlur: () => commit(), children: /* @__PURE__ */ jsx(
2963
- MDEditor,
2964
- {
2965
- value: local,
2966
- height: 220,
2967
- onChange: handleChange,
2968
- previewOptions
2969
- }
2970
- ) });
3082
+ return /* @__PURE__ */ jsx(MarkdownField, { value, onChange });
2971
3083
  }, (prev, next) => prev.value === next.value);
2972
3084
  const HtmlRichEditorField = React.memo(function HtmlRichEditorField2({ value = "", onChange }) {
2973
3085
  const t = useT();
@@ -3111,12 +3223,20 @@ const ListboxMultiSelect = React.memo(function ListboxMultiSelect2({
3111
3223
  return /* @__PURE__ */ jsx(
3112
3224
  Button,
3113
3225
  {
3226
+ type: "button",
3114
3227
  variant: "ghost",
3115
3228
  size: "sm",
3116
3229
  onClick: () => toggle(opt.value),
3117
3230
  className: `w-full justify-start rounded-none font-normal px-3 py-2 ${isSel ? "bg-muted" : ""}`,
3118
3231
  children: /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-2", children: [
3119
- /* @__PURE__ */ jsx(Checkbox, { checked: isSel, disabled: true, tabIndex: -1, className: "pointer-events-none" }),
3232
+ /* @__PURE__ */ jsx(
3233
+ "span",
3234
+ {
3235
+ "aria-hidden": "true",
3236
+ className: `inline-flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-colors ${isSel ? "border-accent-indigo bg-accent-indigo text-accent-indigo-foreground" : "border-input bg-background"}`,
3237
+ children: isSel ? /* @__PURE__ */ jsx(Check, { className: "size-3.5", "aria-hidden": "true" }) : null
3238
+ }
3239
+ ),
3120
3240
  /* @__PURE__ */ jsx("span", { children: opt.label })
3121
3241
  ] })
3122
3242
  },
@@ -3165,6 +3285,12 @@ const FieldControl = React.memo(
3165
3285
  const placeholder = builtin?.placeholder;
3166
3286
  const rootClassName = wrapperClassName ? `space-y-1 ${wrapperClassName}` : "space-y-1";
3167
3287
  const validateOnWrapperBlur = supportsWrapperBlurValidation(field);
3288
+ const singleSelectValue = Array.isArray(value) ? String(value[0] ?? "") : value == null ? "" : String(value);
3289
+ const singleSelectOptionsKey = React.useMemo(
3290
+ () => options.map((opt) => `${opt.value}:${opt.label}`).join("\0"),
3291
+ [options]
3292
+ );
3293
+ const singleSelectLabel = singleSelectValue ? options.find((option) => option.value === singleSelectValue)?.label : void 0;
3168
3294
  return /* @__PURE__ */ jsxs(
3169
3295
  "div",
3170
3296
  {
@@ -3350,7 +3476,7 @@ const FieldControl = React.memo(
3350
3476
  field.type === "select" && !builtin?.multiple && /* @__PURE__ */ jsxs(
3351
3477
  Select,
3352
3478
  {
3353
- value: Array.isArray(value) ? String(value[0] ?? "") : value == null ? "" : String(value),
3479
+ value: singleSelectValue,
3354
3480
  onValueChange: (next) => {
3355
3481
  if (!next || next === SELECT_CLEAR_SENTINEL) {
3356
3482
  const isCustomField = field.id.startsWith("cf_") || field.id.startsWith("cf:");
@@ -3361,13 +3487,14 @@ const FieldControl = React.memo(
3361
3487
  },
3362
3488
  disabled,
3363
3489
  children: [
3364
- /* @__PURE__ */ jsx(SelectTrigger, { "data-crud-focus-target": "", children: /* @__PURE__ */ jsx(SelectValue, { placeholder: t("ui.forms.select.emptyOption", "\u2014") }) }),
3490
+ /* @__PURE__ */ jsx(SelectTrigger, { "data-crud-focus-target": "", children: /* @__PURE__ */ jsx(SelectValue, { placeholder: t("ui.forms.select.emptyOption", "\u2014"), children: singleSelectLabel }) }),
3365
3491
  /* @__PURE__ */ jsxs(SelectContent, { children: [
3366
3492
  !field.required && value != null && value !== "" && /* @__PURE__ */ jsx(SelectItem, { value: SELECT_CLEAR_SENTINEL, children: t("ui.forms.select.clearOption", "\u2014 Clear \u2014") }),
3367
3493
  options.filter((opt) => opt.value !== "").map((opt) => /* @__PURE__ */ jsx(SelectItem, { value: opt.value, children: opt.label }, opt.value))
3368
3494
  ] })
3369
3495
  ]
3370
- }
3496
+ },
3497
+ `${field.id}:${singleSelectValue}:${singleSelectOptionsKey}`
3371
3498
  ),
3372
3499
  field.type === "select" && builtin?.multiple && builtin.listbox === true && /* @__PURE__ */ jsx(
3373
3500
  ListboxMultiSelect,