@open-mercato/ui 0.6.4-develop.4371.1.8f3030407e → 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.
Files changed (133) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/AGENTS.md +2 -1
  3. package/dist/backend/AppShell.js +6 -0
  4. package/dist/backend/AppShell.js.map +2 -2
  5. package/dist/backend/CrudForm.js +212 -85
  6. package/dist/backend/CrudForm.js.map +3 -3
  7. package/dist/backend/DataTable.js +77 -26
  8. package/dist/backend/DataTable.js.map +3 -3
  9. package/dist/backend/FlashMessages.js +21 -2
  10. package/dist/backend/FlashMessages.js.map +2 -2
  11. package/dist/backend/JsonBuilder.js +64 -41
  12. package/dist/backend/JsonBuilder.js.map +2 -2
  13. package/dist/backend/PerspectiveSidebar.js +8 -1
  14. package/dist/backend/PerspectiveSidebar.js.map +2 -2
  15. package/dist/backend/RowActions.js +1 -1
  16. package/dist/backend/RowActions.js.map +2 -2
  17. package/dist/backend/conflicts/RecordConflictBanner.js +73 -0
  18. package/dist/backend/conflicts/RecordConflictBanner.js.map +7 -0
  19. package/dist/backend/conflicts/index.js +32 -0
  20. package/dist/backend/conflicts/index.js.map +7 -0
  21. package/dist/backend/conflicts/store.js +50 -0
  22. package/dist/backend/conflicts/store.js.map +7 -0
  23. package/dist/backend/detail/ActivitiesSection.js +1 -0
  24. package/dist/backend/detail/ActivitiesSection.js.map +2 -2
  25. package/dist/backend/detail/InlineEditors.js +2 -51
  26. package/dist/backend/detail/InlineEditors.js.map +3 -3
  27. package/dist/backend/detail/RecordNotFoundState.js +10 -5
  28. package/dist/backend/detail/RecordNotFoundState.js.map +2 -2
  29. package/dist/backend/filters/AdvancedFilterPanel.js +14 -0
  30. package/dist/backend/filters/AdvancedFilterPanel.js.map +2 -2
  31. package/dist/backend/filters/FilteredEmptyResults.js +12 -4
  32. package/dist/backend/filters/FilteredEmptyResults.js.map +2 -2
  33. package/dist/backend/filters/ListEmptyState.js +44 -0
  34. package/dist/backend/filters/ListEmptyState.js.map +7 -0
  35. package/dist/backend/filters/SearchEmptyResults.js +28 -0
  36. package/dist/backend/filters/SearchEmptyResults.js.map +7 -0
  37. package/dist/backend/icons/lucideRegistry.generated.js +8 -0
  38. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  39. package/dist/backend/injection/useGuardedMutation.js +8 -1
  40. package/dist/backend/injection/useGuardedMutation.js.map +2 -2
  41. package/dist/backend/inputs/MarkdownField.js +20 -0
  42. package/dist/backend/inputs/MarkdownField.js.map +7 -0
  43. package/dist/backend/inputs/MdxEditorImpl.js +98 -0
  44. package/dist/backend/inputs/MdxEditorImpl.js.map +7 -0
  45. package/dist/backend/inputs/SwitchableMarkdownInput.js +6 -38
  46. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
  47. package/dist/backend/markdown/markdownToPlainText.js +29 -0
  48. package/dist/backend/markdown/markdownToPlainText.js.map +2 -2
  49. package/dist/backend/messages/EmailThreadsPanel.js +212 -0
  50. package/dist/backend/messages/EmailThreadsPanel.js.map +7 -0
  51. package/dist/backend/messages/index.js +4 -0
  52. package/dist/backend/messages/index.js.map +2 -2
  53. package/dist/backend/messages/mergeOptimisticEmailThreads.js +55 -0
  54. package/dist/backend/messages/mergeOptimisticEmailThreads.js.map +7 -0
  55. package/dist/backend/notifications/NotificationDispatcher.js +1 -0
  56. package/dist/backend/notifications/NotificationDispatcher.js.map +2 -2
  57. package/dist/backend/notifications/useNotificationsPoll.js +7 -0
  58. package/dist/backend/notifications/useNotificationsPoll.js.map +2 -2
  59. package/dist/backend/notifications/useNotificationsSse.js +8 -0
  60. package/dist/backend/notifications/useNotificationsSse.js.map +2 -2
  61. package/dist/backend/sidebar/SidebarCustomizationEditor.js +23 -7
  62. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  63. package/dist/backend/utils/optimisticLock.js +34 -0
  64. package/dist/backend/utils/optimisticLock.js.map +7 -0
  65. package/dist/portal/hooks/useTenantContext.js +1 -1
  66. package/dist/portal/hooks/useTenantContext.js.map +2 -2
  67. package/dist/primitives/alert.js.map +1 -1
  68. package/dist/primitives/checkbox.js +2 -1
  69. package/dist/primitives/checkbox.js.map +2 -2
  70. package/dist/types/css.d.js +1 -0
  71. package/dist/types/css.d.js.map +7 -0
  72. package/package.json +17 -13
  73. package/src/backend/AppShell.tsx +10 -0
  74. package/src/backend/CrudForm.tsx +320 -97
  75. package/src/backend/DataTable.tsx +113 -34
  76. package/src/backend/FlashMessages.tsx +31 -2
  77. package/src/backend/JsonBuilder.tsx +79 -26
  78. package/src/backend/PerspectiveSidebar.tsx +8 -1
  79. package/src/backend/RowActions.tsx +1 -1
  80. package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +0 -1
  81. package/src/backend/__tests__/CrudForm.datetime.test.tsx +0 -1
  82. package/src/backend/__tests__/CrudForm.defaults.test.tsx +0 -1
  83. package/src/backend/__tests__/CrudForm.dotPath.test.tsx +311 -0
  84. package/src/backend/__tests__/CrudForm.doubleSubmit.test.tsx +0 -1
  85. package/src/backend/__tests__/CrudForm.navigation.test.tsx +120 -1
  86. package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +287 -0
  87. package/src/backend/__tests__/CrudForm.render.test.tsx +74 -2
  88. package/src/backend/__tests__/CrudForm.sortable.test.tsx +0 -1
  89. package/src/backend/__tests__/CrudForm.transformData.test.tsx +0 -1
  90. package/src/backend/__tests__/CrudForm.unsavedNavigation.test.tsx +51 -3
  91. package/src/backend/__tests__/CrudForm.validation.test.tsx +0 -1
  92. package/src/backend/__tests__/CustomDataSection.test.tsx +0 -1
  93. package/src/backend/__tests__/FlashMessages.test.tsx +47 -0
  94. package/src/backend/__tests__/JsonBuilder.test.tsx +135 -0
  95. package/src/backend/__tests__/lazy-heavy-libraries.test.ts +16 -3
  96. package/src/backend/conflicts/RecordConflictBanner.tsx +77 -0
  97. package/src/backend/conflicts/__tests__/RecordConflictBanner.test.tsx +61 -0
  98. package/src/backend/conflicts/__tests__/store.test.ts +78 -0
  99. package/src/backend/conflicts/index.ts +49 -0
  100. package/src/backend/conflicts/store.ts +94 -0
  101. package/src/backend/detail/ActivitiesSection.tsx +1 -0
  102. package/src/backend/detail/InlineEditors.tsx +14 -76
  103. package/src/backend/detail/RecordNotFoundState.tsx +13 -6
  104. package/src/backend/detail/__tests__/RecordNotFoundState.test.tsx +80 -0
  105. package/src/backend/filters/AdvancedFilterPanel.tsx +27 -0
  106. package/src/backend/filters/FilteredEmptyResults.tsx +23 -10
  107. package/src/backend/filters/ListEmptyState.tsx +69 -0
  108. package/src/backend/filters/SearchEmptyResults.tsx +42 -0
  109. package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +98 -0
  110. package/src/backend/filters/__tests__/ListEmptyState.test.tsx +53 -0
  111. package/src/backend/icons/lucideRegistry.generated.tsx +8 -0
  112. package/src/backend/injection/__tests__/useGuardedMutation.optimisticLock.test.tsx +118 -0
  113. package/src/backend/injection/useGuardedMutation.ts +16 -1
  114. package/src/backend/inputs/MarkdownField.tsx +34 -0
  115. package/src/backend/inputs/MdxEditorImpl.tsx +113 -0
  116. package/src/backend/inputs/SwitchableMarkdownInput.tsx +5 -54
  117. package/src/backend/markdown/__tests__/markdownToPlainText.test.ts +13 -0
  118. package/src/backend/markdown/markdownToPlainText.ts +41 -0
  119. package/src/backend/messages/EmailThreadsPanel.tsx +351 -0
  120. package/src/backend/messages/__tests__/mergeOptimisticEmailThreads.test.ts +126 -0
  121. package/src/backend/messages/index.ts +12 -0
  122. package/src/backend/messages/mergeOptimisticEmailThreads.ts +84 -0
  123. package/src/backend/notifications/NotificationDispatcher.ts +2 -0
  124. package/src/backend/notifications/useNotificationsPoll.ts +5 -0
  125. package/src/backend/notifications/useNotificationsSse.ts +6 -0
  126. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +35 -11
  127. package/src/backend/utils/__tests__/optimisticLock.test.ts +126 -0
  128. package/src/backend/utils/optimisticLock.ts +70 -0
  129. package/src/portal/hooks/useTenantContext.ts +7 -2
  130. package/src/primitives/__tests__/checkbox.test.tsx +28 -0
  131. package/src/primitives/alert.tsx +1 -1
  132. package/src/primitives/checkbox.tsx +2 -1
  133. 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