@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.
- package/.turbo/turbo-build.log +2 -2
- package/AGENTS.md +2 -1
- package/dist/backend/AppShell.js +6 -0
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +212 -85
- package/dist/backend/CrudForm.js.map +3 -3
- package/dist/backend/DataTable.js +77 -26
- package/dist/backend/DataTable.js.map +3 -3
- package/dist/backend/FlashMessages.js +21 -2
- package/dist/backend/FlashMessages.js.map +2 -2
- package/dist/backend/JsonBuilder.js +64 -41
- package/dist/backend/JsonBuilder.js.map +2 -2
- 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/detail/ActivitiesSection.js +1 -0
- package/dist/backend/detail/ActivitiesSection.js.map +2 -2
- package/dist/backend/detail/InlineEditors.js +2 -51
- package/dist/backend/detail/InlineEditors.js.map +3 -3
- package/dist/backend/detail/RecordNotFoundState.js +10 -5
- package/dist/backend/detail/RecordNotFoundState.js.map +2 -2
- package/dist/backend/filters/AdvancedFilterPanel.js +14 -0
- package/dist/backend/filters/AdvancedFilterPanel.js.map +2 -2
- package/dist/backend/filters/FilteredEmptyResults.js +12 -4
- package/dist/backend/filters/FilteredEmptyResults.js.map +2 -2
- package/dist/backend/filters/ListEmptyState.js +44 -0
- package/dist/backend/filters/ListEmptyState.js.map +7 -0
- package/dist/backend/filters/SearchEmptyResults.js +28 -0
- package/dist/backend/filters/SearchEmptyResults.js.map +7 -0
- package/dist/backend/icons/lucideRegistry.generated.js +8 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/useGuardedMutation.js +8 -1
- package/dist/backend/injection/useGuardedMutation.js.map +2 -2
- package/dist/backend/inputs/MarkdownField.js +20 -0
- package/dist/backend/inputs/MarkdownField.js.map +7 -0
- package/dist/backend/inputs/MdxEditorImpl.js +98 -0
- package/dist/backend/inputs/MdxEditorImpl.js.map +7 -0
- package/dist/backend/inputs/SwitchableMarkdownInput.js +6 -38
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
- package/dist/backend/markdown/markdownToPlainText.js +29 -0
- package/dist/backend/markdown/markdownToPlainText.js.map +2 -2
- package/dist/backend/messages/EmailThreadsPanel.js +212 -0
- package/dist/backend/messages/EmailThreadsPanel.js.map +7 -0
- package/dist/backend/messages/index.js +4 -0
- package/dist/backend/messages/index.js.map +2 -2
- package/dist/backend/messages/mergeOptimisticEmailThreads.js +55 -0
- package/dist/backend/messages/mergeOptimisticEmailThreads.js.map +7 -0
- package/dist/backend/notifications/NotificationDispatcher.js +1 -0
- package/dist/backend/notifications/NotificationDispatcher.js.map +2 -2
- package/dist/backend/notifications/useNotificationsPoll.js +7 -0
- package/dist/backend/notifications/useNotificationsPoll.js.map +2 -2
- package/dist/backend/notifications/useNotificationsSse.js +8 -0
- package/dist/backend/notifications/useNotificationsSse.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/dist/portal/hooks/useTenantContext.js +1 -1
- package/dist/portal/hooks/useTenantContext.js.map +2 -2
- package/dist/primitives/alert.js.map +1 -1
- package/dist/primitives/checkbox.js +2 -1
- package/dist/primitives/checkbox.js.map +2 -2
- package/dist/types/css.d.js +1 -0
- package/dist/types/css.d.js.map +7 -0
- package/package.json +17 -13
- package/src/backend/AppShell.tsx +10 -0
- package/src/backend/CrudForm.tsx +320 -97
- package/src/backend/DataTable.tsx +113 -34
- package/src/backend/FlashMessages.tsx +31 -2
- package/src/backend/JsonBuilder.tsx +79 -26
- package/src/backend/PerspectiveSidebar.tsx +8 -1
- package/src/backend/RowActions.tsx +1 -1
- package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.datetime.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.defaults.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.dotPath.test.tsx +311 -0
- package/src/backend/__tests__/CrudForm.doubleSubmit.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.navigation.test.tsx +120 -1
- package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +287 -0
- package/src/backend/__tests__/CrudForm.render.test.tsx +74 -2
- package/src/backend/__tests__/CrudForm.sortable.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.transformData.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.unsavedNavigation.test.tsx +51 -3
- package/src/backend/__tests__/CrudForm.validation.test.tsx +0 -1
- package/src/backend/__tests__/CustomDataSection.test.tsx +0 -1
- package/src/backend/__tests__/FlashMessages.test.tsx +47 -0
- package/src/backend/__tests__/JsonBuilder.test.tsx +135 -0
- package/src/backend/__tests__/lazy-heavy-libraries.test.ts +16 -3
- 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/detail/ActivitiesSection.tsx +1 -0
- package/src/backend/detail/InlineEditors.tsx +14 -76
- package/src/backend/detail/RecordNotFoundState.tsx +13 -6
- package/src/backend/detail/__tests__/RecordNotFoundState.test.tsx +80 -0
- package/src/backend/filters/AdvancedFilterPanel.tsx +27 -0
- package/src/backend/filters/FilteredEmptyResults.tsx +23 -10
- package/src/backend/filters/ListEmptyState.tsx +69 -0
- package/src/backend/filters/SearchEmptyResults.tsx +42 -0
- package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +98 -0
- package/src/backend/filters/__tests__/ListEmptyState.test.tsx +53 -0
- package/src/backend/icons/lucideRegistry.generated.tsx +8 -0
- package/src/backend/injection/__tests__/useGuardedMutation.optimisticLock.test.tsx +118 -0
- package/src/backend/injection/useGuardedMutation.ts +16 -1
- package/src/backend/inputs/MarkdownField.tsx +34 -0
- package/src/backend/inputs/MdxEditorImpl.tsx +113 -0
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +5 -54
- package/src/backend/markdown/__tests__/markdownToPlainText.test.ts +13 -0
- package/src/backend/markdown/markdownToPlainText.ts +41 -0
- package/src/backend/messages/EmailThreadsPanel.tsx +351 -0
- package/src/backend/messages/__tests__/mergeOptimisticEmailThreads.test.ts +126 -0
- package/src/backend/messages/index.ts +12 -0
- package/src/backend/messages/mergeOptimisticEmailThreads.ts +84 -0
- package/src/backend/notifications/NotificationDispatcher.ts +2 -0
- package/src/backend/notifications/useNotificationsPoll.ts +5 -0
- package/src/backend/notifications/useNotificationsSse.ts +6 -0
- 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/src/portal/hooks/useTenantContext.ts +7 -2
- package/src/primitives/__tests__/checkbox.test.tsx +28 -0
- package/src/primitives/alert.tsx +1 -1
- package/src/primitives/checkbox.tsx +2 -1
- package/src/types/css.d.ts +5 -0
package/dist/backend/CrudForm.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 &&
|
|
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 &&
|
|
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
|
-
|
|
832
|
-
|
|
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
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1966
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
2097
|
-
if (
|
|
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
|
|
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 (
|
|
2114
|
-
map.set(f.id,
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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,
|