@open-mercato/ui 0.4.6-develop-ce2a0728a5 → 0.4.6-develop-4d77832982

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 (48) hide show
  1. package/AGENTS.md +16 -0
  2. package/dist/backend/CrudForm.js +138 -17
  3. package/dist/backend/CrudForm.js.map +3 -3
  4. package/dist/backend/DataTable.js +297 -24
  5. package/dist/backend/DataTable.js.map +3 -3
  6. package/dist/backend/detail/ActivitiesSection.js +11 -1
  7. package/dist/backend/detail/ActivitiesSection.js.map +2 -2
  8. package/dist/backend/detail/AddressesSection.js +11 -1
  9. package/dist/backend/detail/AddressesSection.js.map +2 -2
  10. package/dist/backend/detail/AttachmentsSection.js +11 -1
  11. package/dist/backend/detail/AttachmentsSection.js.map +2 -2
  12. package/dist/backend/detail/CustomDataSection.js +11 -1
  13. package/dist/backend/detail/CustomDataSection.js.map +2 -2
  14. package/dist/backend/detail/DetailFieldsSection.js +11 -1
  15. package/dist/backend/detail/DetailFieldsSection.js.map +2 -2
  16. package/dist/backend/detail/NotesSection.js +11 -1
  17. package/dist/backend/detail/NotesSection.js.map +2 -2
  18. package/dist/backend/detail/TagsSection.js +11 -1
  19. package/dist/backend/detail/TagsSection.js.map +2 -2
  20. package/dist/backend/injection/ComponentOverrideProvider.js +54 -0
  21. package/dist/backend/injection/ComponentOverrideProvider.js.map +7 -0
  22. package/dist/backend/injection/InjectedField.js +166 -0
  23. package/dist/backend/injection/InjectedField.js.map +7 -0
  24. package/dist/backend/injection/spotIds.js +5 -1
  25. package/dist/backend/injection/spotIds.js.map +2 -2
  26. package/dist/backend/injection/useRegisteredComponent.js +89 -0
  27. package/dist/backend/injection/useRegisteredComponent.js.map +7 -0
  28. package/dist/backend/injection/visibility-utils.js +29 -0
  29. package/dist/backend/injection/visibility-utils.js.map +7 -0
  30. package/package.json +2 -2
  31. package/src/backend/AGENTS.md +7 -0
  32. package/src/backend/CrudForm.tsx +144 -16
  33. package/src/backend/DataTable.tsx +342 -22
  34. package/src/backend/__tests__/DataTable.extensions.test.tsx +115 -0
  35. package/src/backend/__tests__/DataTable.namespaces.test.ts +32 -0
  36. package/src/backend/__tests__/component-replacement.test.tsx +232 -0
  37. package/src/backend/detail/ActivitiesSection.tsx +17 -1
  38. package/src/backend/detail/AddressesSection.tsx +17 -1
  39. package/src/backend/detail/AttachmentsSection.tsx +17 -1
  40. package/src/backend/detail/CustomDataSection.tsx +17 -1
  41. package/src/backend/detail/DetailFieldsSection.tsx +17 -1
  42. package/src/backend/detail/NotesSection.tsx +17 -1
  43. package/src/backend/detail/TagsSection.tsx +17 -1
  44. package/src/backend/injection/ComponentOverrideProvider.tsx +65 -0
  45. package/src/backend/injection/InjectedField.tsx +194 -0
  46. package/src/backend/injection/spotIds.ts +4 -0
  47. package/src/backend/injection/useRegisteredComponent.tsx +106 -0
  48. package/src/backend/injection/visibility-utils.ts +31 -0
@@ -69,6 +69,11 @@ import { useInjectionSpotEvents, InjectionSpot, useInjectionWidgets } from './in
69
69
  import { dispatchBackendMutationError } from './injection/mutationEvents'
70
70
  import { VersionHistoryAction } from './version-history/VersionHistoryAction'
71
71
  import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
72
+ import { useInjectionDataWidgets } from './injection/useInjectionDataWidgets'
73
+ import { InjectedField } from './injection/InjectedField'
74
+ import type { InjectionFieldDefinition, FieldContext } from '@open-mercato/shared/modules/widgets/injection'
75
+ import { evaluateInjectedVisibility } from './injection/visibility-utils'
76
+ import { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry'
72
77
 
73
78
  // Stable empty options array to avoid creating a new [] every render
74
79
  const EMPTY_OPTIONS: CrudFieldOption[] = []
@@ -203,6 +208,7 @@ export type CrudFormProps<TValues extends Record<string, unknown>> = {
203
208
  customFieldsetBindings?: Record<string, { valueKey: string }>
204
209
  // Optional injection spot ID for widget injection
205
210
  injectionSpotId?: string
211
+ replacementHandle?: string
206
212
  }
207
213
 
208
214
  // Group-level custom component context
@@ -228,6 +234,18 @@ export type CrudFormGroup = {
228
234
  bare?: boolean
229
235
  }
230
236
 
237
+ function readByDotPath(source: Record<string, unknown> | undefined, path: string): unknown {
238
+ if (!source || !path) return undefined
239
+ if (Object.prototype.hasOwnProperty.call(source, path)) return source[path]
240
+ const segments = path.split('.').filter(Boolean)
241
+ let current: unknown = source
242
+ for (const segment of segments) {
243
+ if (!current || typeof current !== 'object' || Array.isArray(current)) return undefined
244
+ current = (current as Record<string, unknown>)[segment]
245
+ }
246
+ return current
247
+ }
248
+
231
249
  const FIELDSET_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
232
250
  layers: Layers,
233
251
  tag: Tag,
@@ -319,6 +337,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
319
337
  contentHeader,
320
338
  customFieldsetBindings,
321
339
  injectionSpotId,
340
+ replacementHandle,
322
341
  }: CrudFormProps<TValues>) {
323
342
  // Ensure module field components are registered (client-side)
324
343
  React.useEffect(() => { loadGeneratedFieldRegistrations().catch(() => {}) }, [])
@@ -390,6 +409,11 @@ export function CrudForm<TValues extends Record<string, unknown>>({
390
409
  }
391
410
  return undefined
392
411
  }, [injectionSpotId, resolvedEntityIds])
412
+ const resolvedReplacementHandle = React.useMemo(() => {
413
+ if (replacementHandle) return replacementHandle
414
+ if (resolvedEntityIds.length) return ComponentReplacementHandles.crudForm(resolvedEntityIds[0].replace(/[:]+/g, '.'))
415
+ return ComponentReplacementHandles.crudForm('unknown')
416
+ }, [replacementHandle, resolvedEntityIds])
393
417
  const headerInjectionSpotId = resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:header` : undefined
394
418
 
395
419
  const recordId = React.useMemo(() => {
@@ -425,6 +449,9 @@ export function CrudForm<TValues extends Record<string, unknown>>({
425
449
  context: injectionContext,
426
450
  triggerOnLoad: true,
427
451
  })
452
+ const { widgets: injectedFieldWidgets } = useInjectionDataWidgets(
453
+ resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:fields` : '__disabled__:fields'
454
+ )
428
455
 
429
456
  const { triggerEvent: triggerInjectionEvent } = useInjectionSpotEvents(resolvedInjectionSpotId ?? '', injectionWidgets)
430
457
  const extendedInjectionEventsEnabled = CRUDFORM_EXTENDED_EVENTS_ENABLED && Boolean(resolvedInjectionSpotId)
@@ -983,12 +1010,69 @@ export function CrudForm<TValues extends Record<string, unknown>>({
983
1010
  resolvedEntityIds,
984
1011
  ])
985
1012
 
1013
+ const injectedFieldDefinitions = React.useMemo<InjectionFieldDefinition[]>(() => {
1014
+ const definitions: InjectionFieldDefinition[] = []
1015
+ for (const widget of injectedFieldWidgets) {
1016
+ if (!('fields' in widget)) continue
1017
+ for (const field of widget.fields ?? []) {
1018
+ definitions.push(field as InjectionFieldDefinition)
1019
+ }
1020
+ }
1021
+ return definitions
1022
+ }, [injectedFieldWidgets])
1023
+
1024
+ const injectedFieldContext = React.useMemo<FieldContext>(() => {
1025
+ const recordValues = values as Record<string, unknown>
1026
+ const organizationId = typeof recordValues.organizationId === 'string' ? recordValues.organizationId : null
1027
+ const tenantId = typeof recordValues.tenantId === 'string' ? recordValues.tenantId : null
1028
+ const userId = typeof recordValues.userId === 'string' ? recordValues.userId : null
1029
+ return {
1030
+ organizationId,
1031
+ tenantId,
1032
+ userId,
1033
+ record: recordValues,
1034
+ }
1035
+ }, [values])
1036
+
1037
+ const hiddenInjectedFieldIds = React.useMemo(() => {
1038
+ const hidden = new Set<string>()
1039
+ for (const definition of injectedFieldDefinitions) {
1040
+ if (!evaluateInjectedVisibility(definition.visibleWhen, values as Record<string, unknown>, injectedFieldContext)) {
1041
+ hidden.add(definition.id)
1042
+ }
1043
+ }
1044
+ return hidden
1045
+ }, [injectedFieldContext, injectedFieldDefinitions, values])
1046
+ const injectedFieldIdSet = React.useMemo(
1047
+ () => new Set(injectedFieldDefinitions.map((definition) => definition.id)),
1048
+ [injectedFieldDefinitions],
1049
+ )
1050
+
1051
+ const injectedCrudFields = React.useMemo<CrudField[]>(() => {
1052
+ return injectedFieldDefinitions.map((definition) => ({
1053
+ id: definition.id,
1054
+ label: definition.label,
1055
+ type: 'custom',
1056
+ readOnly: definition.readOnly,
1057
+ component: ({ value, setValue, values: formValues }) => (
1058
+ <InjectedField
1059
+ field={definition}
1060
+ value={value}
1061
+ onChange={(_, nextValue) => setValue(nextValue)}
1062
+ context={injectedFieldContext}
1063
+ formData={(formValues ?? values) as Record<string, unknown>}
1064
+ />
1065
+ ),
1066
+ }))
1067
+ }, [injectedFieldContext, injectedFieldDefinitions, values])
1068
+
986
1069
  const allFields = React.useMemo(() => {
987
- if (!cfFields.length) return fields
988
- const provided = new Set(fields.map(f => f.id))
1070
+ const base = [...fields, ...injectedCrudFields]
1071
+ if (!cfFields.length) return base
1072
+ const provided = new Set(base.map(f => f.id))
989
1073
  const extras = cfFields.filter(f => !provided.has(f.id))
990
- return [...fields, ...extras]
991
- }, [fields, cfFields])
1074
+ return [...base, ...extras]
1075
+ }, [fields, injectedCrudFields, cfFields])
992
1076
 
993
1077
  const fieldById = React.useMemo(() => {
994
1078
  return new globalThis.Map(allFields.map((f) => [f.id, f]))
@@ -1020,12 +1104,32 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1020
1104
  return pairs.map((p) => p.group)
1021
1105
  }, [injectionWidgets, injectionContext, pending, setValues, values])
1022
1106
 
1023
- const shouldAutoGroup = (!groups || groups.length === 0) && injectionGroupCards.length > 0
1107
+ const groupsWithInjectedFields = React.useMemo(() => {
1108
+ if (!groups || groups.length === 0 || injectedFieldDefinitions.length === 0) return groups
1109
+ const cloned = groups.map((group) => ({ ...group, fields: [...(group.fields ?? [])] }))
1110
+ const fallbackIndex = cloned.length - 1
1111
+ for (const definition of injectedFieldDefinitions) {
1112
+ const targetIndex = cloned.findIndex((group) => group.id === definition.group)
1113
+ const index = targetIndex >= 0 ? targetIndex : fallbackIndex
1114
+ if (targetIndex < 0 && process.env.NODE_ENV !== 'production') {
1115
+ console.warn(`[CrudForm] Injected field "${definition.id}" targets group "${definition.group}" which does not exist. Appended to last group.`)
1116
+ }
1117
+ if (index < 0) continue
1118
+ const fieldEntries = cloned[index].fields ?? []
1119
+ if (!fieldEntries.some((entry) => typeof entry === 'string' && entry === definition.id)) {
1120
+ fieldEntries.push(definition.id)
1121
+ }
1122
+ cloned[index].fields = fieldEntries
1123
+ }
1124
+ return cloned
1125
+ }, [groups, injectedFieldDefinitions])
1126
+
1127
+ const shouldAutoGroup = (!groupsWithInjectedFields || groupsWithInjectedFields.length === 0) && injectionGroupCards.length > 0
1024
1128
  const resolvedGroupsForLayout = React.useMemo(() => {
1025
- const baseGroups = groups && groups.length ? groups : []
1129
+ const baseGroups = groupsWithInjectedFields && groupsWithInjectedFields.length ? groupsWithInjectedFields : []
1026
1130
  const autoGroup = shouldAutoGroup ? [{ id: '__auto-fields__', fields: allFields }] as CrudFormGroup[] : []
1027
1131
  return [...(baseGroups.length ? baseGroups : autoGroup), ...injectionGroupCards]
1028
- }, [allFields, groups, injectionGroupCards, shouldAutoGroup])
1132
+ }, [allFields, groupsWithInjectedFields, injectionGroupCards, shouldAutoGroup])
1029
1133
  const useGroupedLayout = resolvedGroupsForLayout.length > 0
1030
1134
  const stackedInjectionWidgets = React.useMemo(
1031
1135
  () => (injectionWidgets ?? []).filter((widget) => (widget.placement?.kind ?? 'stack') === 'stack'),
@@ -1261,7 +1365,15 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1261
1365
  initialValuesSnapshotRef.current = snapshot
1262
1366
  let mergedValues: CrudFormValues<TValues> | null = null
1263
1367
  setValues((prev) => {
1264
- mergedValues = { ...prev, ...initialValues } as CrudFormValues<TValues>
1368
+ const merged = { ...prev, ...initialValues } as CrudFormValues<TValues>
1369
+ for (const definition of injectedFieldDefinitions) {
1370
+ if (merged[definition.id] !== undefined) continue
1371
+ const extracted = readByDotPath(initialValues as Record<string, unknown>, definition.id)
1372
+ if (extracted !== undefined) {
1373
+ ;(merged as Record<string, unknown>)[definition.id] = extracted
1374
+ }
1375
+ }
1376
+ mergedValues = merged
1265
1377
  return mergedValues
1266
1378
  })
1267
1379
  if (!extendedInjectionEventsEnabled || !mergedValues) return
@@ -1284,7 +1396,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1284
1396
  return () => {
1285
1397
  cancelled = true
1286
1398
  }
1287
- }, [extendedInjectionEventsEnabled, initialValues, triggerInjectionEvent])
1399
+ }, [extendedInjectionEventsEnabled, initialValues, injectedFieldDefinitions, triggerInjectionEvent])
1288
1400
 
1289
1401
  const buildFieldsetEditorHref = React.useCallback(
1290
1402
  (includeViewParam: boolean) => {
@@ -1338,6 +1450,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1338
1450
  for (const field of allFields) {
1339
1451
  if (!field.required) continue
1340
1452
  if (field.disabled) continue
1453
+ if (hiddenInjectedFieldIds.has(field.id)) continue
1341
1454
  const v = values[field.id]
1342
1455
  const isArray = Array.isArray(v)
1343
1456
  const isString = typeof v === 'string'
@@ -1402,9 +1515,18 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1402
1515
  }
1403
1516
  }
1404
1517
 
1518
+ const widgetValues = { ...(values as Record<string, unknown>) }
1519
+ for (const hiddenId of hiddenInjectedFieldIds) {
1520
+ delete widgetValues[hiddenId]
1521
+ }
1522
+ const coreValues = { ...widgetValues }
1523
+ for (const injectedId of injectedFieldIdSet) {
1524
+ delete coreValues[injectedId]
1525
+ }
1526
+
1405
1527
  let parsedValues: TValues
1406
1528
  if (schema) {
1407
- const res = schema.safeParse(values)
1529
+ const res = schema.safeParse(coreValues)
1408
1530
  if (!res.success) {
1409
1531
  const fieldErrors: Record<string, string> = {}
1410
1532
  res.error.issues.forEach((issue) => {
@@ -1420,14 +1542,20 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1420
1542
  }
1421
1543
  parsedValues = res.data
1422
1544
  } else {
1423
- parsedValues = values as TValues
1545
+ parsedValues = coreValues as TValues
1424
1546
  }
1425
- let submitValues = parsedValues
1547
+ let submitValues = widgetValues as TValues
1548
+ let coreSubmitValues = parsedValues
1426
1549
  if (extendedInjectionEventsEnabled) {
1427
1550
  try {
1428
1551
  const result = await triggerInjectionEvent('transformFormData', submitValues, injectionContext)
1429
1552
  if (result.data) {
1430
1553
  submitValues = result.data as TValues
1554
+ const projectedCoreValues = { ...(result.data as Record<string, unknown>) }
1555
+ for (const injectedId of injectedFieldIdSet) {
1556
+ delete projectedCoreValues[injectedId]
1557
+ }
1558
+ coreSubmitValues = schema ? schema.parse(projectedCoreValues) : (projectedCoreValues as TValues)
1431
1559
  }
1432
1560
  } catch (err) {
1433
1561
  console.error('[CrudForm] Error in transformFormData:', err)
@@ -1492,10 +1620,10 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1492
1620
  try {
1493
1621
  if (injectionRequestHeaders && Object.keys(injectionRequestHeaders).length > 0) {
1494
1622
  await withScopedApiRequestHeaders(injectionRequestHeaders, async () => {
1495
- await onSubmit?.(submitValues)
1623
+ await onSubmit?.(coreSubmitValues)
1496
1624
  })
1497
1625
  } else {
1498
- await onSubmit?.(submitValues)
1626
+ await onSubmit?.(coreSubmitValues)
1499
1627
  }
1500
1628
 
1501
1629
  // Trigger onAfterSave event for injection widgets
@@ -1958,7 +2086,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1958
2086
  const hasSecondaryColumn = col2Content.length > 0
1959
2087
 
1960
2088
  return (
1961
- <div className="space-y-4" ref={rootRef}>
2089
+ <div className="space-y-4" ref={rootRef} data-component-handle={resolvedReplacementHandle}>
1962
2090
  {!embedded ? (
1963
2091
  <FormHeader
1964
2092
  mode="edit"
@@ -2030,7 +2158,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
2030
2158
 
2031
2159
  // Default single-card layout (compatible with previous API)
2032
2160
  return (
2033
- <div className="space-y-4" ref={rootRef}>
2161
+ <div className="space-y-4" ref={rootRef} data-component-handle={resolvedReplacementHandle}>
2034
2162
  {!embedded ? (
2035
2163
  <FormHeader
2036
2164
  mode="edit"