@open-mercato/ui 0.6.5-develop.4534.1.b459babe6d → 0.6.5-develop.4559.1.839e136509

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -0
  3. package/dist/backend/AppShell.js +6 -0
  4. package/dist/backend/AppShell.js.map +2 -2
  5. package/dist/backend/CrudForm.js +57 -11
  6. package/dist/backend/CrudForm.js.map +2 -2
  7. package/dist/backend/DataTable.js +20 -8
  8. package/dist/backend/DataTable.js.map +3 -3
  9. package/dist/backend/PerspectiveSidebar.js +8 -1
  10. package/dist/backend/PerspectiveSidebar.js.map +2 -2
  11. package/dist/backend/RowActions.js +1 -1
  12. package/dist/backend/RowActions.js.map +2 -2
  13. package/dist/backend/conflicts/RecordConflictBanner.js +73 -0
  14. package/dist/backend/conflicts/RecordConflictBanner.js.map +7 -0
  15. package/dist/backend/conflicts/index.js +32 -0
  16. package/dist/backend/conflicts/index.js.map +7 -0
  17. package/dist/backend/conflicts/store.js +50 -0
  18. package/dist/backend/conflicts/store.js.map +7 -0
  19. package/dist/backend/injection/useGuardedMutation.js +8 -1
  20. package/dist/backend/injection/useGuardedMutation.js.map +2 -2
  21. package/dist/backend/sidebar/SidebarCustomizationEditor.js +23 -7
  22. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  23. package/dist/backend/utils/optimisticLock.js +34 -0
  24. package/dist/backend/utils/optimisticLock.js.map +7 -0
  25. package/package.json +7 -3
  26. package/src/backend/AppShell.tsx +10 -0
  27. package/src/backend/CrudForm.tsx +99 -9
  28. package/src/backend/DataTable.tsx +22 -8
  29. package/src/backend/PerspectiveSidebar.tsx +8 -1
  30. package/src/backend/RowActions.tsx +1 -1
  31. package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +288 -0
  32. package/src/backend/conflicts/RecordConflictBanner.tsx +77 -0
  33. package/src/backend/conflicts/__tests__/RecordConflictBanner.test.tsx +61 -0
  34. package/src/backend/conflicts/__tests__/store.test.ts +78 -0
  35. package/src/backend/conflicts/index.ts +49 -0
  36. package/src/backend/conflicts/store.ts +94 -0
  37. package/src/backend/injection/__tests__/useGuardedMutation.optimisticLock.test.tsx +118 -0
  38. package/src/backend/injection/useGuardedMutation.ts +16 -1
  39. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +35 -11
  40. package/src/backend/utils/__tests__/optimisticLock.test.ts +126 -0
  41. package/src/backend/utils/optimisticLock.ts +70 -0
@@ -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";
@@ -351,6 +354,8 @@ function CrudForm({
351
354
  onSubmit,
352
355
  onDelete,
353
356
  deleteVisible,
357
+ optimisticLockUpdatedAt,
358
+ disableOptimisticLock = false,
354
359
  twoColumn = false,
355
360
  title,
356
361
  backHref,
@@ -458,6 +463,16 @@ function CrudForm({
458
463
  }, [values]);
459
464
  const fallbackRecordId = recordId || (versionHistory?.resourceId === void 0 || versionHistory.resourceId === null ? void 0 : String(versionHistory.resourceId).trim() || void 0);
460
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]);
461
476
  const injectionContext = React.useMemo(() => ({
462
477
  formId,
463
478
  entityId: primaryEntityId,
@@ -883,8 +898,13 @@ function CrudForm({
883
898
  return;
884
899
  }
885
900
  }
886
- if (injectionRequestHeaders && Object.keys(injectionRequestHeaders).length > 0) {
887
- 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 () => {
888
908
  await onDelete();
889
909
  });
890
910
  } else {
@@ -928,10 +948,18 @@ function CrudForm({
928
948
  }
929
949
  } catch {
930
950
  }
931
- const message = err instanceof Error && err.message ? err.message : deleteErrorMessage;
932
- try {
933
- flash(message, "error");
934
- } 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
+ }
935
963
  }
936
964
  } finally {
937
965
  deletingRef.current = false;
@@ -2041,8 +2069,13 @@ function CrudForm({
2041
2069
  }
2042
2070
  try {
2043
2071
  submitNavigationBypassRef.current = true;
2044
- if (injectionRequestHeaders && Object.keys(injectionRequestHeaders).length > 0) {
2045
- 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 () => {
2046
2079
  await onSubmit?.(coreSubmitValues, submitContext);
2047
2080
  });
2048
2081
  } else {
@@ -2093,7 +2126,8 @@ function CrudForm({
2093
2126
  console.debug("[crud-form] Submission failed with field errors", transformedErrors);
2094
2127
  }
2095
2128
  }
2096
- 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() : "";
2097
2131
  if (hasFieldErrors) {
2098
2132
  const lowered = displayMessage.toLowerCase();
2099
2133
  const highlightedLower = highlightedMessage.toLowerCase();
@@ -2108,7 +2142,12 @@ function CrudForm({
2108
2142
  displayMessage = hasFieldErrors ? highlightedMessage : saveErrorMessage;
2109
2143
  }
2110
2144
  displayMessage = parseServerMessage(displayMessage);
2111
- if (!hasFieldErrors) {
2145
+ if (optimisticLockConflict) {
2146
+ try {
2147
+ surfaceRecordConflict(err, t);
2148
+ } catch {
2149
+ }
2150
+ } else if (!hasFieldErrors) {
2112
2151
  flash(displayMessage, "error");
2113
2152
  }
2114
2153
  setFormError(displayMessage);
@@ -3195,7 +3234,14 @@ const ListboxMultiSelect = React.memo(function ListboxMultiSelect2({
3195
3234
  onClick: () => toggle(opt.value),
3196
3235
  className: `w-full justify-start rounded-none font-normal px-3 py-2 ${isSel ? "bg-muted" : ""}`,
3197
3236
  children: /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-2", children: [
3198
- /* @__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
+ ),
3199
3245
  /* @__PURE__ */ jsx("span", { children: opt.label })
3200
3246
  ] })
3201
3247
  },