@open-mercato/ui 0.4.6-develop-ce2a0728a5 → 0.4.6-develop-77fa3b7ed8

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
package/AGENTS.md CHANGED
@@ -122,11 +122,20 @@ import { IconButton } from '@open-mercato/ui/primitives/icon-button'
122
122
  ## DataTable Guidelines
123
123
 
124
124
  - Use `DataTable` as the default list view.
125
+ - DataTable extension spots include: `data-table:<tableId>:columns`, `:row-actions`, `:bulk-actions`, `:filters` (in addition to `:header`/`:footer`).
125
126
  - Populate `columns` with explicit renderers and set `meta.truncate`/`meta.maxWidth` where truncation is needed.
126
127
  - For filters, use `FilterBar`/`FilterOverlay` with async option loaders; keep `pageSize` at or below 100.
127
128
  - Support exports using `buildCrudExportUrl` and pass `exportOptions` to `DataTable`.
128
129
  - Use `RowActions` for per-row actions and include navigation via `onRowClick` or action links.
129
130
  - Keep table state (paging, sorting, filters, search) in component state and reload on scope changes.
131
+ - Keep `extensionTableId` stable and deterministic; host pages should not derive it from transient UI state.
132
+ - Render injected row actions and bulk actions through `RowActions`/bulk action handlers so injected actions follow the same guard and i18n behavior as built-ins.
133
+
134
+ ## CrudForm Field Injection (UMES Phase G)
135
+
136
+ - `CrudForm` automatically resolves injected field widgets from `crud-form:<entityId>:fields`; always pass a stable `entityId`.
137
+ - Keep host field/group IDs stable so injected fields can target groups deterministically across versions.
138
+ - Use injected fields for cross-module form augmentation; keep core module fields in the base form config.
130
139
 
131
140
  ## Menu Injection (UMES Phase A/B)
132
141
 
@@ -158,6 +167,13 @@ import { IconButton } from '@open-mercato/ui/primitives/icon-button'
158
167
  ## Component Reuse
159
168
 
160
169
  - Prefer existing UI primitives and backend components from `@open-mercato/ui` before creating new ones.
170
+ - For replacement-aware hosts, expose stable handle IDs (`page:*`, `data-table:*`, `crud-form:*`, `section:*`) so overrides are deterministic.
161
171
  - Reference @`.ai/specs/SPEC-001-2026-01-21-ui-reusable-components.md` for the reusable component catalog and usage patterns.
162
172
  - For dialogs and forms, keep the interaction model consistent: `Cmd/Ctrl + Enter` to submit, `Escape` to cancel.
163
173
  - Favor composable, data-first helpers (custom field helpers, CRUD helpers, filter utilities) over bespoke logic.
174
+
175
+ ## Component Replacement (UMES Phase H)
176
+
177
+ - When a host surface is replacement-aware, resolve implementations via `useRegisteredComponent(handle, Fallback)` instead of hardcoded component references.
178
+ - Prefer additive override modes (`wrapper`, `props`) before full `replace`; reserve `replace` for cases where compatibility is preserved.
179
+ - Keep handle IDs stable and document them when introducing new replacement-aware surfaces.
@@ -65,12 +65,27 @@ import { useInjectionSpotEvents, InjectionSpot, useInjectionWidgets } from "./in
65
65
  import { dispatchBackendMutationError } from "./injection/mutationEvents.js";
66
66
  import { VersionHistoryAction } from "./version-history/VersionHistoryAction.js";
67
67
  import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
68
+ import { useInjectionDataWidgets } from "./injection/useInjectionDataWidgets.js";
69
+ import { InjectedField } from "./injection/InjectedField.js";
70
+ import { evaluateInjectedVisibility } from "./injection/visibility-utils.js";
71
+ import { ComponentReplacementHandles } from "@open-mercato/shared/modules/widgets/component-registry";
68
72
  const EMPTY_OPTIONS = [];
69
73
  const FOCUSABLE_SELECTOR = '[data-crud-focus-target], input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
70
74
  const CRUDFORM_EXTENDED_EVENTS_ENABLED = parseBooleanWithDefault(
71
75
  process.env.NEXT_PUBLIC_OM_CRUDFORM_EXTENDED_EVENTS_ENABLED,
72
76
  true
73
77
  );
78
+ function readByDotPath(source, path) {
79
+ if (!source || !path) return void 0;
80
+ if (Object.prototype.hasOwnProperty.call(source, path)) return source[path];
81
+ const segments = path.split(".").filter(Boolean);
82
+ let current = source;
83
+ for (const segment of segments) {
84
+ if (!current || typeof current !== "object" || Array.isArray(current)) return void 0;
85
+ current = current[segment];
86
+ }
87
+ return current;
88
+ }
74
89
  const FIELDSET_ICON_COMPONENTS = {
75
90
  layers: Layers,
76
91
  tag: Tag,
@@ -135,7 +150,8 @@ function CrudForm({
135
150
  versionHistory,
136
151
  contentHeader,
137
152
  customFieldsetBindings,
138
- injectionSpotId
153
+ injectionSpotId,
154
+ replacementHandle
139
155
  }) {
140
156
  React.useEffect(() => {
141
157
  loadGeneratedFieldRegistrations().catch(() => {
@@ -207,6 +223,11 @@ function CrudForm({
207
223
  }
208
224
  return void 0;
209
225
  }, [injectionSpotId, resolvedEntityIds]);
226
+ const resolvedReplacementHandle = React.useMemo(() => {
227
+ if (replacementHandle) return replacementHandle;
228
+ if (resolvedEntityIds.length) return ComponentReplacementHandles.crudForm(resolvedEntityIds[0].replace(/[:]+/g, "."));
229
+ return ComponentReplacementHandles.crudForm("unknown");
230
+ }, [replacementHandle, resolvedEntityIds]);
210
231
  const headerInjectionSpotId = resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:header` : void 0;
211
232
  const recordId = React.useMemo(() => {
212
233
  const raw = values.id;
@@ -235,6 +256,9 @@ function CrudForm({
235
256
  context: injectionContext,
236
257
  triggerOnLoad: true
237
258
  });
259
+ const { widgets: injectedFieldWidgets } = useInjectionDataWidgets(
260
+ resolvedInjectionSpotId ? `${resolvedInjectionSpotId}:fields` : "__disabled__:fields"
261
+ );
238
262
  const { triggerEvent: triggerInjectionEvent } = useInjectionSpotEvents(resolvedInjectionSpotId ?? "", injectionWidgets);
239
263
  const extendedInjectionEventsEnabled = CRUDFORM_EXTENDED_EVENTS_ENABLED && Boolean(resolvedInjectionSpotId);
240
264
  const transformValidationErrors = React.useCallback(
@@ -745,12 +769,66 @@ function CrudForm({
745
769
  fieldsetsByEntity,
746
770
  resolvedEntityIds
747
771
  ]);
772
+ const injectedFieldDefinitions = React.useMemo(() => {
773
+ const definitions = [];
774
+ for (const widget of injectedFieldWidgets) {
775
+ if (!("fields" in widget)) continue;
776
+ for (const field of widget.fields ?? []) {
777
+ definitions.push(field);
778
+ }
779
+ }
780
+ return definitions;
781
+ }, [injectedFieldWidgets]);
782
+ const injectedFieldContext = React.useMemo(() => {
783
+ const recordValues = values;
784
+ const organizationId = typeof recordValues.organizationId === "string" ? recordValues.organizationId : null;
785
+ const tenantId = typeof recordValues.tenantId === "string" ? recordValues.tenantId : null;
786
+ const userId = typeof recordValues.userId === "string" ? recordValues.userId : null;
787
+ return {
788
+ organizationId,
789
+ tenantId,
790
+ userId,
791
+ record: recordValues
792
+ };
793
+ }, [values]);
794
+ const hiddenInjectedFieldIds = React.useMemo(() => {
795
+ const hidden = /* @__PURE__ */ new Set();
796
+ for (const definition of injectedFieldDefinitions) {
797
+ if (!evaluateInjectedVisibility(definition.visibleWhen, values, injectedFieldContext)) {
798
+ hidden.add(definition.id);
799
+ }
800
+ }
801
+ return hidden;
802
+ }, [injectedFieldContext, injectedFieldDefinitions, values]);
803
+ const injectedFieldIdSet = React.useMemo(
804
+ () => new Set(injectedFieldDefinitions.map((definition) => definition.id)),
805
+ [injectedFieldDefinitions]
806
+ );
807
+ const injectedCrudFields = React.useMemo(() => {
808
+ return injectedFieldDefinitions.map((definition) => ({
809
+ id: definition.id,
810
+ label: definition.label,
811
+ type: "custom",
812
+ readOnly: definition.readOnly,
813
+ component: ({ value, setValue: setValue2, values: formValues }) => /* @__PURE__ */ jsx(
814
+ InjectedField,
815
+ {
816
+ field: definition,
817
+ value,
818
+ onChange: (_, nextValue) => setValue2(nextValue),
819
+ context: injectedFieldContext,
820
+ formData: formValues ?? values
821
+ }
822
+ )
823
+ }));
824
+ }, [injectedFieldContext, injectedFieldDefinitions, values]);
748
825
  const allFields = React.useMemo(() => {
749
- if (!cfFields.length) return fields;
750
- const provided = new Set(fields.map((f) => f.id));
826
+ const base = [...fields, ...injectedCrudFields];
827
+ if (!cfFields.length) return base;
828
+ const provided = new Set(base.map((f) => f.id));
751
829
  const extras = cfFields.filter((f) => !provided.has(f.id));
752
- return [...fields, ...extras];
753
- }, [fields, cfFields]);
830
+ return [...base, ...extras];
831
+ }, [fields, injectedCrudFields, cfFields]);
754
832
  const fieldById = React.useMemo(() => {
755
833
  return new globalThis.Map(allFields.map((f) => [f.id, f]));
756
834
  }, [allFields]);
@@ -778,12 +856,31 @@ function CrudForm({
778
856
  pairs.sort((a, b) => b.priority - a.priority);
779
857
  return pairs.map((p) => p.group);
780
858
  }, [injectionWidgets, injectionContext, pending, setValues, values]);
781
- const shouldAutoGroup = (!groups || groups.length === 0) && injectionGroupCards.length > 0;
859
+ const groupsWithInjectedFields = React.useMemo(() => {
860
+ if (!groups || groups.length === 0 || injectedFieldDefinitions.length === 0) return groups;
861
+ const cloned = groups.map((group) => ({ ...group, fields: [...group.fields ?? []] }));
862
+ const fallbackIndex = cloned.length - 1;
863
+ for (const definition of injectedFieldDefinitions) {
864
+ const targetIndex = cloned.findIndex((group) => group.id === definition.group);
865
+ const index = targetIndex >= 0 ? targetIndex : fallbackIndex;
866
+ if (targetIndex < 0 && process.env.NODE_ENV !== "production") {
867
+ console.warn(`[CrudForm] Injected field "${definition.id}" targets group "${definition.group}" which does not exist. Appended to last group.`);
868
+ }
869
+ if (index < 0) continue;
870
+ const fieldEntries = cloned[index].fields ?? [];
871
+ if (!fieldEntries.some((entry) => typeof entry === "string" && entry === definition.id)) {
872
+ fieldEntries.push(definition.id);
873
+ }
874
+ cloned[index].fields = fieldEntries;
875
+ }
876
+ return cloned;
877
+ }, [groups, injectedFieldDefinitions]);
878
+ const shouldAutoGroup = (!groupsWithInjectedFields || groupsWithInjectedFields.length === 0) && injectionGroupCards.length > 0;
782
879
  const resolvedGroupsForLayout = React.useMemo(() => {
783
- const baseGroups = groups && groups.length ? groups : [];
880
+ const baseGroups = groupsWithInjectedFields && groupsWithInjectedFields.length ? groupsWithInjectedFields : [];
784
881
  const autoGroup = shouldAutoGroup ? [{ id: "__auto-fields__", fields: allFields }] : [];
785
882
  return [...baseGroups.length ? baseGroups : autoGroup, ...injectionGroupCards];
786
- }, [allFields, groups, injectionGroupCards, shouldAutoGroup]);
883
+ }, [allFields, groupsWithInjectedFields, injectionGroupCards, shouldAutoGroup]);
787
884
  const useGroupedLayout = resolvedGroupsForLayout.length > 0;
788
885
  const stackedInjectionWidgets = React.useMemo(
789
886
  () => (injectionWidgets ?? []).filter((widget) => (widget.placement?.kind ?? "stack") === "stack"),
@@ -977,7 +1074,16 @@ function CrudForm({
977
1074
  initialValuesSnapshotRef.current = snapshot;
978
1075
  let mergedValues = null;
979
1076
  setValues((prev) => {
980
- mergedValues = { ...prev, ...initialValues };
1077
+ const merged = { ...prev, ...initialValues };
1078
+ for (const definition of injectedFieldDefinitions) {
1079
+ if (merged[definition.id] !== void 0) continue;
1080
+ const extracted = readByDotPath(initialValues, definition.id);
1081
+ if (extracted !== void 0) {
1082
+ ;
1083
+ merged[definition.id] = extracted;
1084
+ }
1085
+ }
1086
+ mergedValues = merged;
981
1087
  return mergedValues;
982
1088
  });
983
1089
  if (!extendedInjectionEventsEnabled || !mergedValues) return;
@@ -1000,7 +1106,7 @@ function CrudForm({
1000
1106
  return () => {
1001
1107
  cancelled = true;
1002
1108
  };
1003
- }, [extendedInjectionEventsEnabled, initialValues, triggerInjectionEvent]);
1109
+ }, [extendedInjectionEventsEnabled, initialValues, injectedFieldDefinitions, triggerInjectionEvent]);
1004
1110
  const buildFieldsetEditorHref = React.useCallback(
1005
1111
  (includeViewParam) => {
1006
1112
  if (!fieldsetEditorTarget) return null;
@@ -1044,6 +1150,7 @@ function CrudForm({
1044
1150
  for (const field of allFields) {
1045
1151
  if (!field.required) continue;
1046
1152
  if (field.disabled) continue;
1153
+ if (hiddenInjectedFieldIds.has(field.id)) continue;
1047
1154
  const v = values[field.id];
1048
1155
  const isArray = Array.isArray(v);
1049
1156
  const isString = typeof v === "string";
@@ -1098,9 +1205,17 @@ function CrudForm({
1098
1205
  } catch {
1099
1206
  }
1100
1207
  }
1208
+ const widgetValues = { ...values };
1209
+ for (const hiddenId of hiddenInjectedFieldIds) {
1210
+ delete widgetValues[hiddenId];
1211
+ }
1212
+ const coreValues = { ...widgetValues };
1213
+ for (const injectedId of injectedFieldIdSet) {
1214
+ delete coreValues[injectedId];
1215
+ }
1101
1216
  let parsedValues;
1102
1217
  if (schema) {
1103
- const res = schema.safeParse(values);
1218
+ const res = schema.safeParse(coreValues);
1104
1219
  if (!res.success) {
1105
1220
  const fieldErrors = {};
1106
1221
  res.error.issues.forEach((issue) => {
@@ -1116,14 +1231,20 @@ function CrudForm({
1116
1231
  }
1117
1232
  parsedValues = res.data;
1118
1233
  } else {
1119
- parsedValues = values;
1234
+ parsedValues = coreValues;
1120
1235
  }
1121
- let submitValues = parsedValues;
1236
+ let submitValues = widgetValues;
1237
+ let coreSubmitValues = parsedValues;
1122
1238
  if (extendedInjectionEventsEnabled) {
1123
1239
  try {
1124
1240
  const result = await triggerInjectionEvent("transformFormData", submitValues, injectionContext);
1125
1241
  if (result.data) {
1126
1242
  submitValues = result.data;
1243
+ const projectedCoreValues = { ...result.data };
1244
+ for (const injectedId of injectedFieldIdSet) {
1245
+ delete projectedCoreValues[injectedId];
1246
+ }
1247
+ coreSubmitValues = schema ? schema.parse(projectedCoreValues) : projectedCoreValues;
1127
1248
  }
1128
1249
  } catch (err) {
1129
1250
  console.error("[CrudForm] Error in transformFormData:", err);
@@ -1181,10 +1302,10 @@ function CrudForm({
1181
1302
  try {
1182
1303
  if (injectionRequestHeaders && Object.keys(injectionRequestHeaders).length > 0) {
1183
1304
  await withScopedApiRequestHeaders(injectionRequestHeaders, async () => {
1184
- await onSubmit?.(submitValues);
1305
+ await onSubmit?.(coreSubmitValues);
1185
1306
  });
1186
1307
  } else {
1187
- await onSubmit?.(submitValues);
1308
+ await onSubmit?.(coreSubmitValues);
1188
1309
  }
1189
1310
  if (resolvedInjectionSpotId) {
1190
1311
  try {
@@ -1573,7 +1694,7 @@ function CrudForm({
1573
1694
  const col1Content = renderGroupedCards(col1);
1574
1695
  const col2Content = renderGroupedCards(col2);
1575
1696
  const hasSecondaryColumn = col2Content.length > 0;
1576
- return /* @__PURE__ */ jsxs("div", { className: "space-y-4", ref: rootRef, children: [
1697
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-4", ref: rootRef, "data-component-handle": resolvedReplacementHandle, children: [
1577
1698
  !embedded ? /* @__PURE__ */ jsx(
1578
1699
  FormHeader,
1579
1700
  {
@@ -1646,7 +1767,7 @@ function CrudForm({
1646
1767
  ConfirmDialogElement
1647
1768
  ] });
1648
1769
  }
1649
- return /* @__PURE__ */ jsxs("div", { className: "space-y-4", ref: rootRef, children: [
1770
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-4", ref: rootRef, "data-component-handle": resolvedReplacementHandle, children: [
1650
1771
  !embedded ? /* @__PURE__ */ jsx(
1651
1772
  FormHeader,
1652
1773
  {