@open-mercato/ui 0.8.1-develop.7237.1.635faf2c7a → 0.8.1-develop.7238.1.3abe669ea5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.8.1-develop.7237.1.635faf2c7a",
3
+ "version": "0.8.1-develop.7238.1.3abe669ea5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -155,14 +155,14 @@
155
155
  "remark-gfm": "^4.0.1"
156
156
  },
157
157
  "peerDependencies": {
158
- "@open-mercato/shared": "0.8.1-develop.7237.1.635faf2c7a",
158
+ "@open-mercato/shared": "0.8.1-develop.7238.1.3abe669ea5",
159
159
  "react": ">=18.0.0",
160
160
  "react-dom": ">=18.0.0",
161
161
  "react-is": ">=18.0.0"
162
162
  },
163
163
  "devDependencies": {
164
164
  "@figma/code-connect": "^1.3.4",
165
- "@open-mercato/shared": "0.8.1-develop.7237.1.635faf2c7a",
165
+ "@open-mercato/shared": "0.8.1-develop.7238.1.3abe669ea5",
166
166
  "@testing-library/dom": "^10.4.1",
167
167
  "@testing-library/jest-dom": "^7.0.1",
168
168
  "@testing-library/react": "^16.3.3",
@@ -417,6 +417,20 @@ export type CrudFormProps<TValues extends Record<string, unknown>> = {
417
417
  customFieldsManageMode?: 'inline' | 'page'
418
418
  // Optional injection spot ID for widget injection
419
419
  injectionSpotId?: string
420
+ /**
421
+ * One prior injection spot ID whose header, body (stack/group), and field
422
+ * (`:fields`) widgets are dual-published alongside `injectionSpotId` — a
423
+ * compatibility bridge for a host that changed its declared spot id, so a
424
+ * widget still targeting the old id keeps rendering. See
425
+ * BACKWARD_COMPATIBILITY.md §6 for the deprecation protocol this supports.
426
+ *
427
+ * Body, group, and `:fields` widgets are deduped by id (`mergeByKey`), so a
428
+ * widget registered on both `injectionSpotId` and `legacyInjectionSpotId`
429
+ * renders once. The header is the exception: it renders through two
430
+ * independent `<InjectionSpot>` elements (not the merged list), so a widget
431
+ * registered on both header spots renders twice.
432
+ */
433
+ legacyInjectionSpotId?: string
420
434
  replacementHandle?: string
421
435
  // Enable collapsible group headers with localStorage persistence.
422
436
  // Pass `true` to enable with auto-generated pageType, or `{ pageType }` for explicit key.
@@ -462,6 +476,16 @@ export type CrudFormGroup = {
462
476
  bare?: boolean
463
477
  }
464
478
 
479
+ // Appends `legacy` entries not already present in `primary` (by `keyOf`), so a
480
+ // `legacyInjectionSpotId` bridge never renders the same widget twice when it is
481
+ // registered on both the primary and the legacy spot.
482
+ function mergeByKey<T>(primary: T[], legacy: T[], keyOf: (item: T) => string): T[] {
483
+ if (!legacy.length) return primary
484
+ const seen = new Set(primary.map(keyOf))
485
+ const extra = legacy.filter((item) => !seen.has(keyOf(item)))
486
+ return extra.length ? [...primary, ...extra] : primary
487
+ }
488
+
465
489
  function readByDotPath(source: Record<string, unknown> | undefined, path: string): unknown {
466
490
  if (!source || !path) return undefined
467
491
  if (Object.prototype.hasOwnProperty.call(source, path)) return source[path]
@@ -761,6 +785,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
761
785
  customFieldsetBindings,
762
786
  customFieldsManageMode = 'inline',
763
787
  injectionSpotId,
788
+ legacyInjectionSpotId,
764
789
  replacementHandle,
765
790
  collapsibleGroups,
766
791
  sortableGroups,
@@ -867,7 +892,10 @@ export function CrudForm<TValues extends Record<string, unknown>>({
867
892
  const headerInjectionSpotId = resolvedInjectionSpotId
868
893
  ? extensionSpotChildId(resolvedInjectionSpotId, 'header')
869
894
  : undefined
870
-
895
+ const legacyHeaderInjectionSpotId = legacyInjectionSpotId
896
+ ? extensionSpotChildId(legacyInjectionSpotId, 'header')
897
+ : undefined
898
+
871
899
  const recordId = React.useMemo(() => {
872
900
  const raw = values.id
873
901
  if (typeof raw === 'string') return raw
@@ -1144,16 +1172,33 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1144
1172
  }
1145
1173
  }, [allowNextNavigation, clearDirtyState, confirmUnsavedChanges, embedded, hasUnsavedChanges, router, trackDirtyWhenEmbedded])
1146
1174
 
1147
- const { widgets: injectionWidgets } = useInjectionWidgets(resolvedInjectionSpotId, {
1175
+ const { widgets: primaryInjectionWidgets } = useInjectionWidgets(resolvedInjectionSpotId, {
1176
+ context: injectionContext,
1177
+ triggerOnLoad: true,
1178
+ })
1179
+ const { widgets: legacyInjectionWidgets } = useInjectionWidgets(legacyInjectionSpotId, {
1148
1180
  context: injectionContext,
1149
1181
  triggerOnLoad: true,
1150
1182
  })
1151
- const { widgets: injectedFieldWidgets } = useInjectionDataWidgets(
1183
+ const injectionWidgets = React.useMemo(
1184
+ () => mergeByKey(primaryInjectionWidgets, legacyInjectionWidgets, (w) => w.widgetId),
1185
+ [primaryInjectionWidgets, legacyInjectionWidgets],
1186
+ )
1187
+ const { widgets: primaryFieldWidgets } = useInjectionDataWidgets(
1152
1188
  resolvedInjectionSpotId
1153
1189
  ? extensionSpotChildId(resolvedInjectionSpotId, 'fields')
1154
1190
  : '__disabled__:fields'
1155
1191
  )
1156
-
1192
+ const { widgets: legacyFieldWidgets } = useInjectionDataWidgets(
1193
+ legacyInjectionSpotId
1194
+ ? extensionSpotChildId(legacyInjectionSpotId, 'fields')
1195
+ : '__disabled__:fields'
1196
+ )
1197
+ const injectedFieldWidgets = React.useMemo(
1198
+ () => mergeByKey(primaryFieldWidgets, legacyFieldWidgets, (w) => w.metadata.id),
1199
+ [primaryFieldWidgets, legacyFieldWidgets],
1200
+ )
1201
+
1157
1202
  const { triggerEvent: triggerInjectionEvent } = useInjectionSpotEvents(resolvedInjectionSpotId ?? '', injectionWidgets)
1158
1203
  const extendedInjectionEventsEnabled = CRUDFORM_EXTENDED_EVENTS_ENABLED && Boolean(resolvedInjectionSpotId)
1159
1204
 
@@ -1524,14 +1569,27 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1524
1569
  autoCheckAcl={versionHistory?.autoCheckAcl}
1525
1570
  />
1526
1571
  )
1527
- const headerInjectionAction = headerInjectionSpotId ? (
1528
- <InjectionSpot
1529
- spotId={headerInjectionSpotId}
1530
- context={injectionContext}
1531
- data={values}
1532
- onDataChange={(newData) => setValues(newData as CrudFormValues<TValues>)}
1533
- disabled={pending}
1534
- />
1572
+ const headerInjectionAction = (headerInjectionSpotId || legacyHeaderInjectionSpotId) ? (
1573
+ <>
1574
+ {headerInjectionSpotId ? (
1575
+ <InjectionSpot
1576
+ spotId={headerInjectionSpotId}
1577
+ context={injectionContext}
1578
+ data={values}
1579
+ onDataChange={(newData) => setValues(newData as CrudFormValues<TValues>)}
1580
+ disabled={pending}
1581
+ />
1582
+ ) : null}
1583
+ {legacyHeaderInjectionSpotId ? (
1584
+ <InjectionSpot
1585
+ spotId={legacyHeaderInjectionSpotId}
1586
+ context={injectionContext}
1587
+ data={values}
1588
+ onDataChange={(newData) => setValues(newData as CrudFormValues<TValues>)}
1589
+ disabled={pending}
1590
+ />
1591
+ ) : null}
1592
+ </>
1535
1593
  ) : null
1536
1594
  const headerExtraActions = versionHistoryEnabled || headerInjectionAction || extraActions ? (
1537
1595
  <>
@@ -20,7 +20,14 @@ jest.mock('../injection/InjectionSpot', () => ({
20
20
  }))
21
21
  jest.mock('../injection/useInjectionDataWidgets', () => ({
22
22
  __esModule: true,
23
- useInjectionDataWidgets: () => ({ widgets: injectedFieldWidgets, isLoading: false, error: null }),
23
+ // `CrudForm` also calls this for its (unused-here) legacy-bridge spot, which
24
+ // resolves to the `__disabled__:fields` sentinel — keep that call empty so it
25
+ // does not double up on the fixture below.
26
+ useInjectionDataWidgets: (spotId: string) => ({
27
+ widgets: spotId.startsWith('__disabled__') ? [] : injectedFieldWidgets,
28
+ isLoading: false,
29
+ error: null,
30
+ }),
24
31
  }))
25
32
 
26
33
  import * as React from 'react'
@@ -0,0 +1,188 @@
1
+ /** @jest-environment jsdom */
2
+ jest.setTimeout(15000)
3
+
4
+ // Regression coverage for the `legacyInjectionSpotId` bridge added for issue #6017 /
5
+ // PR #6063 (see BACKWARD_COMPATIBILITY.md "Customers Quick-Create Injection Spot
6
+ // Bridge"). When a caller renames/narrows its declared `injectionSpotId`, passing
7
+ // the prior id as `legacyInjectionSpotId` must keep every widget still targeting
8
+ // that prior id rendering — header, body/group/stack, and `:fields` — without
9
+ // double-rendering a widget registered under the same `widgetId` on both spots.
10
+ let widgetsBySpot: Record<string, unknown[]> = {}
11
+ let fieldWidgetsBySpot: Record<string, unknown[]> = {}
12
+
13
+ jest.mock('next/navigation', () => ({
14
+ useRouter: () => ({ push: () => {} }),
15
+ usePathname: () => '/',
16
+ useSearchParams: () => new URLSearchParams(),
17
+ }))
18
+ jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
19
+ jest.mock('../injection/InjectionSpot', () => ({
20
+ __esModule: true,
21
+ InjectionSpot: (props: any) => {
22
+ const widgets = (props.widgetsOverride ?? widgetsBySpot[props.spotId] ?? []) as any[]
23
+ return (
24
+ <div data-testid={`injection-spot-${props.spotId}`}>
25
+ {widgets.map((w) => (
26
+ <w.module.Widget key={w.widgetId} context={props.context} data={props.data} onDataChange={() => {}} disabled={false} />
27
+ ))}
28
+ </div>
29
+ )
30
+ },
31
+ useInjectionWidgets: (spotId: string | undefined) => ({
32
+ widgets: spotId ? (widgetsBySpot[spotId] ?? []) : [],
33
+ loading: false,
34
+ error: null,
35
+ }),
36
+ useInjectionSpotEvents: () => ({ triggerEvent: jest.fn(async () => ({ ok: true, data: {} })) }),
37
+ }))
38
+ jest.mock('../injection/useInjectionDataWidgets', () => ({
39
+ __esModule: true,
40
+ useInjectionDataWidgets: (spotId: string) => ({
41
+ widgets: fieldWidgetsBySpot[spotId] ?? [],
42
+ isLoading: false,
43
+ error: null,
44
+ }),
45
+ }))
46
+
47
+ import * as React from 'react'
48
+ import { waitFor } from '@testing-library/react'
49
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
50
+ import { CrudForm, type CrudField, type CrudFormGroup } from '../CrudForm'
51
+
52
+ const PRIMARY_SPOT = 'crud-form:test.declared'
53
+ const LEGACY_SPOT = 'crud-form:test.legacy'
54
+
55
+ const fields: CrudField[] = [{ id: 'name', label: 'Name', type: 'text' }]
56
+ const groups: CrudFormGroup[] = [{ id: 'main', label: 'Main', fields: ['name'] }]
57
+
58
+ function makeWidget(widgetId: string, testId: string) {
59
+ return {
60
+ widgetId,
61
+ moduleId: 'test-module',
62
+ key: widgetId,
63
+ placement: { kind: 'stack' },
64
+ module: {
65
+ metadata: { id: widgetId, title: widgetId, description: '' },
66
+ Widget: () => <div data-testid={testId}>{testId}</div>,
67
+ },
68
+ }
69
+ }
70
+
71
+ function makeFieldWidget(fieldId: string) {
72
+ return {
73
+ metadata: { id: fieldId, title: fieldId, description: '' },
74
+ fields: [{ id: fieldId, label: fieldId, type: 'text', group: 'main' }],
75
+ }
76
+ }
77
+
78
+ function renderForm() {
79
+ return renderWithProviders(
80
+ React.createElement(CrudForm as never, {
81
+ title: 'Form',
82
+ injectionSpotId: PRIMARY_SPOT,
83
+ legacyInjectionSpotId: LEGACY_SPOT,
84
+ fields,
85
+ groups,
86
+ onSubmit: () => {},
87
+ }),
88
+ )
89
+ }
90
+
91
+ describe('CrudForm legacyInjectionSpotId bridge (#6017 / #6063)', () => {
92
+ afterEach(() => {
93
+ widgetsBySpot = {}
94
+ fieldWidgetsBySpot = {}
95
+ })
96
+
97
+ it('renders a widget registered only on the primary spot', async () => {
98
+ widgetsBySpot[PRIMARY_SPOT] = [makeWidget('primary-only', 'primary-only-widget')]
99
+
100
+ const { container } = renderForm()
101
+
102
+ await waitFor(() => {
103
+ expect(container.querySelector('[data-testid="primary-only-widget"]')).toBeTruthy()
104
+ })
105
+ })
106
+
107
+ it('renders a widget registered only on the legacy spot', async () => {
108
+ widgetsBySpot[LEGACY_SPOT] = [makeWidget('legacy-only', 'legacy-only-widget')]
109
+
110
+ const { container } = renderForm()
111
+
112
+ await waitFor(() => {
113
+ expect(container.querySelector('[data-testid="legacy-only-widget"]')).toBeTruthy()
114
+ })
115
+ })
116
+
117
+ it('renders a widget with the same widgetId on both spots exactly once', async () => {
118
+ widgetsBySpot[PRIMARY_SPOT] = [makeWidget('shared-widget', 'shared-widget')]
119
+ widgetsBySpot[LEGACY_SPOT] = [makeWidget('shared-widget', 'shared-widget')]
120
+
121
+ const { container } = renderForm()
122
+
123
+ await waitFor(() => {
124
+ expect(container.querySelectorAll('[data-testid="shared-widget"]')).toHaveLength(1)
125
+ })
126
+ })
127
+
128
+ it('bridges the header injection spot for a widget still targeting the legacy id', async () => {
129
+ widgetsBySpot[`${LEGACY_SPOT}:header`] = [makeWidget('legacy-header', 'legacy-header-widget')]
130
+
131
+ const { container } = renderForm()
132
+
133
+ await waitFor(() => {
134
+ expect(container.querySelector(`[data-testid="injection-spot-${LEGACY_SPOT}:header"]`)).toBeTruthy()
135
+ })
136
+ expect(container.querySelector('[data-testid="legacy-header-widget"]')).toBeTruthy()
137
+ })
138
+
139
+ it('renders a widget with the same widgetId on both header spots twice (header is not deduped)', async () => {
140
+ widgetsBySpot[`${PRIMARY_SPOT}:header`] = [makeWidget('shared-header-widget', 'shared-header-widget')]
141
+ widgetsBySpot[`${LEGACY_SPOT}:header`] = [makeWidget('shared-header-widget', 'shared-header-widget')]
142
+
143
+ const { container } = renderForm()
144
+
145
+ await waitFor(() => {
146
+ expect(container.querySelectorAll('[data-testid="shared-header-widget"]')).toHaveLength(2)
147
+ })
148
+ })
149
+
150
+ it('merges :fields widgets from both spots and dedupes a shared field id', async () => {
151
+ fieldWidgetsBySpot[`${PRIMARY_SPOT}:fields`] = [makeFieldWidget('cf:primary_field')]
152
+ fieldWidgetsBySpot[`${LEGACY_SPOT}:fields`] = [
153
+ makeFieldWidget('cf:legacy_field'),
154
+ makeFieldWidget('cf:primary_field'),
155
+ ]
156
+
157
+ const { container } = renderForm()
158
+
159
+ await waitFor(() => {
160
+ expect(container.querySelector('[data-crud-field-id="cf:legacy_field"]')).toBeTruthy()
161
+ })
162
+ // `[data-crud-field-id]` is nested onto both a field's wrapper and its inner
163
+ // control, so count distinct labels (one per rendered field) rather than nodes.
164
+ const primaryFieldLabels = Array.from(container.querySelectorAll('label')).filter(
165
+ (node) => node.textContent?.trim() === 'cf:primary_field',
166
+ )
167
+ expect(primaryFieldLabels).toHaveLength(1)
168
+ })
169
+
170
+ it('renders unchanged when legacyInjectionSpotId is omitted (every other CrudForm caller)', async () => {
171
+ widgetsBySpot[PRIMARY_SPOT] = [makeWidget('primary-only', 'primary-only-widget')]
172
+
173
+ const { container } = renderWithProviders(
174
+ React.createElement(CrudForm as never, {
175
+ title: 'Form',
176
+ injectionSpotId: PRIMARY_SPOT,
177
+ fields,
178
+ groups,
179
+ onSubmit: () => {},
180
+ }),
181
+ )
182
+
183
+ await waitFor(() => {
184
+ expect(container.querySelector('[data-testid="primary-only-widget"]')).toBeTruthy()
185
+ })
186
+ expect(container.querySelector(`[data-testid="injection-spot-${LEGACY_SPOT}:header"]`)).toBeNull()
187
+ })
188
+ })