@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.
|
|
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.
|
|
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.
|
|
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",
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
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
|
-
|
|
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
|
+
})
|