@open-mercato/ui 0.4.5-develop-03023b2707 → 0.4.5-develop-0c30cb4b11

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 (54) hide show
  1. package/AGENTS.md +8 -0
  2. package/dist/backend/AppShell.js +395 -134
  3. package/dist/backend/AppShell.js.map +2 -2
  4. package/dist/backend/CrudForm.js +232 -21
  5. package/dist/backend/CrudForm.js.map +2 -2
  6. package/dist/backend/ProfileDropdown.js +214 -94
  7. package/dist/backend/ProfileDropdown.js.map +2 -2
  8. package/dist/backend/injection/InjectionSpot.js +74 -4
  9. package/dist/backend/injection/InjectionSpot.js.map +2 -2
  10. package/dist/backend/injection/SseEventIndicator.js +16 -0
  11. package/dist/backend/injection/SseEventIndicator.js.map +7 -0
  12. package/dist/backend/injection/WidgetSharedState.js +49 -0
  13. package/dist/backend/injection/WidgetSharedState.js.map +7 -0
  14. package/dist/backend/injection/eventBridge.js +105 -0
  15. package/dist/backend/injection/eventBridge.js.map +7 -0
  16. package/dist/backend/injection/mergeMenuItems.js +43 -0
  17. package/dist/backend/injection/mergeMenuItems.js.map +7 -0
  18. package/dist/backend/injection/resolveInjectedIcon.js +23 -0
  19. package/dist/backend/injection/resolveInjectedIcon.js.map +7 -0
  20. package/dist/backend/injection/spotIds.js +40 -1
  21. package/dist/backend/injection/spotIds.js.map +2 -2
  22. package/dist/backend/injection/useAppEvent.js +35 -0
  23. package/dist/backend/injection/useAppEvent.js.map +7 -0
  24. package/dist/backend/injection/useInjectedMenuItems.js +92 -0
  25. package/dist/backend/injection/useInjectedMenuItems.js.map +7 -0
  26. package/dist/backend/injection/useInjectionDataWidgets.js +36 -0
  27. package/dist/backend/injection/useInjectionDataWidgets.js.map +7 -0
  28. package/dist/backend/injection/useOperationProgress.js +64 -0
  29. package/dist/backend/injection/useOperationProgress.js.map +7 -0
  30. package/dist/backend/injection/useWidgetSharedState.js +26 -0
  31. package/dist/backend/injection/useWidgetSharedState.js.map +7 -0
  32. package/dist/backend/section-page/SectionNav.js +22 -2
  33. package/dist/backend/section-page/SectionNav.js.map +2 -2
  34. package/dist/backend/utils/api.js +9 -1
  35. package/dist/backend/utils/api.js.map +2 -2
  36. package/package.json +2 -2
  37. package/src/backend/AGENTS.md +50 -0
  38. package/src/backend/AppShell.tsx +317 -30
  39. package/src/backend/CrudForm.tsx +238 -21
  40. package/src/backend/ProfileDropdown.tsx +199 -78
  41. package/src/backend/injection/InjectionSpot.tsx +118 -16
  42. package/src/backend/injection/SseEventIndicator.tsx +24 -0
  43. package/src/backend/injection/WidgetSharedState.ts +58 -0
  44. package/src/backend/injection/eventBridge.ts +134 -0
  45. package/src/backend/injection/mergeMenuItems.ts +71 -0
  46. package/src/backend/injection/resolveInjectedIcon.tsx +30 -0
  47. package/src/backend/injection/spotIds.ts +38 -0
  48. package/src/backend/injection/useAppEvent.ts +76 -0
  49. package/src/backend/injection/useInjectedMenuItems.ts +125 -0
  50. package/src/backend/injection/useInjectionDataWidgets.ts +41 -0
  51. package/src/backend/injection/useOperationProgress.ts +105 -0
  52. package/src/backend/injection/useWidgetSharedState.ts +28 -0
  53. package/src/backend/section-page/SectionNav.tsx +22 -1
  54. package/src/backend/utils/api.ts +14 -5
@@ -64,8 +64,13 @@ import { useConfirmDialog } from "./confirm-dialog/index.js";
64
64
  import { useInjectionSpotEvents, InjectionSpot, useInjectionWidgets } from "./injection/InjectionSpot.js";
65
65
  import { dispatchBackendMutationError } from "./injection/mutationEvents.js";
66
66
  import { VersionHistoryAction } from "./version-history/VersionHistoryAction.js";
67
+ import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
67
68
  const EMPTY_OPTIONS = [];
68
69
  const FOCUSABLE_SELECTOR = '[data-crud-focus-target], input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
70
+ const CRUDFORM_EXTENDED_EVENTS_ENABLED = parseBooleanWithDefault(
71
+ process.env.NEXT_PUBLIC_OM_CRUDFORM_EXTENDED_EVENTS_ENABLED,
72
+ true
73
+ );
69
74
  const FIELDSET_ICON_COMPONENTS = {
70
75
  layers: Layers,
71
76
  tag: Tag,
@@ -162,6 +167,7 @@ function CrudForm({
162
167
  const [values, setValues] = React.useState(
163
168
  () => ({ ...initialValues ?? {} })
164
169
  );
170
+ const valuesRef = React.useRef(values);
165
171
  const [errors, setErrors] = React.useState({});
166
172
  const [pending, setPending] = React.useState(false);
167
173
  const [formError, setFormError] = React.useState(null);
@@ -217,12 +223,138 @@ function CrudForm({
217
223
  recordId: fallbackRecordId,
218
224
  isLoading,
219
225
  pending
220
- }), [formId, primaryEntityId, versionHistory?.resourceKind, versionHistory?.resourceId, fallbackRecordId, isLoading, pending]);
226
+ }), [formId, primaryEntityId, versionHistory?.resourceKind, versionHistory?.resourceId, recordId, fallbackRecordId, isLoading, pending]);
227
+ const injectionContextRef = React.useRef(injectionContext);
228
+ React.useEffect(() => {
229
+ injectionContextRef.current = injectionContext;
230
+ }, [injectionContext]);
231
+ React.useEffect(() => {
232
+ valuesRef.current = values;
233
+ }, [values]);
221
234
  const { widgets: injectionWidgets } = useInjectionWidgets(resolvedInjectionSpotId, {
222
235
  context: injectionContext,
223
236
  triggerOnLoad: true
224
237
  });
225
238
  const { triggerEvent: triggerInjectionEvent } = useInjectionSpotEvents(resolvedInjectionSpotId ?? "", injectionWidgets);
239
+ const extendedInjectionEventsEnabled = CRUDFORM_EXTENDED_EVENTS_ENABLED && Boolean(resolvedInjectionSpotId);
240
+ const transformValidationErrors = React.useCallback(
241
+ async (fieldErrors) => {
242
+ if (!extendedInjectionEventsEnabled || !Object.keys(fieldErrors).length) return fieldErrors;
243
+ try {
244
+ const result = await triggerInjectionEvent(
245
+ "transformValidation",
246
+ fieldErrors,
247
+ injectionContextRef.current,
248
+ { originalData: valuesRef.current }
249
+ );
250
+ const transformed = result.data;
251
+ if (!transformed || typeof transformed !== "object" || Array.isArray(transformed)) return fieldErrors;
252
+ return Object.fromEntries(
253
+ Object.entries(transformed).map(([key, value]) => [key, String(value)])
254
+ );
255
+ } catch (err) {
256
+ console.error("[CrudForm] Error in transformValidation:", err);
257
+ return fieldErrors;
258
+ }
259
+ },
260
+ [extendedInjectionEventsEnabled, triggerInjectionEvent]
261
+ );
262
+ const canNavigateTo = React.useCallback(
263
+ async (target) => {
264
+ if (!extendedInjectionEventsEnabled) return true;
265
+ try {
266
+ const result = await triggerInjectionEvent(
267
+ "onBeforeNavigate",
268
+ valuesRef.current,
269
+ injectionContextRef.current,
270
+ { target }
271
+ );
272
+ if (!result.ok) {
273
+ flash(result.message || t("ui.forms.flash.saveBlocked", "Save blocked by validation"), "error");
274
+ return false;
275
+ }
276
+ return true;
277
+ } catch (err) {
278
+ const message = err instanceof Error && err.message ? err.message : t("ui.forms.flash.saveBlocked", "Save blocked by validation");
279
+ flash(message, "error");
280
+ return false;
281
+ }
282
+ },
283
+ [extendedInjectionEventsEnabled, t, triggerInjectionEvent]
284
+ );
285
+ const navigateWithGuard = React.useCallback(
286
+ async (target) => {
287
+ if (!target) return;
288
+ const allowed = await canNavigateTo(target);
289
+ if (allowed) router.push(target);
290
+ },
291
+ [canNavigateTo, router]
292
+ );
293
+ React.useEffect(() => {
294
+ if (!extendedInjectionEventsEnabled || typeof window === "undefined") return;
295
+ const handleEvent = (event) => {
296
+ const customEvent = event;
297
+ void triggerInjectionEvent("onAppEvent", valuesRef.current, injectionContextRef.current, {
298
+ appEvent: customEvent.detail
299
+ }).catch((err) => {
300
+ console.error("[CrudForm] Error in onAppEvent:", err);
301
+ });
302
+ };
303
+ window.addEventListener("om:event", handleEvent);
304
+ return () => {
305
+ window.removeEventListener("om:event", handleEvent);
306
+ };
307
+ }, [extendedInjectionEventsEnabled, triggerInjectionEvent]);
308
+ React.useEffect(() => {
309
+ if (!extendedInjectionEventsEnabled || typeof document === "undefined") return;
310
+ const emitVisibility = () => {
311
+ void triggerInjectionEvent("onVisibilityChange", valuesRef.current, injectionContextRef.current, {
312
+ visible: document.visibilityState === "visible"
313
+ }).catch((err) => {
314
+ console.error("[CrudForm] Error in onVisibilityChange:", err);
315
+ });
316
+ };
317
+ document.addEventListener("visibilitychange", emitVisibility);
318
+ emitVisibility();
319
+ return () => {
320
+ document.removeEventListener("visibilitychange", emitVisibility);
321
+ };
322
+ }, [extendedInjectionEventsEnabled, triggerInjectionEvent]);
323
+ React.useEffect(() => {
324
+ if (!extendedInjectionEventsEnabled) return;
325
+ const root = rootRef.current;
326
+ if (!root || typeof window === "undefined") return;
327
+ const handleClickCapture = (event) => {
328
+ if (event.defaultPrevented) return;
329
+ if (event.button !== 0) return;
330
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
331
+ const targetElement = event.target instanceof Element ? event.target : null;
332
+ const linkElement = targetElement?.closest("a[href]");
333
+ if (!(linkElement instanceof HTMLAnchorElement)) return;
334
+ if (!root.contains(linkElement)) return;
335
+ if (linkElement.target && linkElement.target !== "_self") return;
336
+ const rawHref = linkElement.getAttribute("href");
337
+ if (!rawHref || rawHref.startsWith("#")) return;
338
+ let target = rawHref;
339
+ if (rawHref.startsWith("http://") || rawHref.startsWith("https://")) {
340
+ try {
341
+ const parsed = new URL(rawHref);
342
+ if (parsed.origin !== window.location.origin) return;
343
+ target = `${parsed.pathname}${parsed.search}${parsed.hash}`;
344
+ } catch {
345
+ return;
346
+ }
347
+ } else if (!rawHref.startsWith("/")) {
348
+ return;
349
+ }
350
+ event.preventDefault();
351
+ void navigateWithGuard(target);
352
+ };
353
+ root.addEventListener("click", handleClickCapture, true);
354
+ return () => {
355
+ root.removeEventListener("click", handleClickCapture, true);
356
+ };
357
+ }, [extendedInjectionEventsEnabled, navigateWithGuard]);
226
358
  React.useEffect(() => {
227
359
  const root = rootRef.current;
228
360
  if (!root) return;
@@ -276,7 +408,8 @@ function CrudForm({
276
408
  } catch {
277
409
  }
278
410
  if (result.fieldErrors && Object.keys(result.fieldErrors).length) {
279
- setErrors(result.fieldErrors);
411
+ const transformedErrors = await transformValidationErrors(result.fieldErrors);
412
+ setErrors(transformedErrors);
280
413
  }
281
414
  const message = result.message || t("ui.forms.flash.saveBlocked", "Save blocked by validation");
282
415
  flash(message, "error");
@@ -318,7 +451,7 @@ function CrudForm({
318
451
  } catch {
319
452
  }
320
453
  if (typeof deleteRedirect === "string" && deleteRedirect) {
321
- router.push(deleteRedirect);
454
+ await navigateWithGuard(deleteRedirect);
322
455
  }
323
456
  } catch (err) {
324
457
  if (resolvedInjectionSpotId) {
@@ -362,8 +495,9 @@ function CrudForm({
362
495
  injectionContext,
363
496
  onDelete,
364
497
  resolvedInjectionSpotId,
365
- router,
498
+ navigateWithGuard,
366
499
  t,
500
+ transformValidationErrors,
367
501
  triggerInjectionEvent,
368
502
  values
369
503
  ]);
@@ -780,11 +914,43 @@ function CrudForm({
780
914
  }
781
915
  }, [errors, formId]);
782
916
  const setValue = React.useCallback((id, nextValue) => {
917
+ let nextData = null;
783
918
  setValues((prev) => {
784
919
  if (Object.is(prev[id], nextValue)) return prev;
785
- return { ...prev, [id]: nextValue };
920
+ nextData = { ...prev, [id]: nextValue };
921
+ return nextData;
786
922
  });
787
- }, []);
923
+ if (!nextData || !extendedInjectionEventsEnabled) return;
924
+ void triggerInjectionEvent("onFieldChange", nextData, injectionContextRef.current, {
925
+ fieldId: id,
926
+ fieldValue: nextValue
927
+ }).then((result) => {
928
+ if (!result.ok) return;
929
+ const change = result.fieldChange;
930
+ if (!change) return;
931
+ const updates = { ...change.sideEffects ?? {} };
932
+ if (change.value !== void 0) {
933
+ updates[id] = change.value;
934
+ }
935
+ if (Object.keys(updates).length > 0) {
936
+ setValues((prev) => {
937
+ let changed = false;
938
+ const next = { ...prev };
939
+ for (const [key, value] of Object.entries(updates)) {
940
+ if (Object.is(next[key], value)) continue;
941
+ next[key] = value;
942
+ changed = true;
943
+ }
944
+ return changed ? next : prev;
945
+ });
946
+ }
947
+ for (const message of change.messages ?? []) {
948
+ flash(message.text, message.severity);
949
+ }
950
+ }).catch((err) => {
951
+ console.error("[CrudForm] Error in onFieldChange:", err);
952
+ });
953
+ }, [extendedInjectionEventsEnabled, flash, triggerInjectionEvent]);
788
954
  const handleFieldsetSelectionChange = React.useCallback(
789
955
  (entityId2, nextCode) => {
790
956
  setCfFieldsetSelections((prev) => ({ ...prev, [entityId2]: nextCode }));
@@ -809,8 +975,32 @@ function CrudForm({
809
975
  const snapshot = JSON.stringify(initialValues);
810
976
  if (initialValuesSnapshotRef.current === snapshot) return;
811
977
  initialValuesSnapshotRef.current = snapshot;
812
- setValues((prev) => ({ ...prev, ...initialValues }));
813
- }, [initialValues]);
978
+ let mergedValues = null;
979
+ setValues((prev) => {
980
+ mergedValues = { ...prev, ...initialValues };
981
+ return mergedValues;
982
+ });
983
+ if (!extendedInjectionEventsEnabled || !mergedValues) return;
984
+ let cancelled = false;
985
+ const run = async () => {
986
+ try {
987
+ const result = await triggerInjectionEvent(
988
+ "transformDisplayData",
989
+ mergedValues,
990
+ injectionContextRef.current
991
+ );
992
+ const transformed = result.data;
993
+ if (cancelled || !transformed) return;
994
+ setValues(transformed);
995
+ } catch (err) {
996
+ console.error("[CrudForm] Error in transformDisplayData:", err);
997
+ }
998
+ };
999
+ void run();
1000
+ return () => {
1001
+ cancelled = true;
1002
+ };
1003
+ }, [extendedInjectionEventsEnabled, initialValues, triggerInjectionEvent]);
814
1004
  const buildFieldsetEditorHref = React.useCallback(
815
1005
  (includeViewParam) => {
816
1006
  if (!fieldsetEditorTarget) return null;
@@ -864,7 +1054,8 @@ function CrudForm({
864
1054
  if (process.env.NODE_ENV !== "production") {
865
1055
  console.debug("[crud-form] Required field errors prevented submit", requiredErrors);
866
1056
  }
867
- setErrors(requiredErrors);
1057
+ const transformedErrors = await transformValidationErrors(requiredErrors);
1058
+ setErrors(transformedErrors);
868
1059
  flash(highlightedMessage, "error");
869
1060
  return;
870
1061
  }
@@ -891,9 +1082,15 @@ function CrudForm({
891
1082
  if (customEntity) {
892
1083
  const mapped = {};
893
1084
  for (const [ek, ev] of Object.entries(result.fieldErrors)) mapped[ek.replace(/^cf_/, "")] = String(ev);
894
- setErrors((prev) => ({ ...prev, ...mapped }));
1085
+ const transformedErrors = await transformValidationErrors(mapped);
1086
+ setErrors((prev) => ({ ...prev, ...transformedErrors }));
895
1087
  } else {
896
- setErrors((prev) => ({ ...prev, ...result.fieldErrors }));
1088
+ const transformedErrors = await transformValidationErrors(
1089
+ Object.fromEntries(
1090
+ Object.entries(result.fieldErrors).map(([key, value]) => [key, String(value)])
1091
+ )
1092
+ );
1093
+ setErrors((prev) => ({ ...prev, ...transformedErrors }));
897
1094
  }
898
1095
  flash(highlightedMessage, "error");
899
1096
  return;
@@ -912,7 +1109,8 @@ function CrudForm({
912
1109
  if (process.env.NODE_ENV !== "production") {
913
1110
  console.debug("[crud-form] Schema validation failed", res.error.issues);
914
1111
  }
915
- setErrors(fieldErrors);
1112
+ const transformedErrors = await transformValidationErrors(fieldErrors);
1113
+ setErrors(transformedErrors);
916
1114
  flash(highlightedMessage, "error");
917
1115
  return;
918
1116
  }
@@ -920,10 +1118,21 @@ function CrudForm({
920
1118
  } else {
921
1119
  parsedValues = values;
922
1120
  }
1121
+ let submitValues = parsedValues;
1122
+ if (extendedInjectionEventsEnabled) {
1123
+ try {
1124
+ const result = await triggerInjectionEvent("transformFormData", submitValues, injectionContext);
1125
+ if (result.data) {
1126
+ submitValues = result.data;
1127
+ }
1128
+ } catch (err) {
1129
+ console.error("[CrudForm] Error in transformFormData:", err);
1130
+ }
1131
+ }
923
1132
  let injectionRequestHeaders;
924
1133
  if (resolvedInjectionSpotId) {
925
1134
  try {
926
- const result = await triggerInjectionEvent("onBeforeSave", parsedValues, injectionContext);
1135
+ const result = await triggerInjectionEvent("onBeforeSave", submitValues, injectionContext);
927
1136
  if (!result.ok) {
928
1137
  try {
929
1138
  if (typeof window !== "undefined") {
@@ -942,7 +1151,8 @@ function CrudForm({
942
1151
  } catch {
943
1152
  }
944
1153
  if (result.fieldErrors && Object.keys(result.fieldErrors).length) {
945
- setErrors(result.fieldErrors);
1154
+ const transformedErrors = await transformValidationErrors(result.fieldErrors);
1155
+ setErrors(transformedErrors);
946
1156
  }
947
1157
  const message = result.message || t("ui.forms.flash.saveBlocked", "Save blocked by validation");
948
1158
  flash(message, "error");
@@ -960,7 +1170,7 @@ function CrudForm({
960
1170
  setPending(true);
961
1171
  if (resolvedInjectionSpotId) {
962
1172
  try {
963
- await triggerInjectionEvent("onSave", parsedValues, injectionContext);
1173
+ await triggerInjectionEvent("onSave", submitValues, injectionContext);
964
1174
  } catch (err) {
965
1175
  console.error("[CrudForm] Error in onSave:", err);
966
1176
  flash(t("ui.forms.flash.saveBlocked", "Save blocked by validation"), "error");
@@ -971,19 +1181,19 @@ function CrudForm({
971
1181
  try {
972
1182
  if (injectionRequestHeaders && Object.keys(injectionRequestHeaders).length > 0) {
973
1183
  await withScopedApiRequestHeaders(injectionRequestHeaders, async () => {
974
- await onSubmit?.(parsedValues);
1184
+ await onSubmit?.(submitValues);
975
1185
  });
976
1186
  } else {
977
- await onSubmit?.(parsedValues);
1187
+ await onSubmit?.(submitValues);
978
1188
  }
979
1189
  if (resolvedInjectionSpotId) {
980
1190
  try {
981
- await triggerInjectionEvent("onAfterSave", parsedValues, injectionContext);
1191
+ await triggerInjectionEvent("onAfterSave", submitValues, injectionContext);
982
1192
  } catch (err) {
983
1193
  console.error("[CrudForm] Error in onAfterSave:", err);
984
1194
  }
985
1195
  }
986
- if (successRedirect) router.push(successRedirect);
1196
+ if (successRedirect) await navigateWithGuard(successRedirect);
987
1197
  } catch (err) {
988
1198
  try {
989
1199
  if (typeof window !== "undefined") {
@@ -1011,9 +1221,10 @@ function CrudForm({
1011
1221
  return typeof value === "string" && value.trim().length ? value.trim() : null;
1012
1222
  })() : null;
1013
1223
  if (hasFieldErrors) {
1014
- setErrors(combinedFieldErrors);
1224
+ const transformedErrors = await transformValidationErrors(combinedFieldErrors);
1225
+ setErrors(transformedErrors);
1015
1226
  if (process.env.NODE_ENV !== "production") {
1016
- console.debug("[crud-form] Submission failed with field errors", combinedFieldErrors);
1227
+ console.debug("[crud-form] Submission failed with field errors", transformedErrors);
1017
1228
  }
1018
1229
  }
1019
1230
  let displayMessage = typeof helperMessage === "string" && helperMessage.trim() ? helperMessage.trim() : "";