@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +1 -0
- package/dist/backend/AppShell.js +6 -0
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +120 -15
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +20 -8
- package/dist/backend/DataTable.js.map +3 -3
- package/dist/backend/PerspectiveSidebar.js +8 -1
- package/dist/backend/PerspectiveSidebar.js.map +2 -2
- package/dist/backend/RowActions.js +1 -1
- package/dist/backend/RowActions.js.map +2 -2
- package/dist/backend/conflicts/RecordConflictBanner.js +73 -0
- package/dist/backend/conflicts/RecordConflictBanner.js.map +7 -0
- package/dist/backend/conflicts/index.js +32 -0
- package/dist/backend/conflicts/index.js.map +7 -0
- package/dist/backend/conflicts/store.js +50 -0
- package/dist/backend/conflicts/store.js.map +7 -0
- package/dist/backend/injection/useGuardedMutation.js +8 -1
- package/dist/backend/injection/useGuardedMutation.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +23 -7
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
- package/dist/backend/utils/optimisticLock.js +34 -0
- package/dist/backend/utils/optimisticLock.js.map +7 -0
- package/package.json +7 -3
- package/src/backend/AppShell.tsx +10 -0
- package/src/backend/CrudForm.tsx +189 -12
- package/src/backend/DataTable.tsx +22 -8
- package/src/backend/PerspectiveSidebar.tsx +8 -1
- package/src/backend/RowActions.tsx +1 -1
- package/src/backend/__tests__/CrudForm.dotPath.test.tsx +312 -0
- package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +288 -0
- package/src/backend/conflicts/RecordConflictBanner.tsx +77 -0
- package/src/backend/conflicts/__tests__/RecordConflictBanner.test.tsx +61 -0
- package/src/backend/conflicts/__tests__/store.test.ts +78 -0
- package/src/backend/conflicts/index.ts +49 -0
- package/src/backend/conflicts/store.ts +94 -0
- package/src/backend/injection/__tests__/useGuardedMutation.optimisticLock.test.tsx +118 -0
- package/src/backend/injection/useGuardedMutation.ts +16 -1
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +35 -11
- package/src/backend/utils/__tests__/optimisticLock.test.ts +126 -0
- package/src/backend/utils/optimisticLock.ts +70 -0
package/dist/backend/CrudForm.js
CHANGED
|
@@ -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
|
-
|
|
852
|
-
|
|
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
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|
-
|
|
1986
|
-
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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
|
},
|