@open-mercato/ui 0.6.5-develop.4516.1.88e6ab71a9 → 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 (42) 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 +120 -15
  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 +189 -12
  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.dotPath.test.tsx +312 -0
  32. package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +288 -0
  33. package/src/backend/conflicts/RecordConflictBanner.tsx +77 -0
  34. package/src/backend/conflicts/__tests__/RecordConflictBanner.test.tsx +61 -0
  35. package/src/backend/conflicts/__tests__/store.test.ts +78 -0
  36. package/src/backend/conflicts/index.ts +49 -0
  37. package/src/backend/conflicts/store.ts +94 -0
  38. package/src/backend/injection/__tests__/useGuardedMutation.optimisticLock.test.tsx +118 -0
  39. package/src/backend/injection/useGuardedMutation.ts +16 -1
  40. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +35 -11
  41. package/src/backend/utils/__tests__/optimisticLock.test.ts +126 -0
  42. package/src/backend/utils/optimisticLock.ts +70 -0
@@ -32,6 +32,7 @@ import { FormFooter } from "./forms/FormFooter.js";
32
32
  import { Button } from "../primitives/button.js";
33
33
  import { IconButton } from "../primitives/icon-button.js";
34
34
  import {
35
+ Check,
35
36
  Settings,
36
37
  Layers,
37
38
  Tag,
@@ -80,6 +81,8 @@ import { TimePicker } from "./inputs/TimePicker.js";
80
81
  import { DatePicker } from "./inputs/DatePicker.js";
81
82
  import { mapCrudServerErrorToFormErrors, parseServerMessage } from "./utils/serverErrors.js";
82
83
  import { withScopedApiRequestHeaders } from "./utils/apiCall.js";
84
+ import { buildOptimisticLockHeader, extractOptimisticLockConflict } from "./utils/optimisticLock.js";
85
+ import { surfaceRecordConflict } from "./conflicts/index.js";
83
86
  import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../primitives/dialog.js";
84
87
  import { FieldDefinitionsManager } from "./custom-fields/FieldDefinitionsManager.js";
85
88
  import { useConfirmDialog } from "./confirm-dialog/index.js";
@@ -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];
@@ -316,6 +354,8 @@ function CrudForm({
316
354
  onSubmit,
317
355
  onDelete,
318
356
  deleteVisible,
357
+ optimisticLockUpdatedAt,
358
+ disableOptimisticLock = false,
319
359
  twoColumn = false,
320
360
  title,
321
361
  backHref,
@@ -423,6 +463,16 @@ function CrudForm({
423
463
  }, [values]);
424
464
  const fallbackRecordId = recordId || (versionHistory?.resourceId === void 0 || versionHistory.resourceId === null ? void 0 : String(versionHistory.resourceId).trim() || void 0);
425
465
  const operation = recordId ? "update" : "create";
466
+ const resolvedOptimisticLockUpdatedAt = React.useMemo(() => {
467
+ if (disableOptimisticLock) return null;
468
+ if (optimisticLockUpdatedAt !== void 0) return optimisticLockUpdatedAt;
469
+ const source = initialValues;
470
+ const camel = source?.updatedAt;
471
+ if (typeof camel === "string" && camel.length > 0) return camel;
472
+ const snake = source?.updated_at;
473
+ if (typeof snake === "string" && snake.length > 0) return snake;
474
+ return null;
475
+ }, [disableOptimisticLock, optimisticLockUpdatedAt, initialValues]);
426
476
  const injectionContext = React.useMemo(() => ({
427
477
  formId,
428
478
  entityId: primaryEntityId,
@@ -848,8 +898,13 @@ function CrudForm({
848
898
  return;
849
899
  }
850
900
  }
851
- if (injectionRequestHeaders && Object.keys(injectionRequestHeaders).length > 0) {
852
- await withScopedApiRequestHeaders(injectionRequestHeaders, async () => {
901
+ const optimisticLockHeader = buildOptimisticLockHeader(resolvedOptimisticLockUpdatedAt);
902
+ const mergedDeleteHeaders = {
903
+ ...injectionRequestHeaders ?? {},
904
+ ...optimisticLockHeader
905
+ };
906
+ if (Object.keys(mergedDeleteHeaders).length > 0) {
907
+ await withScopedApiRequestHeaders(mergedDeleteHeaders, async () => {
853
908
  await onDelete();
854
909
  });
855
910
  } else {
@@ -893,10 +948,18 @@ function CrudForm({
893
948
  }
894
949
  } catch {
895
950
  }
896
- const message = err instanceof Error && err.message ? err.message : deleteErrorMessage;
897
- try {
898
- flash(message, "error");
899
- } catch {
951
+ const optimisticLockConflict = extractOptimisticLockConflict(err);
952
+ if (optimisticLockConflict) {
953
+ try {
954
+ surfaceRecordConflict(err, t);
955
+ } catch {
956
+ }
957
+ } else {
958
+ const message = err instanceof Error && err.message ? err.message : deleteErrorMessage;
959
+ try {
960
+ flash(message, "error");
961
+ } catch {
962
+ }
900
963
  }
901
964
  } finally {
902
965
  deletingRef.current = false;
@@ -1257,6 +1320,19 @@ function CrudForm({
1257
1320
  const fieldById = React.useMemo(() => {
1258
1321
  return new globalThis.Map(allFields.map((f) => [f.id, f]));
1259
1322
  }, [allFields]);
1323
+ const dotPathBaseFieldIds = React.useMemo(() => {
1324
+ const ids = /* @__PURE__ */ new Set();
1325
+ const cfFieldIds = new Set(cfFields.map((field) => field.id));
1326
+ for (const field of allFields) {
1327
+ const fieldId = field.id;
1328
+ if (!fieldId.includes(".")) continue;
1329
+ if (injectedFieldIdSet.has(fieldId)) continue;
1330
+ if (fieldId.startsWith("cf_") || fieldId.startsWith("cf:")) continue;
1331
+ if (cfFieldIds.has(fieldId)) continue;
1332
+ ids.add(fieldId);
1333
+ }
1334
+ return ids;
1335
+ }, [allFields, injectedFieldIdSet, cfFields]);
1260
1336
  const validateFieldOnBlur = React.useCallback(async (fieldId, sourceValues) => {
1261
1337
  if (formReadOnly) return;
1262
1338
  const field = fieldById.get(fieldId);
@@ -1307,7 +1383,7 @@ function CrudForm({
1307
1383
  for (const injectedId of injectedFieldIdSet) {
1308
1384
  delete coreValues[injectedId];
1309
1385
  }
1310
- const result = schema.safeParse(coreValues);
1386
+ const result = schema.safeParse(collapseDotPathFields(coreValues, dotPathBaseFieldIds));
1311
1387
  if (!result.success) {
1312
1388
  const schemaFieldErrors = {};
1313
1389
  result.error.issues.forEach((issue) => {
@@ -1336,6 +1412,7 @@ function CrudForm({
1336
1412
  }, [
1337
1413
  cfDefinitions,
1338
1414
  customEntity,
1415
+ dotPathBaseFieldIds,
1339
1416
  fieldById,
1340
1417
  formReadOnly,
1341
1418
  hiddenBaseFieldIds,
@@ -1670,7 +1747,8 @@ function CrudForm({
1670
1747
  const snapshot = JSON.stringify({
1671
1748
  initialValues,
1672
1749
  injectedFieldIds: injectedFieldDefinitions.map((definition) => definition.id),
1673
- customFieldMappings: cfDefinitions.map((definition) => definition.key)
1750
+ customFieldMappings: cfDefinitions.map((definition) => definition.key),
1751
+ dotPathBaseFieldIds: Array.from(dotPathBaseFieldIds)
1674
1752
  });
1675
1753
  if (appliedInitialValuesSnapshotRef.current === snapshot) return;
1676
1754
  appliedInitialValuesSnapshotRef.current = snapshot;
@@ -1695,6 +1773,14 @@ function CrudForm({
1695
1773
  merged[targetId] = extracted;
1696
1774
  }
1697
1775
  }
1776
+ for (const fieldId of dotPathBaseFieldIds) {
1777
+ if (merged[fieldId] !== void 0) continue;
1778
+ const extracted = readByDotPath(initialRecord, fieldId);
1779
+ if (extracted !== void 0) {
1780
+ ;
1781
+ merged[fieldId] = extracted;
1782
+ }
1783
+ }
1698
1784
  mergedValues = merged;
1699
1785
  return mergedValues;
1700
1786
  });
@@ -1725,6 +1811,7 @@ function CrudForm({
1725
1811
  }, [
1726
1812
  cfDefinitions,
1727
1813
  customEntity,
1814
+ dotPathBaseFieldIds,
1728
1815
  extendedInjectionEventsEnabled,
1729
1816
  initialValues,
1730
1817
  injectedFieldDefinitions,
@@ -1881,7 +1968,7 @@ function CrudForm({
1881
1968
  }
1882
1969
  let parsedValues;
1883
1970
  if (schema) {
1884
- const res = schema.safeParse(coreValues);
1971
+ const res = schema.safeParse(collapseDotPathFields(coreValues, dotPathBaseFieldIds));
1885
1972
  if (!res.success) {
1886
1973
  const fieldErrors = {};
1887
1974
  res.error.issues.forEach((issue) => {
@@ -1911,7 +1998,7 @@ function CrudForm({
1911
1998
  for (const injectedId of injectedFieldIdSet) {
1912
1999
  delete projectedCoreValues[injectedId];
1913
2000
  }
1914
- coreSubmitValues = schema ? schema.parse(projectedCoreValues) : projectedCoreValues;
2001
+ coreSubmitValues = schema ? schema.parse(collapseDotPathFields(projectedCoreValues, dotPathBaseFieldIds)) : projectedCoreValues;
1915
2002
  if (result.applyToForm) {
1916
2003
  setValues(result.data);
1917
2004
  }
@@ -1982,8 +2069,13 @@ function CrudForm({
1982
2069
  }
1983
2070
  try {
1984
2071
  submitNavigationBypassRef.current = true;
1985
- if (injectionRequestHeaders && Object.keys(injectionRequestHeaders).length > 0) {
1986
- await withScopedApiRequestHeaders(injectionRequestHeaders, async () => {
2072
+ const optimisticLockHeader = buildOptimisticLockHeader(resolvedOptimisticLockUpdatedAt);
2073
+ const mergedSubmitHeaders = {
2074
+ ...injectionRequestHeaders ?? {},
2075
+ ...optimisticLockHeader
2076
+ };
2077
+ if (Object.keys(mergedSubmitHeaders).length > 0) {
2078
+ await withScopedApiRequestHeaders(mergedSubmitHeaders, async () => {
1987
2079
  await onSubmit?.(coreSubmitValues, submitContext);
1988
2080
  });
1989
2081
  } else {
@@ -2034,7 +2126,8 @@ function CrudForm({
2034
2126
  console.debug("[crud-form] Submission failed with field errors", transformedErrors);
2035
2127
  }
2036
2128
  }
2037
- let displayMessage = typeof helperMessage === "string" && helperMessage.trim() ? helperMessage.trim() : "";
2129
+ const optimisticLockConflict = extractOptimisticLockConflict(err);
2130
+ 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() : "";
2038
2131
  if (hasFieldErrors) {
2039
2132
  const lowered = displayMessage.toLowerCase();
2040
2133
  const highlightedLower = highlightedMessage.toLowerCase();
@@ -2049,7 +2142,12 @@ function CrudForm({
2049
2142
  displayMessage = hasFieldErrors ? highlightedMessage : saveErrorMessage;
2050
2143
  }
2051
2144
  displayMessage = parseServerMessage(displayMessage);
2052
- if (!hasFieldErrors) {
2145
+ if (optimisticLockConflict) {
2146
+ try {
2147
+ surfaceRecordConflict(err, t);
2148
+ } catch {
2149
+ }
2150
+ } else if (!hasFieldErrors) {
2053
2151
  flash(displayMessage, "error");
2054
2152
  }
2055
2153
  setFormError(displayMessage);
@@ -3136,7 +3234,14 @@ const ListboxMultiSelect = React.memo(function ListboxMultiSelect2({
3136
3234
  onClick: () => toggle(opt.value),
3137
3235
  className: `w-full justify-start rounded-none font-normal px-3 py-2 ${isSel ? "bg-muted" : ""}`,
3138
3236
  children: /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-2", children: [
3139
- /* @__PURE__ */ jsx(Checkbox, { checked: isSel, disabled: true, tabIndex: -1, className: "pointer-events-none" }),
3237
+ /* @__PURE__ */ jsx(
3238
+ "span",
3239
+ {
3240
+ "aria-hidden": "true",
3241
+ 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"}`,
3242
+ children: isSel ? /* @__PURE__ */ jsx(Check, { className: "size-3.5", "aria-hidden": "true" }) : null
3243
+ }
3244
+ ),
3140
3245
  /* @__PURE__ */ jsx("span", { children: opt.label })
3141
3246
  ] })
3142
3247
  },