@open-mercato/ui 0.6.5-develop.4516.1.88e6ab71a9 → 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.4516.1.88e6ab71a9",
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.4516.1.88e6ab71a9",
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.4516.1.88e6ab71a9",
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",
@@ -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 ? schema.parse(projectedCoreValues) : (projectedCoreValues as TValues)
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
+ })