@open-mercato/ui 0.6.4-develop.4382.1.6b4f656b77 → 0.6.4
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/.turbo/turbo-build.log +2 -2
- package/AGENTS.md +2 -1
- package/dist/backend/AppShell.js +6 -0
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +212 -85
- package/dist/backend/CrudForm.js.map +3 -3
- package/dist/backend/DataTable.js +77 -26
- package/dist/backend/DataTable.js.map +3 -3
- package/dist/backend/FlashMessages.js +21 -2
- package/dist/backend/FlashMessages.js.map +2 -2
- package/dist/backend/JsonBuilder.js +64 -41
- package/dist/backend/JsonBuilder.js.map +2 -2
- package/dist/backend/PerspectiveSidebar.js +8 -1
- package/dist/backend/PerspectiveSidebar.js.map +2 -2
- package/dist/backend/RowActions.js +1 -1
- package/dist/backend/RowActions.js.map +2 -2
- package/dist/backend/conflicts/RecordConflictBanner.js +73 -0
- package/dist/backend/conflicts/RecordConflictBanner.js.map +7 -0
- package/dist/backend/conflicts/index.js +32 -0
- package/dist/backend/conflicts/index.js.map +7 -0
- package/dist/backend/conflicts/store.js +50 -0
- package/dist/backend/conflicts/store.js.map +7 -0
- package/dist/backend/detail/ActivitiesSection.js +1 -0
- package/dist/backend/detail/ActivitiesSection.js.map +2 -2
- package/dist/backend/detail/InlineEditors.js +2 -51
- package/dist/backend/detail/InlineEditors.js.map +3 -3
- package/dist/backend/detail/RecordNotFoundState.js +10 -5
- package/dist/backend/detail/RecordNotFoundState.js.map +2 -2
- package/dist/backend/filters/AdvancedFilterPanel.js +14 -0
- package/dist/backend/filters/AdvancedFilterPanel.js.map +2 -2
- package/dist/backend/filters/FilteredEmptyResults.js +12 -4
- package/dist/backend/filters/FilteredEmptyResults.js.map +2 -2
- package/dist/backend/filters/ListEmptyState.js +44 -0
- package/dist/backend/filters/ListEmptyState.js.map +7 -0
- package/dist/backend/filters/SearchEmptyResults.js +28 -0
- package/dist/backend/filters/SearchEmptyResults.js.map +7 -0
- package/dist/backend/icons/lucideRegistry.generated.js +8 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/useGuardedMutation.js +8 -1
- package/dist/backend/injection/useGuardedMutation.js.map +2 -2
- package/dist/backend/inputs/MarkdownField.js +20 -0
- package/dist/backend/inputs/MarkdownField.js.map +7 -0
- package/dist/backend/inputs/MdxEditorImpl.js +98 -0
- package/dist/backend/inputs/MdxEditorImpl.js.map +7 -0
- package/dist/backend/inputs/SwitchableMarkdownInput.js +6 -38
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
- package/dist/backend/markdown/markdownToPlainText.js +29 -0
- package/dist/backend/markdown/markdownToPlainText.js.map +2 -2
- package/dist/backend/messages/EmailThreadsPanel.js +212 -0
- package/dist/backend/messages/EmailThreadsPanel.js.map +7 -0
- package/dist/backend/messages/index.js +4 -0
- package/dist/backend/messages/index.js.map +2 -2
- package/dist/backend/messages/mergeOptimisticEmailThreads.js +55 -0
- package/dist/backend/messages/mergeOptimisticEmailThreads.js.map +7 -0
- package/dist/backend/notifications/NotificationDispatcher.js +1 -0
- package/dist/backend/notifications/NotificationDispatcher.js.map +2 -2
- package/dist/backend/notifications/useNotificationsPoll.js +7 -0
- package/dist/backend/notifications/useNotificationsPoll.js.map +2 -2
- package/dist/backend/notifications/useNotificationsSse.js +8 -0
- package/dist/backend/notifications/useNotificationsSse.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +23 -7
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
- package/dist/backend/utils/optimisticLock.js +34 -0
- package/dist/backend/utils/optimisticLock.js.map +7 -0
- package/dist/portal/hooks/useTenantContext.js +1 -1
- package/dist/portal/hooks/useTenantContext.js.map +2 -2
- package/dist/primitives/alert.js.map +1 -1
- package/dist/primitives/checkbox.js +2 -1
- package/dist/primitives/checkbox.js.map +2 -2
- package/dist/types/css.d.js +1 -0
- package/dist/types/css.d.js.map +7 -0
- package/package.json +17 -13
- package/src/backend/AppShell.tsx +10 -0
- package/src/backend/CrudForm.tsx +320 -97
- package/src/backend/DataTable.tsx +113 -34
- package/src/backend/FlashMessages.tsx +31 -2
- package/src/backend/JsonBuilder.tsx +79 -26
- package/src/backend/PerspectiveSidebar.tsx +8 -1
- package/src/backend/RowActions.tsx +1 -1
- package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.datetime.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.defaults.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.dotPath.test.tsx +311 -0
- package/src/backend/__tests__/CrudForm.doubleSubmit.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.navigation.test.tsx +120 -1
- package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +287 -0
- package/src/backend/__tests__/CrudForm.render.test.tsx +74 -2
- package/src/backend/__tests__/CrudForm.sortable.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.transformData.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.unsavedNavigation.test.tsx +51 -3
- package/src/backend/__tests__/CrudForm.validation.test.tsx +0 -1
- package/src/backend/__tests__/CustomDataSection.test.tsx +0 -1
- package/src/backend/__tests__/FlashMessages.test.tsx +47 -0
- package/src/backend/__tests__/JsonBuilder.test.tsx +135 -0
- package/src/backend/__tests__/lazy-heavy-libraries.test.ts +16 -3
- package/src/backend/conflicts/RecordConflictBanner.tsx +77 -0
- package/src/backend/conflicts/__tests__/RecordConflictBanner.test.tsx +61 -0
- package/src/backend/conflicts/__tests__/store.test.ts +78 -0
- package/src/backend/conflicts/index.ts +49 -0
- package/src/backend/conflicts/store.ts +94 -0
- package/src/backend/detail/ActivitiesSection.tsx +1 -0
- package/src/backend/detail/InlineEditors.tsx +14 -76
- package/src/backend/detail/RecordNotFoundState.tsx +13 -6
- package/src/backend/detail/__tests__/RecordNotFoundState.test.tsx +80 -0
- package/src/backend/filters/AdvancedFilterPanel.tsx +27 -0
- package/src/backend/filters/FilteredEmptyResults.tsx +23 -10
- package/src/backend/filters/ListEmptyState.tsx +69 -0
- package/src/backend/filters/SearchEmptyResults.tsx +42 -0
- package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +98 -0
- package/src/backend/filters/__tests__/ListEmptyState.test.tsx +53 -0
- package/src/backend/icons/lucideRegistry.generated.tsx +8 -0
- package/src/backend/injection/__tests__/useGuardedMutation.optimisticLock.test.tsx +118 -0
- package/src/backend/injection/useGuardedMutation.ts +16 -1
- package/src/backend/inputs/MarkdownField.tsx +34 -0
- package/src/backend/inputs/MdxEditorImpl.tsx +113 -0
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +5 -54
- package/src/backend/markdown/__tests__/markdownToPlainText.test.ts +13 -0
- package/src/backend/markdown/markdownToPlainText.ts +41 -0
- package/src/backend/messages/EmailThreadsPanel.tsx +351 -0
- package/src/backend/messages/__tests__/mergeOptimisticEmailThreads.test.ts +126 -0
- package/src/backend/messages/index.ts +12 -0
- package/src/backend/messages/mergeOptimisticEmailThreads.ts +84 -0
- package/src/backend/notifications/NotificationDispatcher.ts +2 -0
- package/src/backend/notifications/useNotificationsPoll.ts +5 -0
- package/src/backend/notifications/useNotificationsSse.ts +6 -0
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +35 -11
- package/src/backend/utils/__tests__/optimisticLock.test.ts +126 -0
- package/src/backend/utils/optimisticLock.ts +70 -0
- package/src/portal/hooks/useTenantContext.ts +7 -2
- package/src/primitives/__tests__/checkbox.test.tsx +28 -0
- package/src/primitives/alert.tsx +1 -1
- package/src/primitives/checkbox.tsx +2 -1
- package/src/types/css.d.ts +5 -0
|
@@ -0,0 +1,311 @@
|
|
|
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('../confirm-dialog', () => ({
|
|
20
|
+
useConfirmDialog: () => ({
|
|
21
|
+
confirm: jest.fn().mockResolvedValue(true),
|
|
22
|
+
ConfirmDialogElement: null,
|
|
23
|
+
}),
|
|
24
|
+
}))
|
|
25
|
+
jest.mock('../injection/InjectionSpot', () => ({
|
|
26
|
+
__esModule: true,
|
|
27
|
+
InjectionSpot: () => null,
|
|
28
|
+
useInjectionWidgets: () => ({ widgets: [], loading: false, error: null }),
|
|
29
|
+
useInjectionSpotEvents: () => ({ triggerEvent: triggerEventMock }),
|
|
30
|
+
}))
|
|
31
|
+
jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
32
|
+
__esModule: true,
|
|
33
|
+
useInjectionDataWidgets: () => ({ widgets: [], isLoading: false, error: null }),
|
|
34
|
+
}))
|
|
35
|
+
|
|
36
|
+
import * as React from 'react'
|
|
37
|
+
import { act, fireEvent, screen } from '@testing-library/react'
|
|
38
|
+
import { z } from 'zod'
|
|
39
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
40
|
+
import { CrudForm, type CrudField } from '../CrudForm'
|
|
41
|
+
|
|
42
|
+
const dict = { 'ui.forms.actions.save': 'Save' }
|
|
43
|
+
|
|
44
|
+
const dotPathFields: CrudField[] = [
|
|
45
|
+
{ id: 'workflowName', label: 'Name', type: 'text' },
|
|
46
|
+
{ id: 'metadata.category', label: 'Category', type: 'text' },
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
function flushMicrotasks() {
|
|
50
|
+
return act(async () => {
|
|
51
|
+
await Promise.resolve()
|
|
52
|
+
await Promise.resolve()
|
|
53
|
+
await Promise.resolve()
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('CrudForm dot-path base fields (issue #2503)', () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
triggerEventMock.mockReset()
|
|
60
|
+
triggerEventMock.mockImplementation(async (event: string, data: Record<string, unknown>) => {
|
|
61
|
+
if (event === 'onBeforeSave') return { ok: true }
|
|
62
|
+
if (event === 'onAfterSave') return { ok: true }
|
|
63
|
+
return { ok: true, data }
|
|
64
|
+
})
|
|
65
|
+
window.history.replaceState({}, '', '/current')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('hydrates a declared dot-path field from nested initial values on load', async () => {
|
|
69
|
+
renderWithProviders(
|
|
70
|
+
<CrudForm
|
|
71
|
+
title="Edit"
|
|
72
|
+
fields={dotPathFields}
|
|
73
|
+
initialValues={{ id: 'wf-1', workflowName: 'Pipeline', metadata: { category: 'Sales' } } as any}
|
|
74
|
+
onSubmit={() => {}}
|
|
75
|
+
/>,
|
|
76
|
+
{ dict },
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
await flushMicrotasks()
|
|
80
|
+
|
|
81
|
+
// The Category input reads values['metadata.category'], hydrated from the
|
|
82
|
+
// nested metadata.category in initialValues.
|
|
83
|
+
expect(screen.queryByDisplayValue('Sales')).toBeInTheDocument()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('collapses the flat dot-path key into a nested object for a nested schema on save', async () => {
|
|
87
|
+
const schema = z.object({
|
|
88
|
+
workflowName: z.string(),
|
|
89
|
+
metadata: z
|
|
90
|
+
.object({ category: z.string().optional() })
|
|
91
|
+
.optional()
|
|
92
|
+
.nullable(),
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
function Harness() {
|
|
96
|
+
const [submitted, setSubmitted] = React.useState<Record<string, unknown> | null>(null)
|
|
97
|
+
return (
|
|
98
|
+
<>
|
|
99
|
+
<CrudForm
|
|
100
|
+
title="Edit"
|
|
101
|
+
fields={dotPathFields}
|
|
102
|
+
schema={schema as any}
|
|
103
|
+
initialValues={{ id: 'wf-1', workflowName: 'Pipeline', metadata: { category: 'Sales' } } as any}
|
|
104
|
+
onSubmit={(values) => setSubmitted(values as Record<string, unknown>)}
|
|
105
|
+
/>
|
|
106
|
+
<div data-testid="submitted">{JSON.stringify(submitted)}</div>
|
|
107
|
+
</>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const { container } = renderWithProviders(<Harness />, { dict })
|
|
112
|
+
await flushMicrotasks()
|
|
113
|
+
|
|
114
|
+
const categoryInput = container.querySelector(
|
|
115
|
+
'[data-crud-field-id="metadata.category"] input[type="text"]',
|
|
116
|
+
) as HTMLInputElement
|
|
117
|
+
const form = container.querySelector('form') as HTMLFormElement
|
|
118
|
+
|
|
119
|
+
await act(async () => {
|
|
120
|
+
fireEvent.change(categoryInput, { target: { value: 'qa-category-2466' } })
|
|
121
|
+
fireEvent.blur(categoryInput)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
await act(async () => {
|
|
125
|
+
fireEvent.submit(form)
|
|
126
|
+
await Promise.resolve()
|
|
127
|
+
await Promise.resolve()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const submitted = JSON.parse(screen.getByTestId('submitted').textContent || 'null')
|
|
131
|
+
expect(submitted).not.toBeNull()
|
|
132
|
+
// Edited value is reassembled into the nested metadata object the schema declares.
|
|
133
|
+
expect(submitted.metadata).toEqual({ category: 'qa-category-2466' })
|
|
134
|
+
// The flat dot-path key is stripped by the non-strict parse for a nested schema.
|
|
135
|
+
expect(submitted['metadata.category']).toBeUndefined()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('preserves the flat dot-path key for a schema that declares it directly (PR #2513 coexistence)', async () => {
|
|
139
|
+
const schema = z.object({
|
|
140
|
+
workflowName: z.string(),
|
|
141
|
+
'metadata.category': z.string().optional(),
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
function Harness() {
|
|
145
|
+
const [submitted, setSubmitted] = React.useState<Record<string, unknown> | null>(null)
|
|
146
|
+
return (
|
|
147
|
+
<>
|
|
148
|
+
<CrudForm
|
|
149
|
+
title="Edit"
|
|
150
|
+
fields={dotPathFields}
|
|
151
|
+
schema={schema as any}
|
|
152
|
+
initialValues={{ id: 'wf-1', workflowName: 'Pipeline', metadata: { category: 'Sales' } } as any}
|
|
153
|
+
onSubmit={(values) => setSubmitted(values as Record<string, unknown>)}
|
|
154
|
+
/>
|
|
155
|
+
<div data-testid="submitted">{JSON.stringify(submitted)}</div>
|
|
156
|
+
</>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const { container } = renderWithProviders(<Harness />, { dict })
|
|
161
|
+
await flushMicrotasks()
|
|
162
|
+
|
|
163
|
+
const categoryInput = container.querySelector(
|
|
164
|
+
'[data-crud-field-id="metadata.category"] input[type="text"]',
|
|
165
|
+
) as HTMLInputElement
|
|
166
|
+
const form = container.querySelector('form') as HTMLFormElement
|
|
167
|
+
|
|
168
|
+
await act(async () => {
|
|
169
|
+
fireEvent.change(categoryInput, { target: { value: 'qa-category-2466' } })
|
|
170
|
+
fireEvent.blur(categoryInput)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
await act(async () => {
|
|
174
|
+
fireEvent.submit(form)
|
|
175
|
+
await Promise.resolve()
|
|
176
|
+
await Promise.resolve()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
const submitted = JSON.parse(screen.getByTestId('submitted').textContent || 'null')
|
|
180
|
+
expect(submitted).not.toBeNull()
|
|
181
|
+
// A flat-key schema keeps the flat representation untouched.
|
|
182
|
+
expect(submitted['metadata.category']).toBe('qa-category-2466')
|
|
183
|
+
expect(submitted.metadata).toBeUndefined()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('does not flag a required nested field on blur once the dot-path input is filled', async () => {
|
|
187
|
+
const schema = z.object({
|
|
188
|
+
workflowName: z.string(),
|
|
189
|
+
metadata: z.object({ category: z.string().min(1) }),
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
const { container } = renderWithProviders(
|
|
193
|
+
<CrudForm
|
|
194
|
+
title="Edit"
|
|
195
|
+
fields={dotPathFields}
|
|
196
|
+
schema={schema as any}
|
|
197
|
+
initialValues={{ id: 'wf-1', workflowName: 'Pipeline', metadata: { category: 'Sales' } } as any}
|
|
198
|
+
onSubmit={() => {}}
|
|
199
|
+
/>,
|
|
200
|
+
{ dict },
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
await flushMicrotasks()
|
|
204
|
+
|
|
205
|
+
const categoryInput = container.querySelector(
|
|
206
|
+
'[data-crud-field-id="metadata.category"] input[type="text"]',
|
|
207
|
+
) as HTMLInputElement
|
|
208
|
+
|
|
209
|
+
await act(async () => {
|
|
210
|
+
fireEvent.change(categoryInput, { target: { value: 'qa-category-2466' } })
|
|
211
|
+
fireEvent.blur(categoryInput)
|
|
212
|
+
await Promise.resolve()
|
|
213
|
+
await Promise.resolve()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// Blur validation collapses the flat key before schema.safeParse, so the
|
|
217
|
+
// required nested metadata.category is seen as satisfied — no error surfaces.
|
|
218
|
+
const fieldContainer = container.querySelector('[data-crud-field-id="metadata.category"]') as HTMLElement
|
|
219
|
+
expect(fieldContainer.querySelector('.text-status-error-text')).toBeNull()
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('merges multiple dot-path fields sharing a parent into one nested object', async () => {
|
|
223
|
+
const fields: CrudField[] = [
|
|
224
|
+
{ id: 'workflowName', label: 'Name', type: 'text' },
|
|
225
|
+
{ id: 'metadata.category', label: 'Category', type: 'text' },
|
|
226
|
+
{ id: 'metadata.icon', label: 'Icon', type: 'text' },
|
|
227
|
+
]
|
|
228
|
+
const schema = z.object({
|
|
229
|
+
workflowName: z.string(),
|
|
230
|
+
metadata: z
|
|
231
|
+
.object({ category: z.string().optional(), icon: z.string().optional() })
|
|
232
|
+
.optional(),
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
function Harness() {
|
|
236
|
+
const [submitted, setSubmitted] = React.useState<Record<string, unknown> | null>(null)
|
|
237
|
+
return (
|
|
238
|
+
<>
|
|
239
|
+
<CrudForm
|
|
240
|
+
title="Edit"
|
|
241
|
+
fields={fields}
|
|
242
|
+
schema={schema as any}
|
|
243
|
+
initialValues={{
|
|
244
|
+
id: 'wf-1',
|
|
245
|
+
workflowName: 'Pipeline',
|
|
246
|
+
metadata: { category: 'Sales', icon: 'shopping-cart' },
|
|
247
|
+
} as any}
|
|
248
|
+
onSubmit={(values) => setSubmitted(values as Record<string, unknown>)}
|
|
249
|
+
/>
|
|
250
|
+
<div data-testid="submitted">{JSON.stringify(submitted)}</div>
|
|
251
|
+
</>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const { container } = renderWithProviders(<Harness />, { dict })
|
|
256
|
+
await flushMicrotasks()
|
|
257
|
+
|
|
258
|
+
const form = container.querySelector('form') as HTMLFormElement
|
|
259
|
+
await act(async () => {
|
|
260
|
+
fireEvent.submit(form)
|
|
261
|
+
await Promise.resolve()
|
|
262
|
+
await Promise.resolve()
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
const submitted = JSON.parse(screen.getByTestId('submitted').textContent || 'null')
|
|
266
|
+
expect(submitted.metadata).toEqual({ category: 'Sales', icon: 'shopping-cart' })
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('does not pollute Object.prototype via a prototype-polluting dot-path field id', async () => {
|
|
270
|
+
const maliciousFields: CrudField[] = [
|
|
271
|
+
{ id: 'workflowName', label: 'Name', type: 'text' },
|
|
272
|
+
{ id: '__proto__.polluted', label: 'Evil', type: 'text' },
|
|
273
|
+
]
|
|
274
|
+
const schema = z.object({ workflowName: z.string() }).passthrough()
|
|
275
|
+
|
|
276
|
+
function Harness() {
|
|
277
|
+
const [submitted, setSubmitted] = React.useState<Record<string, unknown> | null>(null)
|
|
278
|
+
return (
|
|
279
|
+
<>
|
|
280
|
+
<CrudForm
|
|
281
|
+
title="Edit"
|
|
282
|
+
fields={maliciousFields}
|
|
283
|
+
schema={schema as any}
|
|
284
|
+
initialValues={{ id: 'wf-1', workflowName: 'Pipeline', '__proto__.polluted': 'pwned' } as any}
|
|
285
|
+
onSubmit={(values) => setSubmitted(values as Record<string, unknown>)}
|
|
286
|
+
/>
|
|
287
|
+
<div data-testid="submitted">{JSON.stringify(submitted)}</div>
|
|
288
|
+
</>
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const { container } = renderWithProviders(<Harness />, { dict })
|
|
293
|
+
await flushMicrotasks()
|
|
294
|
+
|
|
295
|
+
const form = container.querySelector('form') as HTMLFormElement
|
|
296
|
+
await act(async () => {
|
|
297
|
+
fireEvent.submit(form)
|
|
298
|
+
await Promise.resolve()
|
|
299
|
+
await Promise.resolve()
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
// The collapse step must skip prototype-polluting segments entirely, so
|
|
303
|
+
// neither a fresh object nor Object.prototype gains the injected key.
|
|
304
|
+
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
|
|
305
|
+
expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined()
|
|
306
|
+
// The submit still completes for the legitimate fields.
|
|
307
|
+
const submitted = JSON.parse(screen.getByTestId('submitted').textContent || 'null')
|
|
308
|
+
expect(submitted).not.toBeNull()
|
|
309
|
+
expect(submitted.workflowName).toBe('Pipeline')
|
|
310
|
+
})
|
|
311
|
+
})
|
|
@@ -7,7 +7,6 @@ jest.mock('next/navigation', () => ({
|
|
|
7
7
|
useSearchParams: () => new URLSearchParams(),
|
|
8
8
|
}))
|
|
9
9
|
jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
|
|
10
|
-
jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
|
|
11
10
|
jest.mock('../injection/InjectionSpot', () => ({
|
|
12
11
|
__esModule: true,
|
|
13
12
|
InjectionSpot: () => null,
|
|
@@ -9,7 +9,6 @@ jest.mock('next/navigation', () => ({
|
|
|
9
9
|
useSearchParams: () => new URLSearchParams(),
|
|
10
10
|
}))
|
|
11
11
|
jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
|
|
12
|
-
jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
|
|
13
12
|
jest.mock('../confirm-dialog', () => ({
|
|
14
13
|
useConfirmDialog: () => ({
|
|
15
14
|
confirm: confirmDialogMock,
|
|
@@ -62,6 +61,126 @@ describe('CrudForm unsaved navigation guard', () => {
|
|
|
62
61
|
expect(event.returnValue).toBe('')
|
|
63
62
|
})
|
|
64
63
|
|
|
64
|
+
it('marks the form dirty on the first edit even when no baseline has been initialized yet', async () => {
|
|
65
|
+
const onDirtyChange = jest.fn()
|
|
66
|
+
const { container } = renderWithProviders(
|
|
67
|
+
<CrudForm title="Form" fields={fields} onSubmit={() => {}} onDirtyChange={onDirtyChange} />,
|
|
68
|
+
{
|
|
69
|
+
dict: {
|
|
70
|
+
'ui.forms.actions.save': 'Save',
|
|
71
|
+
'ui.forms.confirmUnsavedChanges': 'Unsaved changes',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
const input = container.querySelector('[data-crud-field-id="name"] input[type="text"]') as HTMLInputElement
|
|
77
|
+
await act(async () => {
|
|
78
|
+
fireEvent.change(input, { target: { value: 'Alice updated' } })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
expect(onDirtyChange).toHaveBeenLastCalledWith(true)
|
|
82
|
+
|
|
83
|
+
const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent
|
|
84
|
+
Object.defineProperty(event, 'returnValue', { writable: true, value: undefined })
|
|
85
|
+
window.dispatchEvent(event)
|
|
86
|
+
|
|
87
|
+
expect(event.defaultPrevented).toBe(true)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('keeps a first edit dirty when late initialValues refresh after the user types', async () => {
|
|
91
|
+
const onDirtyChange = jest.fn()
|
|
92
|
+
const view = renderWithProviders(
|
|
93
|
+
<CrudForm
|
|
94
|
+
title="Form"
|
|
95
|
+
fields={fields}
|
|
96
|
+
initialValues={{ name: 'Alice' }}
|
|
97
|
+
embedded
|
|
98
|
+
trackDirtyWhenEmbedded
|
|
99
|
+
onDirtyChange={onDirtyChange}
|
|
100
|
+
onSubmit={() => {}}
|
|
101
|
+
/>,
|
|
102
|
+
{
|
|
103
|
+
dict: {
|
|
104
|
+
'ui.forms.actions.save': 'Save',
|
|
105
|
+
'ui.forms.confirmUnsavedChanges': 'Unsaved changes',
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
const input = view.container.querySelector('[data-crud-field-id="name"] input[type="text"]') as HTMLInputElement
|
|
111
|
+
await act(async () => {
|
|
112
|
+
fireEvent.change(input, { target: { value: 'Alice updated' } })
|
|
113
|
+
})
|
|
114
|
+
expect(onDirtyChange).toHaveBeenLastCalledWith(true)
|
|
115
|
+
|
|
116
|
+
await act(async () => {
|
|
117
|
+
view.rerender(
|
|
118
|
+
<CrudForm
|
|
119
|
+
title="Form"
|
|
120
|
+
fields={fields}
|
|
121
|
+
initialValues={{ name: 'Alice from server' }}
|
|
122
|
+
embedded
|
|
123
|
+
trackDirtyWhenEmbedded
|
|
124
|
+
onDirtyChange={onDirtyChange}
|
|
125
|
+
onSubmit={() => {}}
|
|
126
|
+
/>,
|
|
127
|
+
)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
expect(input).toHaveValue('Alice updated')
|
|
131
|
+
expect(onDirtyChange).toHaveBeenLastCalledWith(true)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('accepts late initialValues refresh after the edited field is reverted to baseline', async () => {
|
|
135
|
+
const onDirtyChange = jest.fn()
|
|
136
|
+
const view = renderWithProviders(
|
|
137
|
+
<CrudForm
|
|
138
|
+
title="Form"
|
|
139
|
+
fields={fields}
|
|
140
|
+
initialValues={{ name: 'Alice' }}
|
|
141
|
+
embedded
|
|
142
|
+
trackDirtyWhenEmbedded
|
|
143
|
+
onDirtyChange={onDirtyChange}
|
|
144
|
+
onSubmit={() => {}}
|
|
145
|
+
/>,
|
|
146
|
+
{
|
|
147
|
+
dict: {
|
|
148
|
+
'ui.forms.actions.save': 'Save',
|
|
149
|
+
'ui.forms.confirmUnsavedChanges': 'Unsaved changes',
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
const input = view.container.querySelector('[data-crud-field-id="name"] input[type="text"]') as HTMLInputElement
|
|
155
|
+
await act(async () => {
|
|
156
|
+
fireEvent.change(input, { target: { value: 'Alice updated' } })
|
|
157
|
+
})
|
|
158
|
+
expect(onDirtyChange).toHaveBeenLastCalledWith(true)
|
|
159
|
+
|
|
160
|
+
await act(async () => {
|
|
161
|
+
fireEvent.change(input, { target: { value: 'Alice' } })
|
|
162
|
+
fireEvent.blur(input)
|
|
163
|
+
})
|
|
164
|
+
expect(onDirtyChange).toHaveBeenLastCalledWith(false)
|
|
165
|
+
|
|
166
|
+
await act(async () => {
|
|
167
|
+
view.rerender(
|
|
168
|
+
<CrudForm
|
|
169
|
+
title="Form"
|
|
170
|
+
fields={fields}
|
|
171
|
+
initialValues={{ name: 'Alice from server' }}
|
|
172
|
+
embedded
|
|
173
|
+
trackDirtyWhenEmbedded
|
|
174
|
+
onDirtyChange={onDirtyChange}
|
|
175
|
+
onSubmit={() => {}}
|
|
176
|
+
/>,
|
|
177
|
+
)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
expect(input).toHaveValue('Alice from server')
|
|
181
|
+
expect(onDirtyChange).toHaveBeenLastCalledWith(false)
|
|
182
|
+
})
|
|
183
|
+
|
|
65
184
|
it('blocks router-driven history pushes until the user confirms leaving', async () => {
|
|
66
185
|
confirmDialogMock.mockResolvedValueOnce(false)
|
|
67
186
|
|