@open-mercato/ui 0.6.5-develop.4498.1.55dc06a57c → 0.6.5-develop.4534.1.b459babe6d
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.6.5-develop.
|
|
3
|
+
"version": "0.6.5-develop.4534.1.b459babe6d",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -145,13 +145,13 @@
|
|
|
145
145
|
"remark-gfm": "^4.0.1"
|
|
146
146
|
},
|
|
147
147
|
"peerDependencies": {
|
|
148
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
148
|
+
"@open-mercato/shared": "0.6.5-develop.4534.1.b459babe6d",
|
|
149
149
|
"react": ">=18.0.0",
|
|
150
150
|
"react-dom": ">=18.0.0",
|
|
151
151
|
"react-is": ">=18.0.0"
|
|
152
152
|
},
|
|
153
153
|
"devDependencies": {
|
|
154
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
154
|
+
"@open-mercato/shared": "0.6.5-develop.4534.1.b459babe6d",
|
|
155
155
|
"@testing-library/dom": "^10.4.1",
|
|
156
156
|
"@testing-library/jest-dom": "^6.9.1",
|
|
157
157
|
"@testing-library/react": "^16.3.1",
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -407,6 +407,62 @@ function readByDotPath(source: Record<string, unknown> | undefined, path: string
|
|
|
407
407
|
return current
|
|
408
408
|
}
|
|
409
409
|
|
|
410
|
+
// Keys that would mutate the prototype chain if used as dot-path segments.
|
|
411
|
+
// Guard against them so a declared field id such as `__proto__.polluted` can
|
|
412
|
+
// never reach into Object.prototype (prototype-pollution hardening).
|
|
413
|
+
const PROTO_POLLUTING_KEYS = new Set(['__proto__', 'prototype', 'constructor'])
|
|
414
|
+
|
|
415
|
+
function isProtoPollutingKey(key: string): boolean {
|
|
416
|
+
return PROTO_POLLUTING_KEYS.has(key)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function writeByDotPath(target: Record<string, unknown>, path: string, value: unknown): void {
|
|
420
|
+
const segments = path.split('.').filter(Boolean)
|
|
421
|
+
if (segments.length === 0) return
|
|
422
|
+
if (segments.some(isProtoPollutingKey)) return
|
|
423
|
+
let current: Record<string, unknown> = target
|
|
424
|
+
for (let index = 0; index < segments.length - 1; index++) {
|
|
425
|
+
const segment = segments[index]
|
|
426
|
+
const existing = current[segment]
|
|
427
|
+
const isSafeObject =
|
|
428
|
+
!!existing &&
|
|
429
|
+
typeof existing === 'object' &&
|
|
430
|
+
!Array.isArray(existing) &&
|
|
431
|
+
(Object.getPrototypeOf(existing) === Object.prototype || Object.getPrototypeOf(existing) === null)
|
|
432
|
+
|
|
433
|
+
if (!isSafeObject) {
|
|
434
|
+
const created = Object.create(null) as Record<string, unknown>
|
|
435
|
+
current[segment] = created
|
|
436
|
+
current = created
|
|
437
|
+
} else {
|
|
438
|
+
current = existing as Record<string, unknown>
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const finalSegment = segments[segments.length - 1]
|
|
442
|
+
if (isProtoPollutingKey(finalSegment)) return
|
|
443
|
+
current[finalSegment] = value
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Collapse flat dot-path keys (e.g. `metadata.category`) declared as base form
|
|
447
|
+
// fields into the nested object the schema expects (`{ metadata: { category } }`).
|
|
448
|
+
// The flat keys are preserved alongside the nested projection so that forms whose
|
|
449
|
+
// schema declares the flat keys directly keep working — CrudForm's non-strict
|
|
450
|
+
// schema.safeParse retains whichever representation the schema declares and strips
|
|
451
|
+
// the other. See issue #2503 ("Latent framework gap").
|
|
452
|
+
function collapseDotPathFields(
|
|
453
|
+
source: Record<string, unknown>,
|
|
454
|
+
dotPathFieldIds: ReadonlySet<string>,
|
|
455
|
+
): Record<string, unknown> {
|
|
456
|
+
if (!dotPathFieldIds.size) return source
|
|
457
|
+
let result: Record<string, unknown> | null = null
|
|
458
|
+
for (const fieldId of dotPathFieldIds) {
|
|
459
|
+
if (!Object.prototype.hasOwnProperty.call(source, fieldId)) continue
|
|
460
|
+
if (!result) result = { ...source }
|
|
461
|
+
writeByDotPath(result, fieldId, source[fieldId])
|
|
462
|
+
}
|
|
463
|
+
return result ?? source
|
|
464
|
+
}
|
|
465
|
+
|
|
410
466
|
function readInitialCustomFieldValue(
|
|
411
467
|
source: Record<string, unknown> | undefined,
|
|
412
468
|
key: string,
|
|
@@ -1660,6 +1716,25 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1660
1716
|
return new globalThis.Map(allFields.map((f) => [f.id, f]))
|
|
1661
1717
|
}, [allFields])
|
|
1662
1718
|
|
|
1719
|
+
// Declared base fields whose id is a dot-path (e.g. `metadata.category`).
|
|
1720
|
+
// Injected and custom (cf) fields already get dot-path/cf-path hydration and
|
|
1721
|
+
// are excluded here so their dedicated handling is preserved. CrudForm hydrates
|
|
1722
|
+
// these flat keys from nested initial values on load and projects them back into
|
|
1723
|
+
// nested objects before schema.safeParse on submit. See issue #2503.
|
|
1724
|
+
const dotPathBaseFieldIds = React.useMemo(() => {
|
|
1725
|
+
const ids = new Set<string>()
|
|
1726
|
+
const cfFieldIds = new Set(cfFields.map((field) => field.id))
|
|
1727
|
+
for (const field of allFields) {
|
|
1728
|
+
const fieldId = field.id
|
|
1729
|
+
if (!fieldId.includes('.')) continue
|
|
1730
|
+
if (injectedFieldIdSet.has(fieldId)) continue
|
|
1731
|
+
if (fieldId.startsWith('cf_') || fieldId.startsWith('cf:')) continue
|
|
1732
|
+
if (cfFieldIds.has(fieldId)) continue
|
|
1733
|
+
ids.add(fieldId)
|
|
1734
|
+
}
|
|
1735
|
+
return ids
|
|
1736
|
+
}, [allFields, injectedFieldIdSet, cfFields])
|
|
1737
|
+
|
|
1663
1738
|
const validateFieldOnBlur = React.useCallback(async (
|
|
1664
1739
|
fieldId: string,
|
|
1665
1740
|
sourceValues?: CrudFormValues<TValues>,
|
|
@@ -1732,7 +1807,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1732
1807
|
for (const injectedId of injectedFieldIdSet) {
|
|
1733
1808
|
delete coreValues[injectedId]
|
|
1734
1809
|
}
|
|
1735
|
-
const result = schema.safeParse(coreValues)
|
|
1810
|
+
const result = schema.safeParse(collapseDotPathFields(coreValues, dotPathBaseFieldIds))
|
|
1736
1811
|
if (!result.success) {
|
|
1737
1812
|
const schemaFieldErrors: Record<string, string> = {}
|
|
1738
1813
|
result.error.issues.forEach((issue) => {
|
|
@@ -1772,6 +1847,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1772
1847
|
}, [
|
|
1773
1848
|
cfDefinitions,
|
|
1774
1849
|
customEntity,
|
|
1850
|
+
dotPathBaseFieldIds,
|
|
1775
1851
|
fieldById,
|
|
1776
1852
|
formReadOnly,
|
|
1777
1853
|
hiddenBaseFieldIds,
|
|
@@ -2177,6 +2253,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2177
2253
|
initialValues,
|
|
2178
2254
|
injectedFieldIds: injectedFieldDefinitions.map((definition) => definition.id),
|
|
2179
2255
|
customFieldMappings: cfDefinitions.map((definition) => definition.key),
|
|
2256
|
+
dotPathBaseFieldIds: Array.from(dotPathBaseFieldIds),
|
|
2180
2257
|
})
|
|
2181
2258
|
if (appliedInitialValuesSnapshotRef.current === snapshot) return
|
|
2182
2259
|
appliedInitialValuesSnapshotRef.current = snapshot
|
|
@@ -2199,6 +2276,13 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2199
2276
|
;(merged as Record<string, unknown>)[targetId] = extracted
|
|
2200
2277
|
}
|
|
2201
2278
|
}
|
|
2279
|
+
for (const fieldId of dotPathBaseFieldIds) {
|
|
2280
|
+
if (merged[fieldId] !== undefined) continue
|
|
2281
|
+
const extracted = readByDotPath(initialRecord, fieldId)
|
|
2282
|
+
if (extracted !== undefined) {
|
|
2283
|
+
;(merged as Record<string, unknown>)[fieldId] = extracted
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2202
2286
|
mergedValues = merged
|
|
2203
2287
|
return mergedValues
|
|
2204
2288
|
})
|
|
@@ -2229,6 +2313,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2229
2313
|
}, [
|
|
2230
2314
|
cfDefinitions,
|
|
2231
2315
|
customEntity,
|
|
2316
|
+
dotPathBaseFieldIds,
|
|
2232
2317
|
extendedInjectionEventsEnabled,
|
|
2233
2318
|
initialValues,
|
|
2234
2319
|
injectedFieldDefinitions,
|
|
@@ -2426,7 +2511,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2426
2511
|
|
|
2427
2512
|
let parsedValues: TValues
|
|
2428
2513
|
if (schema) {
|
|
2429
|
-
const res = schema.safeParse(coreValues)
|
|
2514
|
+
const res = schema.safeParse(collapseDotPathFields(coreValues, dotPathBaseFieldIds))
|
|
2430
2515
|
if (!res.success) {
|
|
2431
2516
|
const fieldErrors: Record<string, string> = {}
|
|
2432
2517
|
res.error.issues.forEach((issue) => {
|
|
@@ -2456,7 +2541,9 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2456
2541
|
for (const injectedId of injectedFieldIdSet) {
|
|
2457
2542
|
delete projectedCoreValues[injectedId]
|
|
2458
2543
|
}
|
|
2459
|
-
coreSubmitValues = schema
|
|
2544
|
+
coreSubmitValues = schema
|
|
2545
|
+
? schema.parse(collapseDotPathFields(projectedCoreValues, dotPathBaseFieldIds))
|
|
2546
|
+
: (projectedCoreValues as TValues)
|
|
2460
2547
|
if (result.applyToForm) {
|
|
2461
2548
|
setValues(result.data as CrudFormValues<TValues>)
|
|
2462
2549
|
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
jest.setTimeout(15000)
|
|
3
|
+
|
|
4
|
+
// Regression coverage for issue #2503 ("Latent framework gap"): CrudForm must
|
|
5
|
+
// honor dot-path ids for declared base fields — hydrate the flat key from nested
|
|
6
|
+
// initial values on load, and project the flat key back into a nested object
|
|
7
|
+
// before schema.safeParse on submit. The fix coexists with forms whose schema
|
|
8
|
+
// declares the flat dot-path key directly (the module-local remedy shipped in
|
|
9
|
+
// PR #2513), because the non-strict parse keeps whichever shape the schema declares.
|
|
10
|
+
|
|
11
|
+
const triggerEventMock = jest.fn()
|
|
12
|
+
|
|
13
|
+
jest.mock('next/navigation', () => ({
|
|
14
|
+
useRouter: () => ({ push: jest.fn() }),
|
|
15
|
+
usePathname: () => '/',
|
|
16
|
+
useSearchParams: () => new URLSearchParams(),
|
|
17
|
+
}))
|
|
18
|
+
jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
|
|
19
|
+
jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
|
|
20
|
+
jest.mock('../confirm-dialog', () => ({
|
|
21
|
+
useConfirmDialog: () => ({
|
|
22
|
+
confirm: jest.fn().mockResolvedValue(true),
|
|
23
|
+
ConfirmDialogElement: null,
|
|
24
|
+
}),
|
|
25
|
+
}))
|
|
26
|
+
jest.mock('../injection/InjectionSpot', () => ({
|
|
27
|
+
__esModule: true,
|
|
28
|
+
InjectionSpot: () => null,
|
|
29
|
+
useInjectionWidgets: () => ({ widgets: [], loading: false, error: null }),
|
|
30
|
+
useInjectionSpotEvents: () => ({ triggerEvent: triggerEventMock }),
|
|
31
|
+
}))
|
|
32
|
+
jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
33
|
+
__esModule: true,
|
|
34
|
+
useInjectionDataWidgets: () => ({ widgets: [], isLoading: false, error: null }),
|
|
35
|
+
}))
|
|
36
|
+
|
|
37
|
+
import * as React from 'react'
|
|
38
|
+
import { act, fireEvent, screen } from '@testing-library/react'
|
|
39
|
+
import { z } from 'zod'
|
|
40
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
41
|
+
import { CrudForm, type CrudField } from '../CrudForm'
|
|
42
|
+
|
|
43
|
+
const dict = { 'ui.forms.actions.save': 'Save' }
|
|
44
|
+
|
|
45
|
+
const dotPathFields: CrudField[] = [
|
|
46
|
+
{ id: 'workflowName', label: 'Name', type: 'text' },
|
|
47
|
+
{ id: 'metadata.category', label: 'Category', type: 'text' },
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
function flushMicrotasks() {
|
|
51
|
+
return act(async () => {
|
|
52
|
+
await Promise.resolve()
|
|
53
|
+
await Promise.resolve()
|
|
54
|
+
await Promise.resolve()
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('CrudForm dot-path base fields (issue #2503)', () => {
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
triggerEventMock.mockReset()
|
|
61
|
+
triggerEventMock.mockImplementation(async (event: string, data: Record<string, unknown>) => {
|
|
62
|
+
if (event === 'onBeforeSave') return { ok: true }
|
|
63
|
+
if (event === 'onAfterSave') return { ok: true }
|
|
64
|
+
return { ok: true, data }
|
|
65
|
+
})
|
|
66
|
+
window.history.replaceState({}, '', '/current')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('hydrates a declared dot-path field from nested initial values on load', async () => {
|
|
70
|
+
renderWithProviders(
|
|
71
|
+
<CrudForm
|
|
72
|
+
title="Edit"
|
|
73
|
+
fields={dotPathFields}
|
|
74
|
+
initialValues={{ id: 'wf-1', workflowName: 'Pipeline', metadata: { category: 'Sales' } } as any}
|
|
75
|
+
onSubmit={() => {}}
|
|
76
|
+
/>,
|
|
77
|
+
{ dict },
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
await flushMicrotasks()
|
|
81
|
+
|
|
82
|
+
// The Category input reads values['metadata.category'], hydrated from the
|
|
83
|
+
// nested metadata.category in initialValues.
|
|
84
|
+
expect(screen.queryByDisplayValue('Sales')).toBeInTheDocument()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('collapses the flat dot-path key into a nested object for a nested schema on save', async () => {
|
|
88
|
+
const schema = z.object({
|
|
89
|
+
workflowName: z.string(),
|
|
90
|
+
metadata: z
|
|
91
|
+
.object({ category: z.string().optional() })
|
|
92
|
+
.optional()
|
|
93
|
+
.nullable(),
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
function Harness() {
|
|
97
|
+
const [submitted, setSubmitted] = React.useState<Record<string, unknown> | null>(null)
|
|
98
|
+
return (
|
|
99
|
+
<>
|
|
100
|
+
<CrudForm
|
|
101
|
+
title="Edit"
|
|
102
|
+
fields={dotPathFields}
|
|
103
|
+
schema={schema as any}
|
|
104
|
+
initialValues={{ id: 'wf-1', workflowName: 'Pipeline', metadata: { category: 'Sales' } } as any}
|
|
105
|
+
onSubmit={(values) => setSubmitted(values as Record<string, unknown>)}
|
|
106
|
+
/>
|
|
107
|
+
<div data-testid="submitted">{JSON.stringify(submitted)}</div>
|
|
108
|
+
</>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const { container } = renderWithProviders(<Harness />, { dict })
|
|
113
|
+
await flushMicrotasks()
|
|
114
|
+
|
|
115
|
+
const categoryInput = container.querySelector(
|
|
116
|
+
'[data-crud-field-id="metadata.category"] input[type="text"]',
|
|
117
|
+
) as HTMLInputElement
|
|
118
|
+
const form = container.querySelector('form') as HTMLFormElement
|
|
119
|
+
|
|
120
|
+
await act(async () => {
|
|
121
|
+
fireEvent.change(categoryInput, { target: { value: 'qa-category-2466' } })
|
|
122
|
+
fireEvent.blur(categoryInput)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
await act(async () => {
|
|
126
|
+
fireEvent.submit(form)
|
|
127
|
+
await Promise.resolve()
|
|
128
|
+
await Promise.resolve()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const submitted = JSON.parse(screen.getByTestId('submitted').textContent || 'null')
|
|
132
|
+
expect(submitted).not.toBeNull()
|
|
133
|
+
// Edited value is reassembled into the nested metadata object the schema declares.
|
|
134
|
+
expect(submitted.metadata).toEqual({ category: 'qa-category-2466' })
|
|
135
|
+
// The flat dot-path key is stripped by the non-strict parse for a nested schema.
|
|
136
|
+
expect(submitted['metadata.category']).toBeUndefined()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('preserves the flat dot-path key for a schema that declares it directly (PR #2513 coexistence)', async () => {
|
|
140
|
+
const schema = z.object({
|
|
141
|
+
workflowName: z.string(),
|
|
142
|
+
'metadata.category': z.string().optional(),
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
function Harness() {
|
|
146
|
+
const [submitted, setSubmitted] = React.useState<Record<string, unknown> | null>(null)
|
|
147
|
+
return (
|
|
148
|
+
<>
|
|
149
|
+
<CrudForm
|
|
150
|
+
title="Edit"
|
|
151
|
+
fields={dotPathFields}
|
|
152
|
+
schema={schema as any}
|
|
153
|
+
initialValues={{ id: 'wf-1', workflowName: 'Pipeline', metadata: { category: 'Sales' } } as any}
|
|
154
|
+
onSubmit={(values) => setSubmitted(values as Record<string, unknown>)}
|
|
155
|
+
/>
|
|
156
|
+
<div data-testid="submitted">{JSON.stringify(submitted)}</div>
|
|
157
|
+
</>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const { container } = renderWithProviders(<Harness />, { dict })
|
|
162
|
+
await flushMicrotasks()
|
|
163
|
+
|
|
164
|
+
const categoryInput = container.querySelector(
|
|
165
|
+
'[data-crud-field-id="metadata.category"] input[type="text"]',
|
|
166
|
+
) as HTMLInputElement
|
|
167
|
+
const form = container.querySelector('form') as HTMLFormElement
|
|
168
|
+
|
|
169
|
+
await act(async () => {
|
|
170
|
+
fireEvent.change(categoryInput, { target: { value: 'qa-category-2466' } })
|
|
171
|
+
fireEvent.blur(categoryInput)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
await act(async () => {
|
|
175
|
+
fireEvent.submit(form)
|
|
176
|
+
await Promise.resolve()
|
|
177
|
+
await Promise.resolve()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const submitted = JSON.parse(screen.getByTestId('submitted').textContent || 'null')
|
|
181
|
+
expect(submitted).not.toBeNull()
|
|
182
|
+
// A flat-key schema keeps the flat representation untouched.
|
|
183
|
+
expect(submitted['metadata.category']).toBe('qa-category-2466')
|
|
184
|
+
expect(submitted.metadata).toBeUndefined()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('does not flag a required nested field on blur once the dot-path input is filled', async () => {
|
|
188
|
+
const schema = z.object({
|
|
189
|
+
workflowName: z.string(),
|
|
190
|
+
metadata: z.object({ category: z.string().min(1) }),
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
const { container } = renderWithProviders(
|
|
194
|
+
<CrudForm
|
|
195
|
+
title="Edit"
|
|
196
|
+
fields={dotPathFields}
|
|
197
|
+
schema={schema as any}
|
|
198
|
+
initialValues={{ id: 'wf-1', workflowName: 'Pipeline', metadata: { category: 'Sales' } } as any}
|
|
199
|
+
onSubmit={() => {}}
|
|
200
|
+
/>,
|
|
201
|
+
{ dict },
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
await flushMicrotasks()
|
|
205
|
+
|
|
206
|
+
const categoryInput = container.querySelector(
|
|
207
|
+
'[data-crud-field-id="metadata.category"] input[type="text"]',
|
|
208
|
+
) as HTMLInputElement
|
|
209
|
+
|
|
210
|
+
await act(async () => {
|
|
211
|
+
fireEvent.change(categoryInput, { target: { value: 'qa-category-2466' } })
|
|
212
|
+
fireEvent.blur(categoryInput)
|
|
213
|
+
await Promise.resolve()
|
|
214
|
+
await Promise.resolve()
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
// Blur validation collapses the flat key before schema.safeParse, so the
|
|
218
|
+
// required nested metadata.category is seen as satisfied — no error surfaces.
|
|
219
|
+
const fieldContainer = container.querySelector('[data-crud-field-id="metadata.category"]') as HTMLElement
|
|
220
|
+
expect(fieldContainer.querySelector('.text-status-error-text')).toBeNull()
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('merges multiple dot-path fields sharing a parent into one nested object', async () => {
|
|
224
|
+
const fields: CrudField[] = [
|
|
225
|
+
{ id: 'workflowName', label: 'Name', type: 'text' },
|
|
226
|
+
{ id: 'metadata.category', label: 'Category', type: 'text' },
|
|
227
|
+
{ id: 'metadata.icon', label: 'Icon', type: 'text' },
|
|
228
|
+
]
|
|
229
|
+
const schema = z.object({
|
|
230
|
+
workflowName: z.string(),
|
|
231
|
+
metadata: z
|
|
232
|
+
.object({ category: z.string().optional(), icon: z.string().optional() })
|
|
233
|
+
.optional(),
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
function Harness() {
|
|
237
|
+
const [submitted, setSubmitted] = React.useState<Record<string, unknown> | null>(null)
|
|
238
|
+
return (
|
|
239
|
+
<>
|
|
240
|
+
<CrudForm
|
|
241
|
+
title="Edit"
|
|
242
|
+
fields={fields}
|
|
243
|
+
schema={schema as any}
|
|
244
|
+
initialValues={{
|
|
245
|
+
id: 'wf-1',
|
|
246
|
+
workflowName: 'Pipeline',
|
|
247
|
+
metadata: { category: 'Sales', icon: 'shopping-cart' },
|
|
248
|
+
} as any}
|
|
249
|
+
onSubmit={(values) => setSubmitted(values as Record<string, unknown>)}
|
|
250
|
+
/>
|
|
251
|
+
<div data-testid="submitted">{JSON.stringify(submitted)}</div>
|
|
252
|
+
</>
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const { container } = renderWithProviders(<Harness />, { dict })
|
|
257
|
+
await flushMicrotasks()
|
|
258
|
+
|
|
259
|
+
const form = container.querySelector('form') as HTMLFormElement
|
|
260
|
+
await act(async () => {
|
|
261
|
+
fireEvent.submit(form)
|
|
262
|
+
await Promise.resolve()
|
|
263
|
+
await Promise.resolve()
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
const submitted = JSON.parse(screen.getByTestId('submitted').textContent || 'null')
|
|
267
|
+
expect(submitted.metadata).toEqual({ category: 'Sales', icon: 'shopping-cart' })
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('does not pollute Object.prototype via a prototype-polluting dot-path field id', async () => {
|
|
271
|
+
const maliciousFields: CrudField[] = [
|
|
272
|
+
{ id: 'workflowName', label: 'Name', type: 'text' },
|
|
273
|
+
{ id: '__proto__.polluted', label: 'Evil', type: 'text' },
|
|
274
|
+
]
|
|
275
|
+
const schema = z.object({ workflowName: z.string() }).passthrough()
|
|
276
|
+
|
|
277
|
+
function Harness() {
|
|
278
|
+
const [submitted, setSubmitted] = React.useState<Record<string, unknown> | null>(null)
|
|
279
|
+
return (
|
|
280
|
+
<>
|
|
281
|
+
<CrudForm
|
|
282
|
+
title="Edit"
|
|
283
|
+
fields={maliciousFields}
|
|
284
|
+
schema={schema as any}
|
|
285
|
+
initialValues={{ id: 'wf-1', workflowName: 'Pipeline', '__proto__.polluted': 'pwned' } as any}
|
|
286
|
+
onSubmit={(values) => setSubmitted(values as Record<string, unknown>)}
|
|
287
|
+
/>
|
|
288
|
+
<div data-testid="submitted">{JSON.stringify(submitted)}</div>
|
|
289
|
+
</>
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const { container } = renderWithProviders(<Harness />, { dict })
|
|
294
|
+
await flushMicrotasks()
|
|
295
|
+
|
|
296
|
+
const form = container.querySelector('form') as HTMLFormElement
|
|
297
|
+
await act(async () => {
|
|
298
|
+
fireEvent.submit(form)
|
|
299
|
+
await Promise.resolve()
|
|
300
|
+
await Promise.resolve()
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
// The collapse step must skip prototype-polluting segments entirely, so
|
|
304
|
+
// neither a fresh object nor Object.prototype gains the injected key.
|
|
305
|
+
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
|
|
306
|
+
expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined()
|
|
307
|
+
// The submit still completes for the legitimate fields.
|
|
308
|
+
const submitted = JSON.parse(screen.getByTestId('submitted').textContent || 'null')
|
|
309
|
+
expect(submitted).not.toBeNull()
|
|
310
|
+
expect(submitted.workflowName).toBe('Pipeline')
|
|
311
|
+
})
|
|
312
|
+
})
|