@open-mercato/ui 0.6.8-develop.6930.1.1e5976efc3 → 0.6.8-develop.6940.1.177ea30c6e
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/dist/backend/CrudForm.js +177 -93
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/injection/InjectionSpot.js +13 -4
- package/dist/backend/injection/InjectionSpot.js.map +2 -2
- package/dist/backend/injection/useRegisteredComponent.js +48 -36
- package/dist/backend/injection/useRegisteredComponent.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +216 -103
- package/src/backend/__tests__/CrudForm.asyncInjectionTransform.test.tsx +555 -0
- package/src/backend/injection/InjectionSpot.tsx +13 -5
- package/src/backend/injection/__tests__/InjectionSpot.features.test.tsx +26 -0
- package/src/backend/injection/__tests__/useRegisteredComponent.remount.test.tsx +96 -0
- package/src/backend/injection/useRegisteredComponent.tsx +85 -38
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
const pushMock = jest.fn()
|
|
3
|
+
const triggerEventMock = jest.fn()
|
|
4
|
+
const confirmDialogMock = jest.fn()
|
|
5
|
+
const flashMock = jest.fn()
|
|
6
|
+
const mockInjectedDataWidgets: unknown[] = []
|
|
7
|
+
let injectionWidgets: Array<{ moduleId: string; widgetId: string; key: string }> = []
|
|
8
|
+
let suspendedDisplayTitle: string | null = null
|
|
9
|
+
const displaySuspension = new Promise<never>(() => {})
|
|
10
|
+
|
|
11
|
+
jest.mock('next/navigation', () => ({
|
|
12
|
+
useRouter: () => ({ push: pushMock }),
|
|
13
|
+
usePathname: () => '/',
|
|
14
|
+
useSearchParams: () => new URLSearchParams(),
|
|
15
|
+
}))
|
|
16
|
+
jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
|
|
17
|
+
jest.mock('../confirm-dialog', () => ({
|
|
18
|
+
useConfirmDialog: () => ({ confirm: confirmDialogMock, ConfirmDialogElement: null }),
|
|
19
|
+
}))
|
|
20
|
+
jest.mock('../FlashMessages', () => ({
|
|
21
|
+
flash: (...args: unknown[]) => flashMock(...args),
|
|
22
|
+
}))
|
|
23
|
+
jest.mock('../injection/InjectionSpot', () => ({
|
|
24
|
+
__esModule: true,
|
|
25
|
+
InjectionSpot: ({ data }: { data?: Record<string, unknown> }) => {
|
|
26
|
+
if (suspendedDisplayTitle !== null && data?.title === suspendedDisplayTitle) {
|
|
27
|
+
throw displaySuspension
|
|
28
|
+
}
|
|
29
|
+
return null
|
|
30
|
+
},
|
|
31
|
+
useInjectionWidgets: () => ({ widgets: injectionWidgets, loading: false, error: null }),
|
|
32
|
+
useInjectionSpotEvents: () => ({ triggerEvent: triggerEventMock }),
|
|
33
|
+
}))
|
|
34
|
+
jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
35
|
+
__esModule: true,
|
|
36
|
+
useInjectionDataWidgets: () => ({ widgets: mockInjectedDataWidgets, isLoading: false, error: null }),
|
|
37
|
+
}))
|
|
38
|
+
|
|
39
|
+
import * as React from 'react'
|
|
40
|
+
import { act, fireEvent, waitFor } from '@testing-library/react'
|
|
41
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
42
|
+
import { CrudForm, type CrudField } from '../CrudForm'
|
|
43
|
+
|
|
44
|
+
describe('CrudForm asynchronous injection display transforms', () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
injectionWidgets = []
|
|
47
|
+
suspendedDisplayTitle = null
|
|
48
|
+
confirmDialogMock.mockReset()
|
|
49
|
+
confirmDialogMock.mockResolvedValue(false)
|
|
50
|
+
flashMock.mockReset()
|
|
51
|
+
triggerEventMock.mockReset()
|
|
52
|
+
triggerEventMock.mockImplementation(async (event: string, data: Record<string, unknown>) => ({
|
|
53
|
+
data: event === 'transformDisplayData' && injectionWidgets.length > 0
|
|
54
|
+
? { ...data, title: String(data.title).toUpperCase() }
|
|
55
|
+
: data,
|
|
56
|
+
ok: true,
|
|
57
|
+
}))
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('reapplies initial display data when an injection widget loads after the record', async () => {
|
|
61
|
+
const fields: CrudField[] = [{ id: 'title', label: 'Title', type: 'text' }]
|
|
62
|
+
const renderForm = () => (
|
|
63
|
+
<CrudForm
|
|
64
|
+
title="Form"
|
|
65
|
+
fields={fields}
|
|
66
|
+
initialValues={{ title: 'display me' }}
|
|
67
|
+
injectionSpotId="example:async-widget"
|
|
68
|
+
onSubmit={() => {}}
|
|
69
|
+
/>
|
|
70
|
+
)
|
|
71
|
+
const rendered = renderWithProviders(renderForm(), {
|
|
72
|
+
dict: { 'ui.forms.actions.save': 'Save' },
|
|
73
|
+
})
|
|
74
|
+
const input = rendered.container.querySelector('[data-crud-field-id="title"] input') as HTMLInputElement
|
|
75
|
+
|
|
76
|
+
await act(async () => {
|
|
77
|
+
await Promise.resolve()
|
|
78
|
+
})
|
|
79
|
+
expect(input).toHaveValue('display me')
|
|
80
|
+
|
|
81
|
+
injectionWidgets = [{ moduleId: 'example', widgetId: 'example.async-widget', key: 'example:async-widget' }]
|
|
82
|
+
rendered.rerender(renderForm())
|
|
83
|
+
await waitFor(() => {
|
|
84
|
+
expect(triggerEventMock.mock.calls.filter(([event]) => event === 'transformDisplayData')).toHaveLength(2)
|
|
85
|
+
})
|
|
86
|
+
await waitFor(() => {
|
|
87
|
+
expect(triggerEventMock).toHaveBeenCalledWith(
|
|
88
|
+
'transformDisplayData',
|
|
89
|
+
expect.objectContaining({ title: 'display me' }),
|
|
90
|
+
expect.anything(),
|
|
91
|
+
)
|
|
92
|
+
expect(input).toHaveValue('DISPLAY ME')
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('does not commit a dirty baseline from an interrupted display transform', async () => {
|
|
97
|
+
let resolveTransform: ((result: { ok: true; data: Record<string, unknown> }) => void) | null = null
|
|
98
|
+
injectionWidgets = [{ moduleId: 'example', widgetId: 'example.async-widget', key: 'example:async-widget' }]
|
|
99
|
+
triggerEventMock.mockImplementation((event: string, data: Record<string, unknown>) => {
|
|
100
|
+
if (event !== 'transformDisplayData') return Promise.resolve({ ok: true, data })
|
|
101
|
+
return new Promise<{ ok: true; data: Record<string, unknown> }>((resolve) => {
|
|
102
|
+
resolveTransform = resolve
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
const fields: CrudField[] = [{ id: 'title', label: 'Title', type: 'text' }]
|
|
106
|
+
const rendered = renderWithProviders(
|
|
107
|
+
<React.Suspense fallback={<div data-testid="display-transform-suspended" />}>
|
|
108
|
+
<CrudForm
|
|
109
|
+
title="Form"
|
|
110
|
+
fields={fields}
|
|
111
|
+
initialValues={{ title: 'display me' }}
|
|
112
|
+
injectionSpotId="example:async-widget"
|
|
113
|
+
onSubmit={() => {}}
|
|
114
|
+
/>
|
|
115
|
+
</React.Suspense>,
|
|
116
|
+
{
|
|
117
|
+
dict: {
|
|
118
|
+
'ui.forms.actions.save': 'Save',
|
|
119
|
+
'ui.forms.confirmUnsavedChanges': 'Unsaved changes',
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
const input = rendered.container.querySelector('[data-crud-field-id="title"] input') as HTMLInputElement
|
|
124
|
+
|
|
125
|
+
await waitFor(() => expect(resolveTransform).not.toBeNull())
|
|
126
|
+
suspendedDisplayTitle = 'DISPLAY ME'
|
|
127
|
+
await act(async () => {
|
|
128
|
+
resolveTransform?.({ ok: true, data: { title: 'DISPLAY ME' } })
|
|
129
|
+
await Promise.resolve()
|
|
130
|
+
})
|
|
131
|
+
await waitFor(() => expect(rendered.getByTestId('display-transform-suspended')).toBeInTheDocument())
|
|
132
|
+
|
|
133
|
+
suspendedDisplayTitle = null
|
|
134
|
+
await act(async () => {
|
|
135
|
+
fireEvent.change(input, { target: { value: 'DISPLAY ME' } })
|
|
136
|
+
await Promise.resolve()
|
|
137
|
+
})
|
|
138
|
+
await waitFor(() => expect(input).toHaveValue('DISPLAY ME'))
|
|
139
|
+
|
|
140
|
+
const anchor = document.createElement('a')
|
|
141
|
+
anchor.href = '/other'
|
|
142
|
+
document.body.appendChild(anchor)
|
|
143
|
+
const navigationEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
|
|
144
|
+
await act(async () => {
|
|
145
|
+
anchor.dispatchEvent(navigationEvent)
|
|
146
|
+
await Promise.resolve()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
expect(confirmDialogMock).toHaveBeenCalled()
|
|
150
|
+
expect(navigationEvent.defaultPrevented).toBe(true)
|
|
151
|
+
anchor.remove()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('composes an async widget side effect with a user edit queued in the same batch', async () => {
|
|
155
|
+
const noteEventPayloads: Record<string, unknown>[] = []
|
|
156
|
+
let resolveFieldChange: ((result: {
|
|
157
|
+
ok: true
|
|
158
|
+
fieldChange: { sideEffects: Record<string, unknown> }
|
|
159
|
+
}) => void) | null = null
|
|
160
|
+
triggerEventMock.mockImplementation((event: string, data: Record<string, unknown>, _context: unknown, meta?: { fieldId?: string }) => {
|
|
161
|
+
if (event === 'onFieldChange' && meta?.fieldId === 'title') {
|
|
162
|
+
return new Promise((resolve) => {
|
|
163
|
+
resolveFieldChange = resolve
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
if (event === 'onFieldChange' && meta?.fieldId === 'note') {
|
|
167
|
+
noteEventPayloads.push(data)
|
|
168
|
+
}
|
|
169
|
+
return Promise.resolve({ ok: true, data })
|
|
170
|
+
})
|
|
171
|
+
const fields: CrudField[] = [
|
|
172
|
+
{ id: 'title', label: 'Title', type: 'text' },
|
|
173
|
+
{ id: 'widgetValue', label: 'Widget value', type: 'text' },
|
|
174
|
+
{ id: 'note', label: 'Note', type: 'text' },
|
|
175
|
+
]
|
|
176
|
+
const rendered = renderWithProviders(
|
|
177
|
+
<CrudForm
|
|
178
|
+
title="Form"
|
|
179
|
+
fields={fields}
|
|
180
|
+
initialValues={{ title: 'Original', widgetValue: '', note: '' }}
|
|
181
|
+
injectionSpotId="example:async-widget"
|
|
182
|
+
onSubmit={() => {}}
|
|
183
|
+
/>,
|
|
184
|
+
{ dict: { 'ui.forms.actions.save': 'Save' } },
|
|
185
|
+
)
|
|
186
|
+
const titleInput = rendered.container.querySelector('[data-crud-field-id="title"] input') as HTMLInputElement
|
|
187
|
+
const widgetInput = rendered.container.querySelector('[data-crud-field-id="widgetValue"] input') as HTMLInputElement
|
|
188
|
+
const noteInput = rendered.container.querySelector('[data-crud-field-id="note"] input') as HTMLInputElement
|
|
189
|
+
|
|
190
|
+
fireEvent.change(titleInput, { target: { value: 'Changed' } })
|
|
191
|
+
await waitFor(() => expect(resolveFieldChange).not.toBeNull())
|
|
192
|
+
|
|
193
|
+
await act(async () => {
|
|
194
|
+
resolveFieldChange?.({
|
|
195
|
+
ok: true,
|
|
196
|
+
fieldChange: { sideEffects: { widgetValue: 'from widget' } },
|
|
197
|
+
})
|
|
198
|
+
await Promise.resolve()
|
|
199
|
+
fireEvent.change(noteInput, { target: { value: 'from user' } })
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
expect(widgetInput).toHaveValue('from widget')
|
|
203
|
+
expect(noteInput).toHaveValue('from user')
|
|
204
|
+
await waitFor(() => {
|
|
205
|
+
expect(noteEventPayloads).toContainEqual(expect.objectContaining({
|
|
206
|
+
widgetValue: 'from widget',
|
|
207
|
+
note: 'from user',
|
|
208
|
+
}))
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('drops a slow field result after a newer revision and dispatches the newer event with matching data', async () => {
|
|
213
|
+
let resolveFirstChange: ((result: {
|
|
214
|
+
ok: true
|
|
215
|
+
fieldChange: { value: string }
|
|
216
|
+
}) => void) | null = null
|
|
217
|
+
const secondChangePayloads: Array<{
|
|
218
|
+
data: Record<string, unknown>
|
|
219
|
+
fieldValue: unknown
|
|
220
|
+
}> = []
|
|
221
|
+
triggerEventMock.mockImplementation((event: string, data: Record<string, unknown>, _context: unknown, meta?: {
|
|
222
|
+
fieldId?: string
|
|
223
|
+
fieldValue?: unknown
|
|
224
|
+
}) => {
|
|
225
|
+
if (event !== 'onFieldChange') return Promise.resolve({ ok: true, data })
|
|
226
|
+
if (meta?.fieldValue === 'A') {
|
|
227
|
+
return new Promise((resolve) => {
|
|
228
|
+
resolveFirstChange = resolve
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
if (meta?.fieldValue === 'B') {
|
|
232
|
+
secondChangePayloads.push({ data, fieldValue: meta.fieldValue })
|
|
233
|
+
return Promise.resolve({ ok: true, fieldChange: { value: 'B' } })
|
|
234
|
+
}
|
|
235
|
+
return Promise.resolve({ ok: true })
|
|
236
|
+
})
|
|
237
|
+
const rendered = renderWithProviders(
|
|
238
|
+
<CrudForm
|
|
239
|
+
title="Form"
|
|
240
|
+
fields={[{ id: 'title', label: 'Title', type: 'text' }]}
|
|
241
|
+
initialValues={{ title: 'Original' }}
|
|
242
|
+
injectionSpotId="example:async-widget"
|
|
243
|
+
onSubmit={() => {}}
|
|
244
|
+
/>,
|
|
245
|
+
{ dict: { 'ui.forms.actions.save': 'Save' } },
|
|
246
|
+
)
|
|
247
|
+
const titleInput = rendered.container.querySelector('[data-crud-field-id="title"] input') as HTMLInputElement
|
|
248
|
+
|
|
249
|
+
fireEvent.change(titleInput, { target: { value: 'A' } })
|
|
250
|
+
await waitFor(() => expect(resolveFirstChange).not.toBeNull())
|
|
251
|
+
fireEvent.change(titleInput, { target: { value: 'B' } })
|
|
252
|
+
expect(titleInput).toHaveValue('B')
|
|
253
|
+
|
|
254
|
+
await act(async () => {
|
|
255
|
+
resolveFirstChange?.({ ok: true, fieldChange: { value: 'A' } })
|
|
256
|
+
await Promise.resolve()
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
await waitFor(() => {
|
|
260
|
+
expect(secondChangePayloads).toContainEqual({
|
|
261
|
+
data: expect.objectContaining({ title: 'B' }),
|
|
262
|
+
fieldValue: 'B',
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
expect(titleInput).toHaveValue('B')
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('does not apply a slow field side effect to a target edited after dispatch', async () => {
|
|
269
|
+
let resolveTitleChange: ((result: {
|
|
270
|
+
ok: true
|
|
271
|
+
fieldChange: { value: string; sideEffects: Record<string, unknown> }
|
|
272
|
+
}) => void) | null = null
|
|
273
|
+
let resolveNoteChange: ((result: {
|
|
274
|
+
ok: true
|
|
275
|
+
fieldChange: { value: string }
|
|
276
|
+
}) => void) | null = null
|
|
277
|
+
const noteEventPayloads: Record<string, unknown>[] = []
|
|
278
|
+
triggerEventMock.mockImplementation((event: string, data: Record<string, unknown>, _context: unknown, meta?: {
|
|
279
|
+
fieldId?: string
|
|
280
|
+
}) => {
|
|
281
|
+
if (event !== 'onFieldChange') return Promise.resolve({ ok: true, data })
|
|
282
|
+
if (meta?.fieldId === 'title') {
|
|
283
|
+
return new Promise((resolve) => {
|
|
284
|
+
resolveTitleChange = resolve
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
if (meta?.fieldId === 'note') {
|
|
288
|
+
noteEventPayloads.push(data)
|
|
289
|
+
return new Promise((resolve) => {
|
|
290
|
+
resolveNoteChange = resolve
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
return Promise.resolve({ ok: true })
|
|
294
|
+
})
|
|
295
|
+
const rendered = renderWithProviders(
|
|
296
|
+
<CrudForm
|
|
297
|
+
title="Form"
|
|
298
|
+
fields={[
|
|
299
|
+
{ id: 'title', label: 'Title', type: 'text' },
|
|
300
|
+
{ id: 'note', label: 'Note', type: 'text' },
|
|
301
|
+
]}
|
|
302
|
+
initialValues={{ title: 'Original', note: '' }}
|
|
303
|
+
injectionSpotId="example:async-widget"
|
|
304
|
+
onSubmit={() => {}}
|
|
305
|
+
/>,
|
|
306
|
+
{ dict: { 'ui.forms.actions.save': 'Save' } },
|
|
307
|
+
)
|
|
308
|
+
const getTitleInput = () => rendered.container.querySelector('[data-crud-field-id="title"] input') as HTMLInputElement
|
|
309
|
+
const getNoteInput = () => rendered.container.querySelector('[data-crud-field-id="note"] input') as HTMLInputElement
|
|
310
|
+
|
|
311
|
+
fireEvent.change(getTitleInput(), { target: { value: 'Changed title' } })
|
|
312
|
+
await waitFor(() => expect(resolveTitleChange).not.toBeNull())
|
|
313
|
+
fireEvent.change(getNoteInput(), { target: { value: 'User note' } })
|
|
314
|
+
|
|
315
|
+
await act(async () => {
|
|
316
|
+
resolveTitleChange?.({
|
|
317
|
+
ok: true,
|
|
318
|
+
fieldChange: {
|
|
319
|
+
value: 'Normalized title',
|
|
320
|
+
sideEffects: { note: 'Stale widget note' },
|
|
321
|
+
},
|
|
322
|
+
})
|
|
323
|
+
await Promise.resolve()
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
await waitFor(() => expect(resolveNoteChange).not.toBeNull())
|
|
327
|
+
expect(getNoteInput()).toHaveValue('User note')
|
|
328
|
+
expect(noteEventPayloads).toContainEqual(expect.objectContaining({
|
|
329
|
+
title: 'Normalized title',
|
|
330
|
+
note: 'User note',
|
|
331
|
+
}))
|
|
332
|
+
|
|
333
|
+
await act(async () => {
|
|
334
|
+
resolveNoteChange?.({ ok: true, fieldChange: { value: 'User note' } })
|
|
335
|
+
await Promise.resolve()
|
|
336
|
+
})
|
|
337
|
+
expect(getNoteInput()).toHaveValue('User note')
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('does not apply a queued field side effect to a target edited before dispatch', async () => {
|
|
341
|
+
let resolveBlockingChange: ((result: {
|
|
342
|
+
ok: true
|
|
343
|
+
fieldChange: { value: string }
|
|
344
|
+
}) => void) | null = null
|
|
345
|
+
let resolveQueuedTitleChange: ((result: {
|
|
346
|
+
ok: true
|
|
347
|
+
fieldChange: { value: string; sideEffects: Record<string, unknown> }
|
|
348
|
+
}) => void) | null = null
|
|
349
|
+
const queuedTitlePayloads: Record<string, unknown>[] = []
|
|
350
|
+
const notePayloads: Record<string, unknown>[] = []
|
|
351
|
+
triggerEventMock.mockImplementation((event: string, data: Record<string, unknown>, _context: unknown, meta?: {
|
|
352
|
+
fieldId?: string
|
|
353
|
+
fieldValue?: unknown
|
|
354
|
+
}) => {
|
|
355
|
+
if (event !== 'onFieldChange') return Promise.resolve({ ok: true, data })
|
|
356
|
+
if (meta?.fieldId === 'blocker') {
|
|
357
|
+
return new Promise((resolve) => {
|
|
358
|
+
resolveBlockingChange = resolve
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
if (meta?.fieldId === 'title') {
|
|
362
|
+
queuedTitlePayloads.push(data)
|
|
363
|
+
return new Promise((resolve) => {
|
|
364
|
+
resolveQueuedTitleChange = resolve
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
if (meta?.fieldId === 'note') {
|
|
368
|
+
notePayloads.push(data)
|
|
369
|
+
return Promise.resolve({ ok: true, fieldChange: { value: meta.fieldValue } })
|
|
370
|
+
}
|
|
371
|
+
return Promise.resolve({ ok: true })
|
|
372
|
+
})
|
|
373
|
+
const rendered = renderWithProviders(
|
|
374
|
+
<CrudForm
|
|
375
|
+
title="Form"
|
|
376
|
+
fields={[
|
|
377
|
+
{ id: 'blocker', label: 'Blocker', type: 'text' },
|
|
378
|
+
{ id: 'title', label: 'Title', type: 'text' },
|
|
379
|
+
{ id: 'note', label: 'Note', type: 'text' },
|
|
380
|
+
]}
|
|
381
|
+
initialValues={{ blocker: '', title: '', note: '' }}
|
|
382
|
+
injectionSpotId="example:async-widget"
|
|
383
|
+
onSubmit={() => {}}
|
|
384
|
+
/>,
|
|
385
|
+
{ dict: { 'ui.forms.actions.save': 'Save' } },
|
|
386
|
+
)
|
|
387
|
+
const getInput = (fieldId: string) => rendered.container.querySelector(
|
|
388
|
+
`[data-crud-field-id="${fieldId}"] input`,
|
|
389
|
+
) as HTMLInputElement
|
|
390
|
+
|
|
391
|
+
fireEvent.change(getInput('blocker'), { target: { value: 'X' } })
|
|
392
|
+
await waitFor(() => expect(resolveBlockingChange).not.toBeNull())
|
|
393
|
+
fireEvent.change(getInput('title'), { target: { value: 'A' } })
|
|
394
|
+
fireEvent.change(getInput('note'), { target: { value: 'User note' } })
|
|
395
|
+
|
|
396
|
+
await act(async () => {
|
|
397
|
+
resolveBlockingChange?.({ ok: true, fieldChange: { value: 'X' } })
|
|
398
|
+
await Promise.resolve()
|
|
399
|
+
})
|
|
400
|
+
await waitFor(() => expect(resolveQueuedTitleChange).not.toBeNull())
|
|
401
|
+
expect(queuedTitlePayloads).toContainEqual(expect.objectContaining({
|
|
402
|
+
title: 'A',
|
|
403
|
+
note: 'User note',
|
|
404
|
+
}))
|
|
405
|
+
|
|
406
|
+
await act(async () => {
|
|
407
|
+
resolveQueuedTitleChange?.({
|
|
408
|
+
ok: true,
|
|
409
|
+
fieldChange: {
|
|
410
|
+
value: 'A normalized',
|
|
411
|
+
sideEffects: { note: 'Stale queued note' },
|
|
412
|
+
},
|
|
413
|
+
})
|
|
414
|
+
await Promise.resolve()
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
await waitFor(() => expect(notePayloads).toHaveLength(1))
|
|
418
|
+
expect(getInput('note')).toHaveValue('User note')
|
|
419
|
+
expect(notePayloads).toContainEqual(expect.objectContaining({
|
|
420
|
+
title: 'A normalized',
|
|
421
|
+
note: 'User note',
|
|
422
|
+
}))
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('invalidates queued and in-flight field events when the initial record generation changes', async () => {
|
|
426
|
+
let resolveOldChange: ((result: {
|
|
427
|
+
ok: true
|
|
428
|
+
fieldChange: {
|
|
429
|
+
value: string
|
|
430
|
+
sideEffects: Record<string, unknown>
|
|
431
|
+
messages: Array<{ text: string; severity: 'error' }>
|
|
432
|
+
}
|
|
433
|
+
}) => void) | null = null
|
|
434
|
+
let queuedOldChangeCalls = 0
|
|
435
|
+
const freshChangePayloads: Record<string, unknown>[] = []
|
|
436
|
+
triggerEventMock.mockImplementation((event: string, data: Record<string, unknown>, _context: unknown, meta?: {
|
|
437
|
+
fieldValue?: unknown
|
|
438
|
+
}) => {
|
|
439
|
+
if (event !== 'onFieldChange') return Promise.resolve({ ok: true, data })
|
|
440
|
+
if (meta?.fieldValue === 'old-active') {
|
|
441
|
+
return new Promise((resolve) => {
|
|
442
|
+
resolveOldChange = resolve
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
if (meta?.fieldValue === 'old-queued') {
|
|
446
|
+
queuedOldChangeCalls += 1
|
|
447
|
+
return Promise.resolve({ ok: true })
|
|
448
|
+
}
|
|
449
|
+
if (meta?.fieldValue === 'fresh') {
|
|
450
|
+
freshChangePayloads.push(data)
|
|
451
|
+
return Promise.resolve({
|
|
452
|
+
ok: true,
|
|
453
|
+
fieldChange: {
|
|
454
|
+
sideEffects: { marker: 'fresh marker' },
|
|
455
|
+
messages: [{ text: 'fresh message', severity: 'info' }],
|
|
456
|
+
},
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
return Promise.resolve({ ok: true })
|
|
460
|
+
})
|
|
461
|
+
const fields: CrudField[] = [
|
|
462
|
+
{ id: 'title', label: 'Title', type: 'text' },
|
|
463
|
+
{ id: 'note', label: 'Note', type: 'text' },
|
|
464
|
+
{ id: 'marker', label: 'Marker', type: 'text' },
|
|
465
|
+
]
|
|
466
|
+
const renderForm = (initialValues: Record<string, unknown>) => (
|
|
467
|
+
<CrudForm
|
|
468
|
+
title="Form"
|
|
469
|
+
fields={fields}
|
|
470
|
+
initialValues={initialValues}
|
|
471
|
+
injectionSpotId="example:async-widget"
|
|
472
|
+
onSubmit={() => {}}
|
|
473
|
+
/>
|
|
474
|
+
)
|
|
475
|
+
const rendered = renderWithProviders(
|
|
476
|
+
renderForm({ id: 'record-1', title: 'First', note: '', marker: 'first marker' }),
|
|
477
|
+
{ dict: { 'ui.forms.actions.save': 'Save' } },
|
|
478
|
+
)
|
|
479
|
+
const titleInput = rendered.container.querySelector('[data-crud-field-id="title"] input') as HTMLInputElement
|
|
480
|
+
const noteInput = rendered.container.querySelector('[data-crud-field-id="note"] input') as HTMLInputElement
|
|
481
|
+
const markerInput = rendered.container.querySelector('[data-crud-field-id="marker"] input') as HTMLInputElement
|
|
482
|
+
|
|
483
|
+
fireEvent.change(titleInput, { target: { value: 'old-active' } })
|
|
484
|
+
await waitFor(() => expect(resolveOldChange).not.toBeNull())
|
|
485
|
+
fireEvent.change(noteInput, { target: { value: 'old-queued' } })
|
|
486
|
+
|
|
487
|
+
rendered.rerender(renderForm({ id: 'record-2', title: 'Second', note: '', marker: 'new marker' }))
|
|
488
|
+
await waitFor(() => expect(markerInput).toHaveValue('new marker'))
|
|
489
|
+
|
|
490
|
+
fireEvent.change(noteInput, { target: { value: 'fresh' } })
|
|
491
|
+
await waitFor(() => expect(freshChangePayloads).toHaveLength(1))
|
|
492
|
+
await waitFor(() => expect(markerInput).toHaveValue('fresh marker'))
|
|
493
|
+
|
|
494
|
+
await act(async () => {
|
|
495
|
+
resolveOldChange?.({
|
|
496
|
+
ok: true,
|
|
497
|
+
fieldChange: {
|
|
498
|
+
value: 'stale result',
|
|
499
|
+
sideEffects: { marker: 'stale marker' },
|
|
500
|
+
messages: [{ text: 'stale message', severity: 'error' }],
|
|
501
|
+
},
|
|
502
|
+
})
|
|
503
|
+
await Promise.resolve()
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
expect(queuedOldChangeCalls).toBe(0)
|
|
507
|
+
expect(markerInput).toHaveValue('fresh marker')
|
|
508
|
+
expect(flashMock).toHaveBeenCalledWith('fresh message', 'info')
|
|
509
|
+
expect(flashMock).not.toHaveBeenCalledWith('stale message', 'error')
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
it('ignores an in-flight field result after unmount', async () => {
|
|
513
|
+
let resolveFieldChange: ((result: {
|
|
514
|
+
ok: true
|
|
515
|
+
fieldChange: {
|
|
516
|
+
sideEffects: Record<string, unknown>
|
|
517
|
+
messages: Array<{ text: string; severity: 'error' }>
|
|
518
|
+
}
|
|
519
|
+
}) => void) | null = null
|
|
520
|
+
triggerEventMock.mockImplementation((event: string, data: Record<string, unknown>) => {
|
|
521
|
+
if (event !== 'onFieldChange') return Promise.resolve({ ok: true, data })
|
|
522
|
+
return new Promise((resolve) => {
|
|
523
|
+
resolveFieldChange = resolve
|
|
524
|
+
})
|
|
525
|
+
})
|
|
526
|
+
const rendered = renderWithProviders(
|
|
527
|
+
<CrudForm
|
|
528
|
+
title="Form"
|
|
529
|
+
fields={[{ id: 'title', label: 'Title', type: 'text' }]}
|
|
530
|
+
initialValues={{ title: 'Original' }}
|
|
531
|
+
injectionSpotId="example:async-widget"
|
|
532
|
+
onSubmit={() => {}}
|
|
533
|
+
/>,
|
|
534
|
+
{ dict: { 'ui.forms.actions.save': 'Save' } },
|
|
535
|
+
)
|
|
536
|
+
const titleInput = rendered.container.querySelector('[data-crud-field-id="title"] input') as HTMLInputElement
|
|
537
|
+
|
|
538
|
+
fireEvent.change(titleInput, { target: { value: 'pending' } })
|
|
539
|
+
await waitFor(() => expect(resolveFieldChange).not.toBeNull())
|
|
540
|
+
rendered.unmount()
|
|
541
|
+
|
|
542
|
+
await act(async () => {
|
|
543
|
+
resolveFieldChange?.({
|
|
544
|
+
ok: true,
|
|
545
|
+
fieldChange: {
|
|
546
|
+
sideEffects: { title: 'stale result' },
|
|
547
|
+
messages: [{ text: 'stale message', severity: 'error' }],
|
|
548
|
+
},
|
|
549
|
+
})
|
|
550
|
+
await Promise.resolve()
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
expect(flashMock).not.toHaveBeenCalledWith('stale message', 'error')
|
|
554
|
+
})
|
|
555
|
+
})
|
|
@@ -139,7 +139,6 @@ export function useInjectionWidgets<TContext = unknown>(
|
|
|
139
139
|
[payload?.grantedFeatures],
|
|
140
140
|
)
|
|
141
141
|
const hasBackendChromePayload = payload !== null
|
|
142
|
-
|
|
143
142
|
React.useEffect(() => {
|
|
144
143
|
contextRef.current = options?.context
|
|
145
144
|
triggerOnLoadRef.current = options?.triggerOnLoad
|
|
@@ -304,6 +303,15 @@ export function useInjectionSpotEvents<TContext = unknown, TData = unknown>(spot
|
|
|
304
303
|
[payload?.grantedFeatures],
|
|
305
304
|
)
|
|
306
305
|
const hasBackendChromePayload = payload !== null
|
|
306
|
+
const prefetchedEventWidgets = React.useMemo(() => {
|
|
307
|
+
if (prefetchedWidgets === undefined || !backendChromeReady) return []
|
|
308
|
+
return filterWidgetsByGrantedFeatures(
|
|
309
|
+
prefetchedWidgets,
|
|
310
|
+
grantedFeatureList,
|
|
311
|
+
hasBackendChromePayload,
|
|
312
|
+
)
|
|
313
|
+
}, [backendChromeReady, grantedFeatureList, hasBackendChromePayload, prefetchedWidgets])
|
|
314
|
+
const eventWidgets = prefetchedWidgets === undefined ? widgets : prefetchedEventWidgets
|
|
307
315
|
|
|
308
316
|
React.useEffect(() => {
|
|
309
317
|
return subscribeToInjectionRegistryChanges(() => {
|
|
@@ -429,7 +437,7 @@ export function useInjectionSpotEvents<TContext = unknown, TData = unknown>(spot
|
|
|
429
437
|
if (TRANSFORMER_EVENTS.has(event)) {
|
|
430
438
|
let pipelineData = data
|
|
431
439
|
let applyToForm = false
|
|
432
|
-
for (const widget of
|
|
440
|
+
for (const widget of eventWidgets) {
|
|
433
441
|
const handler = widget.module.eventHandlers?.[event]
|
|
434
442
|
if (!handler) continue
|
|
435
443
|
try {
|
|
@@ -467,7 +475,7 @@ export function useInjectionSpotEvents<TContext = unknown, TData = unknown>(spot
|
|
|
467
475
|
let fieldSideEffects: Record<string, unknown> | undefined
|
|
468
476
|
let fieldMessages: Array<{ text: string; severity: 'info' | 'warning' | 'error' }> | undefined
|
|
469
477
|
|
|
470
|
-
for (const widget of
|
|
478
|
+
for (const widget of eventWidgets) {
|
|
471
479
|
const eventHandlers = widget.module.eventHandlers
|
|
472
480
|
// Check operation filter — skip widget if current operation is filtered out
|
|
473
481
|
const operationFilter = eventHandlers?.filter?.operations
|
|
@@ -566,8 +574,8 @@ export function useInjectionSpotEvents<TContext = unknown, TData = unknown>(spot
|
|
|
566
574
|
}
|
|
567
575
|
return { ok: true }
|
|
568
576
|
},
|
|
569
|
-
[
|
|
577
|
+
[eventWidgets]
|
|
570
578
|
)
|
|
571
579
|
|
|
572
|
-
return { triggerEvent, widgets }
|
|
580
|
+
return { triggerEvent, widgets: eventWidgets }
|
|
573
581
|
}
|
|
@@ -210,6 +210,32 @@ describe('Injection widget feature gating', () => {
|
|
|
210
210
|
expect(onBeforeSaveHiddenMock).not.toHaveBeenCalled()
|
|
211
211
|
})
|
|
212
212
|
|
|
213
|
+
it('dispatches a newly prefetched lifecycle widget in the same layout commit', async () => {
|
|
214
|
+
const onBeforeSaveMock = jest.fn()
|
|
215
|
+
const prefetchedWidget = loadedSpotWidget(
|
|
216
|
+
lifecycleWidget('visible-lifecycle-widget', undefined, onBeforeSaveMock),
|
|
217
|
+
)
|
|
218
|
+
let layoutDispatches = 0
|
|
219
|
+
|
|
220
|
+
function Harness({ prefetched }: { prefetched: LoadedInjectionSpotWidget[] }) {
|
|
221
|
+
const { triggerEvent } = useInjectionSpotEvents<Record<string, unknown>, Record<string, unknown>>(
|
|
222
|
+
SPOT_ID,
|
|
223
|
+
prefetched,
|
|
224
|
+
)
|
|
225
|
+
React.useLayoutEffect(() => {
|
|
226
|
+
layoutDispatches += 1
|
|
227
|
+
void triggerEvent('onBeforeSave', {}, {})
|
|
228
|
+
}, [prefetched, triggerEvent])
|
|
229
|
+
return null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const rendered = render(<Harness prefetched={[]} />)
|
|
233
|
+
rendered.rerender(<Harness prefetched={[prefetchedWidget]} />)
|
|
234
|
+
|
|
235
|
+
await waitFor(() => expect(onBeforeSaveMock).toHaveBeenCalledTimes(1))
|
|
236
|
+
expect(layoutDispatches).toBe(2)
|
|
237
|
+
})
|
|
238
|
+
|
|
213
239
|
it('filters lifecycle widgets loaded directly by metadata features', async () => {
|
|
214
240
|
const onBeforeSaveHiddenMock = jest.fn()
|
|
215
241
|
let resolveWidgets: (widgets: LoadedInjectionWidget[]) => void = () => {}
|