@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
@@ -68,11 +68,16 @@ import { useConfirmDialog } from './confirm-dialog'
68
68
  import { useInjectionSpotEvents, InjectionSpot, useInjectionWidgets } from './injection/InjectionSpot'
69
69
  import { dispatchBackendMutationError } from './injection/mutationEvents'
70
70
  import { VersionHistoryAction } from './version-history/VersionHistoryAction'
71
+ import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
71
72
 
72
73
  // Stable empty options array to avoid creating a new [] every render
73
74
  const EMPTY_OPTIONS: CrudFieldOption[] = []
74
75
  const FOCUSABLE_SELECTOR =
75
76
  '[data-crud-focus-target], input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
77
+ const CRUDFORM_EXTENDED_EVENTS_ENABLED = parseBooleanWithDefault(
78
+ process.env.NEXT_PUBLIC_OM_CRUDFORM_EXTENDED_EVENTS_ENABLED,
79
+ true,
80
+ )
76
81
 
77
82
  export type CrudFieldBase = {
78
83
  id: string
@@ -343,6 +348,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
343
348
  const [values, setValues] = React.useState<CrudFormValues<TValues>>(
344
349
  () => ({ ...(initialValues ?? {}) } as CrudFormValues<TValues>)
345
350
  )
351
+ const valuesRef = React.useRef(values)
346
352
  const [errors, setErrors] = React.useState<Record<string, string>>({})
347
353
  const [pending, setPending] = React.useState(false)
348
354
  const [formError, setFormError] = React.useState<string | null>(null)
@@ -406,7 +412,14 @@ export function CrudForm<TValues extends Record<string, unknown>>({
406
412
  recordId: fallbackRecordId,
407
413
  isLoading,
408
414
  pending,
409
- }), [formId, primaryEntityId, versionHistory?.resourceKind, versionHistory?.resourceId, fallbackRecordId, isLoading, pending])
415
+ }), [formId, primaryEntityId, versionHistory?.resourceKind, versionHistory?.resourceId, recordId, fallbackRecordId, isLoading, pending])
416
+ const injectionContextRef = React.useRef(injectionContext)
417
+ React.useEffect(() => {
418
+ injectionContextRef.current = injectionContext
419
+ }, [injectionContext])
420
+ React.useEffect(() => {
421
+ valuesRef.current = values
422
+ }, [values])
410
423
 
411
424
  const { widgets: injectionWidgets } = useInjectionWidgets(resolvedInjectionSpotId, {
412
425
  context: injectionContext,
@@ -414,6 +427,131 @@ export function CrudForm<TValues extends Record<string, unknown>>({
414
427
  })
415
428
 
416
429
  const { triggerEvent: triggerInjectionEvent } = useInjectionSpotEvents(resolvedInjectionSpotId ?? '', injectionWidgets)
430
+ const extendedInjectionEventsEnabled = CRUDFORM_EXTENDED_EVENTS_ENABLED && Boolean(resolvedInjectionSpotId)
431
+
432
+ const transformValidationErrors = React.useCallback(
433
+ async (fieldErrors: Record<string, string>): Promise<Record<string, string>> => {
434
+ if (!extendedInjectionEventsEnabled || !Object.keys(fieldErrors).length) return fieldErrors
435
+ try {
436
+ const result = await triggerInjectionEvent(
437
+ 'transformValidation',
438
+ fieldErrors as unknown as TValues,
439
+ injectionContextRef.current,
440
+ { originalData: valuesRef.current as TValues },
441
+ )
442
+ const transformed = result.data
443
+ if (!transformed || typeof transformed !== 'object' || Array.isArray(transformed)) return fieldErrors
444
+ return Object.fromEntries(
445
+ Object.entries(transformed as Record<string, unknown>).map(([key, value]) => [key, String(value)]),
446
+ )
447
+ } catch (err) {
448
+ console.error('[CrudForm] Error in transformValidation:', err)
449
+ return fieldErrors
450
+ }
451
+ },
452
+ [extendedInjectionEventsEnabled, triggerInjectionEvent],
453
+ )
454
+
455
+ const canNavigateTo = React.useCallback(
456
+ async (target: string): Promise<boolean> => {
457
+ if (!extendedInjectionEventsEnabled) return true
458
+ try {
459
+ const result = await triggerInjectionEvent(
460
+ 'onBeforeNavigate',
461
+ valuesRef.current as TValues,
462
+ injectionContextRef.current,
463
+ { target },
464
+ )
465
+ if (!result.ok) {
466
+ flash(result.message || t('ui.forms.flash.saveBlocked', 'Save blocked by validation'), 'error')
467
+ return false
468
+ }
469
+ return true
470
+ } catch (err) {
471
+ const message = err instanceof Error && err.message ? err.message : t('ui.forms.flash.saveBlocked', 'Save blocked by validation')
472
+ flash(message, 'error')
473
+ return false
474
+ }
475
+ },
476
+ [extendedInjectionEventsEnabled, t, triggerInjectionEvent],
477
+ )
478
+
479
+ const navigateWithGuard = React.useCallback(
480
+ async (target: string) => {
481
+ if (!target) return
482
+ const allowed = await canNavigateTo(target)
483
+ if (allowed) router.push(target)
484
+ },
485
+ [canNavigateTo, router],
486
+ )
487
+
488
+ React.useEffect(() => {
489
+ if (!extendedInjectionEventsEnabled || typeof window === 'undefined') return
490
+ const handleEvent = (event: Event) => {
491
+ const customEvent = event as CustomEvent<unknown>
492
+ void triggerInjectionEvent('onAppEvent', valuesRef.current as TValues, injectionContextRef.current, {
493
+ appEvent: customEvent.detail,
494
+ }).catch((err) => {
495
+ console.error('[CrudForm] Error in onAppEvent:', err)
496
+ })
497
+ }
498
+ window.addEventListener('om:event', handleEvent as EventListener)
499
+ return () => {
500
+ window.removeEventListener('om:event', handleEvent as EventListener)
501
+ }
502
+ }, [extendedInjectionEventsEnabled, triggerInjectionEvent])
503
+
504
+ React.useEffect(() => {
505
+ if (!extendedInjectionEventsEnabled || typeof document === 'undefined') return
506
+ const emitVisibility = () => {
507
+ void triggerInjectionEvent('onVisibilityChange', valuesRef.current as TValues, injectionContextRef.current, {
508
+ visible: document.visibilityState === 'visible',
509
+ }).catch((err) => {
510
+ console.error('[CrudForm] Error in onVisibilityChange:', err)
511
+ })
512
+ }
513
+ document.addEventListener('visibilitychange', emitVisibility)
514
+ emitVisibility()
515
+ return () => {
516
+ document.removeEventListener('visibilitychange', emitVisibility)
517
+ }
518
+ }, [extendedInjectionEventsEnabled, triggerInjectionEvent])
519
+
520
+ React.useEffect(() => {
521
+ if (!extendedInjectionEventsEnabled) return
522
+ const root = rootRef.current
523
+ if (!root || typeof window === 'undefined') return
524
+ const handleClickCapture = (event: MouseEvent) => {
525
+ if (event.defaultPrevented) return
526
+ if (event.button !== 0) return
527
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
528
+ const targetElement = event.target instanceof Element ? event.target : null
529
+ const linkElement = targetElement?.closest('a[href]')
530
+ if (!(linkElement instanceof HTMLAnchorElement)) return
531
+ if (!root.contains(linkElement)) return
532
+ if (linkElement.target && linkElement.target !== '_self') return
533
+ const rawHref = linkElement.getAttribute('href')
534
+ if (!rawHref || rawHref.startsWith('#')) return
535
+ let target = rawHref
536
+ if (rawHref.startsWith('http://') || rawHref.startsWith('https://')) {
537
+ try {
538
+ const parsed = new URL(rawHref)
539
+ if (parsed.origin !== window.location.origin) return
540
+ target = `${parsed.pathname}${parsed.search}${parsed.hash}`
541
+ } catch {
542
+ return
543
+ }
544
+ } else if (!rawHref.startsWith('/')) {
545
+ return
546
+ }
547
+ event.preventDefault()
548
+ void navigateWithGuard(target)
549
+ }
550
+ root.addEventListener('click', handleClickCapture, true)
551
+ return () => {
552
+ root.removeEventListener('click', handleClickCapture, true)
553
+ }
554
+ }, [extendedInjectionEventsEnabled, navigateWithGuard])
417
555
 
418
556
  React.useEffect(() => {
419
557
  const root = rootRef.current
@@ -476,7 +614,8 @@ export function CrudForm<TValues extends Record<string, unknown>>({
476
614
  // ignore event dispatch failures
477
615
  }
478
616
  if (result.fieldErrors && Object.keys(result.fieldErrors).length) {
479
- setErrors(result.fieldErrors)
617
+ const transformedErrors = await transformValidationErrors(result.fieldErrors)
618
+ setErrors(transformedErrors)
480
619
  }
481
620
  const message = result.message || t('ui.forms.flash.saveBlocked', 'Save blocked by validation')
482
621
  flash(message, 'error')
@@ -519,7 +658,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
519
658
  try { flash(deleteSuccessMessage, 'success') } catch {}
520
659
  // Redirect if requested by caller
521
660
  if (typeof deleteRedirect === 'string' && deleteRedirect) {
522
- router.push(deleteRedirect)
661
+ await navigateWithGuard(deleteRedirect)
523
662
  }
524
663
  } catch (err) {
525
664
  if (resolvedInjectionSpotId) {
@@ -561,8 +700,9 @@ export function CrudForm<TValues extends Record<string, unknown>>({
561
700
  injectionContext,
562
701
  onDelete,
563
702
  resolvedInjectionSpotId,
564
- router,
703
+ navigateWithGuard,
565
704
  t,
705
+ transformValidationErrors,
566
706
  triggerInjectionEvent,
567
707
  values,
568
708
  ])
@@ -1055,11 +1195,43 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1055
1195
  }, [errors, formId])
1056
1196
 
1057
1197
  const setValue = React.useCallback((id: string, nextValue: unknown) => {
1198
+ let nextData: CrudFormValues<TValues> | null = null
1058
1199
  setValues((prev) => {
1059
1200
  if (Object.is(prev[id], nextValue)) return prev
1060
- return { ...prev, [id]: nextValue } as CrudFormValues<TValues>
1201
+ nextData = { ...prev, [id]: nextValue } as CrudFormValues<TValues>
1202
+ return nextData
1061
1203
  })
1062
- }, [])
1204
+ if (!nextData || !extendedInjectionEventsEnabled) return
1205
+ void triggerInjectionEvent('onFieldChange', nextData as TValues, injectionContextRef.current, {
1206
+ fieldId: id,
1207
+ fieldValue: nextValue,
1208
+ }).then((result) => {
1209
+ if (!result.ok) return
1210
+ const change = result.fieldChange
1211
+ if (!change) return
1212
+ const updates: Record<string, unknown> = { ...(change.sideEffects ?? {}) }
1213
+ if (change.value !== undefined) {
1214
+ updates[id] = change.value
1215
+ }
1216
+ if (Object.keys(updates).length > 0) {
1217
+ setValues((prev) => {
1218
+ let changed = false
1219
+ const next = { ...prev } as Record<string, unknown>
1220
+ for (const [key, value] of Object.entries(updates)) {
1221
+ if (Object.is(next[key], value)) continue
1222
+ next[key] = value
1223
+ changed = true
1224
+ }
1225
+ return changed ? (next as CrudFormValues<TValues>) : prev
1226
+ })
1227
+ }
1228
+ for (const message of change.messages ?? []) {
1229
+ flash(message.text, message.severity)
1230
+ }
1231
+ }).catch((err) => {
1232
+ console.error('[CrudForm] Error in onFieldChange:', err)
1233
+ })
1234
+ }, [extendedInjectionEventsEnabled, flash, triggerInjectionEvent])
1063
1235
 
1064
1236
  const handleFieldsetSelectionChange = React.useCallback(
1065
1237
  (entityId: string, nextCode: string | null) => {
@@ -1087,8 +1259,32 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1087
1259
  const snapshot = JSON.stringify(initialValues)
1088
1260
  if (initialValuesSnapshotRef.current === snapshot) return
1089
1261
  initialValuesSnapshotRef.current = snapshot
1090
- setValues((prev) => ({ ...prev, ...initialValues } as CrudFormValues<TValues>))
1091
- }, [initialValues])
1262
+ let mergedValues: CrudFormValues<TValues> | null = null
1263
+ setValues((prev) => {
1264
+ mergedValues = { ...prev, ...initialValues } as CrudFormValues<TValues>
1265
+ return mergedValues
1266
+ })
1267
+ if (!extendedInjectionEventsEnabled || !mergedValues) return
1268
+ let cancelled = false
1269
+ const run = async () => {
1270
+ try {
1271
+ const result = await triggerInjectionEvent(
1272
+ 'transformDisplayData',
1273
+ mergedValues as TValues,
1274
+ injectionContextRef.current,
1275
+ )
1276
+ const transformed = result.data
1277
+ if (cancelled || !transformed) return
1278
+ setValues(transformed as CrudFormValues<TValues>)
1279
+ } catch (err) {
1280
+ console.error('[CrudForm] Error in transformDisplayData:', err)
1281
+ }
1282
+ }
1283
+ void run()
1284
+ return () => {
1285
+ cancelled = true
1286
+ }
1287
+ }, [extendedInjectionEventsEnabled, initialValues, triggerInjectionEvent])
1092
1288
 
1093
1289
  const buildFieldsetEditorHref = React.useCallback(
1094
1290
  (includeViewParam: boolean) => {
@@ -1157,7 +1353,8 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1157
1353
  if (process.env.NODE_ENV !== 'production') {
1158
1354
  console.debug('[crud-form] Required field errors prevented submit', requiredErrors)
1159
1355
  }
1160
- setErrors(requiredErrors)
1356
+ const transformedErrors = await transformValidationErrors(requiredErrors)
1357
+ setErrors(transformedErrors)
1161
1358
  flash(highlightedMessage, 'error')
1162
1359
  return
1163
1360
  }
@@ -1187,9 +1384,15 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1187
1384
  if (customEntity) {
1188
1385
  const mapped: Record<string, string> = {}
1189
1386
  for (const [ek, ev] of Object.entries(result.fieldErrors)) mapped[ek.replace(/^cf_/, '')] = String(ev)
1190
- setErrors((prev) => ({ ...prev, ...mapped }))
1387
+ const transformedErrors = await transformValidationErrors(mapped)
1388
+ setErrors((prev) => ({ ...prev, ...transformedErrors }))
1191
1389
  } else {
1192
- setErrors((prev) => ({ ...prev, ...result.fieldErrors }))
1390
+ const transformedErrors = await transformValidationErrors(
1391
+ Object.fromEntries(
1392
+ Object.entries(result.fieldErrors).map(([key, value]) => [key, String(value)]),
1393
+ ),
1394
+ )
1395
+ setErrors((prev) => ({ ...prev, ...transformedErrors }))
1193
1396
  }
1194
1397
  flash(highlightedMessage, 'error')
1195
1398
  return
@@ -1210,7 +1413,8 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1210
1413
  if (process.env.NODE_ENV !== 'production') {
1211
1414
  console.debug('[crud-form] Schema validation failed', res.error.issues)
1212
1415
  }
1213
- setErrors(fieldErrors)
1416
+ const transformedErrors = await transformValidationErrors(fieldErrors)
1417
+ setErrors(transformedErrors)
1214
1418
  flash(highlightedMessage, 'error')
1215
1419
  return
1216
1420
  }
@@ -1218,12 +1422,23 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1218
1422
  } else {
1219
1423
  parsedValues = values as TValues
1220
1424
  }
1425
+ let submitValues = parsedValues
1426
+ if (extendedInjectionEventsEnabled) {
1427
+ try {
1428
+ const result = await triggerInjectionEvent('transformFormData', submitValues, injectionContext)
1429
+ if (result.data) {
1430
+ submitValues = result.data as TValues
1431
+ }
1432
+ } catch (err) {
1433
+ console.error('[CrudForm] Error in transformFormData:', err)
1434
+ }
1435
+ }
1221
1436
 
1222
1437
  // Trigger onBeforeSave event for injection widgets
1223
1438
  let injectionRequestHeaders: Record<string, string> | undefined
1224
1439
  if (resolvedInjectionSpotId) {
1225
1440
  try {
1226
- const result = await triggerInjectionEvent('onBeforeSave', parsedValues, injectionContext)
1441
+ const result = await triggerInjectionEvent('onBeforeSave', submitValues, injectionContext)
1227
1442
  if (!result.ok) {
1228
1443
  try {
1229
1444
  if (typeof window !== 'undefined') {
@@ -1243,7 +1458,8 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1243
1458
  // ignore event dispatch failures
1244
1459
  }
1245
1460
  if (result.fieldErrors && Object.keys(result.fieldErrors).length) {
1246
- setErrors(result.fieldErrors)
1461
+ const transformedErrors = await transformValidationErrors(result.fieldErrors)
1462
+ setErrors(transformedErrors)
1247
1463
  }
1248
1464
  const message = result.message || t('ui.forms.flash.saveBlocked', 'Save blocked by validation')
1249
1465
  flash(message, 'error')
@@ -1264,7 +1480,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1264
1480
  // Trigger onSave event for injection widgets
1265
1481
  if (resolvedInjectionSpotId) {
1266
1482
  try {
1267
- await triggerInjectionEvent('onSave', parsedValues, injectionContext)
1483
+ await triggerInjectionEvent('onSave', submitValues, injectionContext)
1268
1484
  } catch (err) {
1269
1485
  console.error('[CrudForm] Error in onSave:', err)
1270
1486
  flash(t('ui.forms.flash.saveBlocked', 'Save blocked by validation'), 'error')
@@ -1276,22 +1492,22 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1276
1492
  try {
1277
1493
  if (injectionRequestHeaders && Object.keys(injectionRequestHeaders).length > 0) {
1278
1494
  await withScopedApiRequestHeaders(injectionRequestHeaders, async () => {
1279
- await onSubmit?.(parsedValues)
1495
+ await onSubmit?.(submitValues)
1280
1496
  })
1281
1497
  } else {
1282
- await onSubmit?.(parsedValues)
1498
+ await onSubmit?.(submitValues)
1283
1499
  }
1284
1500
 
1285
1501
  // Trigger onAfterSave event for injection widgets
1286
1502
  if (resolvedInjectionSpotId) {
1287
1503
  try {
1288
- await triggerInjectionEvent('onAfterSave', parsedValues, injectionContext)
1504
+ await triggerInjectionEvent('onAfterSave', submitValues, injectionContext)
1289
1505
  } catch (err) {
1290
1506
  console.error('[CrudForm] Error in onAfterSave:', err)
1291
1507
  }
1292
1508
  }
1293
1509
 
1294
- if (successRedirect) router.push(successRedirect)
1510
+ if (successRedirect) await navigateWithGuard(successRedirect)
1295
1511
  } catch (err: unknown) {
1296
1512
  try {
1297
1513
  if (typeof window !== 'undefined') {
@@ -1322,9 +1538,10 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1322
1538
  })()
1323
1539
  : null
1324
1540
  if (hasFieldErrors) {
1325
- setErrors(combinedFieldErrors)
1541
+ const transformedErrors = await transformValidationErrors(combinedFieldErrors)
1542
+ setErrors(transformedErrors)
1326
1543
  if (process.env.NODE_ENV !== 'production') {
1327
- console.debug('[crud-form] Submission failed with field errors', combinedFieldErrors)
1544
+ console.debug('[crud-form] Submission failed with field errors', transformedErrors)
1328
1545
  }
1329
1546
  }
1330
1547