@byline/admin 6.2.1 → 6.4.0
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/fields/array/array-field.js +29 -12
- package/dist/fields/blocks/blocks-field.js +20 -8
- package/dist/fields/checkbox/checkbox-field.js +4 -1
- package/dist/fields/code/code-field.js +2 -1
- package/dist/fields/datetime/datetime-field.js +5 -2
- package/dist/fields/field-renderer.js +2 -1
- package/dist/fields/field-services-types.d.ts +23 -0
- package/dist/fields/file/file-field.js +6 -4
- package/dist/fields/group/group-field.js +8 -3
- package/dist/fields/image/image-field.js +6 -4
- package/dist/fields/numerical/numerical-field.js +2 -1
- package/dist/fields/relation/relation-field.js +8 -2
- package/dist/fields/relation/relation-many-field.js +12 -4
- package/dist/fields/relation/relation-picker.js +38 -7
- package/dist/fields/relation/relation-picker.module.js +3 -0
- package/dist/fields/relation/relation-picker_module.css +20 -0
- package/dist/fields/select/select-field.js +4 -2
- package/dist/fields/text/text-field.js +6 -3
- package/dist/fields/text-area/text-area-field.js +6 -3
- package/dist/fields/use-field-change-handler.js +24 -6
- package/dist/fields/use-field-condition.d.ts +0 -15
- package/dist/fields/use-field-condition.js +10 -1
- package/dist/forms/form-context.d.ts +3 -12
- package/dist/forms/form-context.js +115 -130
- package/dist/forms/form-dom-scope.d.ts +34 -0
- package/dist/forms/form-dom-scope.js +20 -0
- package/dist/forms/form-layout.js +10 -3
- package/dist/forms/form-page-chrome.js +14 -24
- package/dist/forms/form-renderer.d.ts +2 -1
- package/dist/forms/form-renderer.js +13 -56
- package/dist/forms/nested-path.d.ts +2 -0
- package/dist/forms/nested-path.js +26 -1
- package/dist/forms/use-document-reload-recovery.d.ts +22 -0
- package/dist/forms/use-document-reload-recovery.js +67 -0
- package/dist/forms/use-form-submission.d.ts +1 -1
- package/dist/forms/use-form-submission.js +45 -27
- package/dist/forms/use-form-tabs.d.ts +2 -2
- package/dist/forms/use-form-tabs.js +18 -11
- package/dist/presentation/tabs.js +2 -1
- package/dist/react.d.ts +1 -1
- package/package.json +14 -14
- package/src/fields/array/array-field.tsx +24 -12
- package/src/fields/blocks/blocks-field.tsx +18 -8
- package/src/fields/checkbox/checkbox-field.tsx +4 -1
- package/src/fields/code/code-field.tsx +2 -1
- package/src/fields/datetime/datetime-field.tsx +5 -2
- package/src/fields/direct-widget-dom-ids.test.tsx +142 -0
- package/src/fields/field-dom-ids.test.tsx +293 -0
- package/src/fields/field-renderer.tsx +2 -1
- package/src/fields/field-services-types.ts +25 -0
- package/src/fields/file/file-field.tsx +6 -4
- package/src/fields/group/group-field.tsx +5 -3
- package/src/fields/image/image-field.tsx +6 -4
- package/src/fields/numerical/numerical-field.tsx +2 -1
- package/src/fields/relation/relation-field.tsx +8 -2
- package/src/fields/relation/relation-many-field.tsx +19 -4
- package/src/fields/relation/relation-picker-create.test.tsx +203 -0
- package/src/fields/relation/relation-picker-refresh.test.tsx +325 -0
- package/src/fields/relation/relation-picker.module.css +32 -0
- package/src/fields/relation/relation-picker.tsx +59 -1
- package/src/fields/select/select-field.tsx +4 -2
- package/src/fields/text/text-field.tsx +5 -3
- package/src/fields/text-area/text-area-field.tsx +5 -3
- package/src/fields/use-field-change-handler.ts +43 -9
- package/src/fields/use-field-condition.ts +17 -1
- package/src/forms/form-context.tsx +180 -234
- package/src/forms/form-correctness.test.tsx +560 -0
- package/src/forms/form-dom-scope.test.tsx +213 -0
- package/src/forms/form-dom-scope.tsx +70 -0
- package/src/forms/form-layout.tsx +10 -3
- package/src/forms/form-page-chrome.tsx +17 -34
- package/src/forms/form-renderer-submit.test.tsx +36 -0
- package/src/forms/form-renderer.tsx +26 -64
- package/src/forms/form-submit-isolation.test.tsx +247 -0
- package/src/forms/nested-path.ts +35 -0
- package/src/forms/use-document-reload-recovery.ts +92 -0
- package/src/forms/use-form-submission.ts +56 -27
- package/src/forms/use-form-tabs.ts +25 -17
- package/src/presentation/tab-dom-ids.test.tsx +153 -0
- package/src/presentation/tabs.test.tsx +49 -2
- package/src/presentation/tabs.tsx +11 -1
- package/src/react.ts +2 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { act, useLayoutEffect } from 'react'
|
|
10
|
+
|
|
11
|
+
import { defineAdminConfig, type Field } from '@byline/core'
|
|
12
|
+
import { adminTranslations } from '@byline/i18n/admin'
|
|
13
|
+
import { I18nProvider } from '@byline/i18n/react'
|
|
14
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
16
|
+
|
|
17
|
+
import { FieldRenderer } from '../fields/field-renderer'
|
|
18
|
+
import { BylineFieldServicesProvider } from '../fields/field-services-context'
|
|
19
|
+
import { TextField } from '../fields/text/text-field'
|
|
20
|
+
import { useFieldChangeHandler } from '../fields/use-field-change-handler'
|
|
21
|
+
import { useFieldCondition } from '../fields/use-field-condition'
|
|
22
|
+
import { FormProvider, useFieldValue, useFormContext } from './form-context'
|
|
23
|
+
import { type UseFormSubmissionResult, useFormSubmission } from './use-form-submission'
|
|
24
|
+
import { useFormTabs } from './use-form-tabs'
|
|
25
|
+
|
|
26
|
+
const globalWithAct = globalThis as any
|
|
27
|
+
globalWithAct.IS_REACT_ACT_ENVIRONMENT = true
|
|
28
|
+
|
|
29
|
+
defineAdminConfig({
|
|
30
|
+
i18n: {
|
|
31
|
+
admin: { defaultLocale: 'en', locales: ['en'] },
|
|
32
|
+
content: { defaultLocale: 'en', locales: ['en'] },
|
|
33
|
+
},
|
|
34
|
+
collections: [
|
|
35
|
+
{
|
|
36
|
+
path: 'pages',
|
|
37
|
+
labels: { singular: 'Page', plural: 'Pages' },
|
|
38
|
+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
slugifier: (value: string) => value.toLowerCase().trim().replace(/\s+/g, '-'),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const fields = [{ name: 'title', label: 'Title', type: 'text' as const }]
|
|
45
|
+
|
|
46
|
+
const deferred = () => {
|
|
47
|
+
let resolve: () => void = () => {}
|
|
48
|
+
const promise = new Promise<void>((r) => {
|
|
49
|
+
resolve = r
|
|
50
|
+
})
|
|
51
|
+
return { promise, resolve }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let container: HTMLDivElement
|
|
55
|
+
let root: Root
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
container = document.createElement('div')
|
|
59
|
+
document.body.appendChild(container)
|
|
60
|
+
root = createRoot(container)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
act(() => {
|
|
65
|
+
root.unmount()
|
|
66
|
+
})
|
|
67
|
+
container.remove()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
interface Harness {
|
|
71
|
+
result: UseFormSubmissionResult
|
|
72
|
+
ctx: ReturnType<typeof useFormContext>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const renderHookInForm = (
|
|
76
|
+
options: Partial<Parameters<typeof useFormSubmission>[0]> = {},
|
|
77
|
+
{ uploadField = async () => ({}) }: { uploadField?: (...args: any[]) => Promise<any> } = {}
|
|
78
|
+
): Harness => {
|
|
79
|
+
const harness = {} as Harness
|
|
80
|
+
|
|
81
|
+
const Probe = () => {
|
|
82
|
+
const ctx = useFormContext()
|
|
83
|
+
const result = useFormSubmission({
|
|
84
|
+
mode: 'create',
|
|
85
|
+
fields,
|
|
86
|
+
onSubmit: async () => {},
|
|
87
|
+
isBlocked: () => false,
|
|
88
|
+
...options,
|
|
89
|
+
} as Parameters<typeof useFormSubmission>[0])
|
|
90
|
+
harness.ctx = ctx
|
|
91
|
+
harness.result = result
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
act(() => {
|
|
96
|
+
root.render(
|
|
97
|
+
<I18nProvider
|
|
98
|
+
bundle={adminTranslations({ locales: ['en'] })}
|
|
99
|
+
activeLocale="en"
|
|
100
|
+
defaultLocale="en"
|
|
101
|
+
localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
|
|
102
|
+
>
|
|
103
|
+
<BylineFieldServicesProvider
|
|
104
|
+
services={
|
|
105
|
+
{
|
|
106
|
+
getCollectionDocuments: async () => ({ docs: [], total: 0 }),
|
|
107
|
+
uploadField,
|
|
108
|
+
} as any
|
|
109
|
+
}
|
|
110
|
+
>
|
|
111
|
+
<FormProvider initialData={{ fields: { title: 'hello' } }} collectionPath="pages">
|
|
112
|
+
<Probe />
|
|
113
|
+
</FormProvider>
|
|
114
|
+
</BylineFieldServicesProvider>
|
|
115
|
+
</I18nProvider>
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
return harness
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
describe('form correctness', () => {
|
|
122
|
+
it('retains a submit-time hook error for the editor', async () => {
|
|
123
|
+
const onSubmit = vi.fn()
|
|
124
|
+
const h = renderHookInForm({
|
|
125
|
+
onSubmit,
|
|
126
|
+
fields: [
|
|
127
|
+
{
|
|
128
|
+
...fields[0],
|
|
129
|
+
hooks: {
|
|
130
|
+
beforeValidate: () => ({ error: 'Title is reserved' }),
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
})
|
|
135
|
+
await act(async () => {
|
|
136
|
+
await h.result.submit()
|
|
137
|
+
})
|
|
138
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
139
|
+
expect(h.ctx.getErrors()).toContainEqual({ field: 'title', message: 'Title is reserved' })
|
|
140
|
+
})
|
|
141
|
+
it('reports a throwing condition at submit instead of rejecting the submission', async () => {
|
|
142
|
+
// Submit-time hooks evaluate the same predicate as the walker. An
|
|
143
|
+
// exception there must not escape submit(), or the editor is refused with
|
|
144
|
+
// nothing shown and no way to find out why.
|
|
145
|
+
const onSubmit = vi.fn()
|
|
146
|
+
const beforeValidate = vi.fn(() => undefined)
|
|
147
|
+
const h = renderHookInForm({
|
|
148
|
+
onSubmit,
|
|
149
|
+
fields: [
|
|
150
|
+
{
|
|
151
|
+
...fields[0],
|
|
152
|
+
condition: (data: any) => data.missing.deep === 1,
|
|
153
|
+
hooks: { beforeValidate },
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
})
|
|
157
|
+
await act(async () => {
|
|
158
|
+
await h.result.submit()
|
|
159
|
+
})
|
|
160
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
161
|
+
// Visible (fail closed), so its hooks still ran...
|
|
162
|
+
expect(beforeValidate).toHaveBeenCalled()
|
|
163
|
+
// ...and the failure reached the editor as a field error.
|
|
164
|
+
expect(h.ctx.getErrors()).toContainEqual({
|
|
165
|
+
field: 'title',
|
|
166
|
+
message: 'Title: could not be validated',
|
|
167
|
+
kind: 'invalid',
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
it('does not upload a successful file again after another file failed', async () => {
|
|
171
|
+
const uploadField = vi.fn(async (_collection, body) => {
|
|
172
|
+
if (body.get('field') === 'second') throw new Error('second failed')
|
|
173
|
+
return { storedFile: { id: 'stored-first' } }
|
|
174
|
+
})
|
|
175
|
+
const h = renderHookInForm({}, { uploadField })
|
|
176
|
+
act(() => {
|
|
177
|
+
h.ctx.addPendingUpload('first', {
|
|
178
|
+
file: new File(['a'], 'a.txt'),
|
|
179
|
+
previewUrl: 'blob:a',
|
|
180
|
+
collectionPath: 'pages',
|
|
181
|
+
})
|
|
182
|
+
h.ctx.addPendingUpload('second', {
|
|
183
|
+
file: new File(['b'], 'b.txt'),
|
|
184
|
+
previewUrl: 'blob:b',
|
|
185
|
+
collectionPath: 'pages',
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
await act(async () => {
|
|
189
|
+
await h.result.submit()
|
|
190
|
+
})
|
|
191
|
+
await act(async () => {
|
|
192
|
+
await h.result.submit()
|
|
193
|
+
})
|
|
194
|
+
expect(uploadField.mock.calls.filter((call) => call[1].get('field') === 'first')).toHaveLength(
|
|
195
|
+
1
|
|
196
|
+
)
|
|
197
|
+
})
|
|
198
|
+
it('validates required descendants of a group', () => {
|
|
199
|
+
const h = renderHookInForm()
|
|
200
|
+
act(() => {
|
|
201
|
+
h.ctx.setFieldValue('details', {})
|
|
202
|
+
})
|
|
203
|
+
const errors = h.ctx.validateForm([
|
|
204
|
+
{
|
|
205
|
+
name: 'details',
|
|
206
|
+
type: 'group',
|
|
207
|
+
fields: [{ name: 'caption', label: 'Caption', type: 'text' }],
|
|
208
|
+
},
|
|
209
|
+
])
|
|
210
|
+
expect(errors).toContainEqual({
|
|
211
|
+
field: 'details.caption',
|
|
212
|
+
message: 'Caption is required',
|
|
213
|
+
kind: 'required',
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
it('does not let an old asynchronous change overwrite newer input', async () => {
|
|
217
|
+
const older = deferred()
|
|
218
|
+
const newer = deferred()
|
|
219
|
+
let change!: (value: any) => void
|
|
220
|
+
let ctx!: ReturnType<typeof useFormContext>
|
|
221
|
+
const Probe = () => {
|
|
222
|
+
ctx = useFormContext()
|
|
223
|
+
change = useFieldChangeHandler(
|
|
224
|
+
{
|
|
225
|
+
name: 'title',
|
|
226
|
+
type: 'text',
|
|
227
|
+
hooks: {
|
|
228
|
+
beforeValidate: async (event) => {
|
|
229
|
+
await (event.value === 'old' ? older.promise : newer.promise)
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
'title'
|
|
234
|
+
)
|
|
235
|
+
return null
|
|
236
|
+
}
|
|
237
|
+
act(() => {
|
|
238
|
+
root.render(
|
|
239
|
+
<FormProvider>
|
|
240
|
+
<Probe />
|
|
241
|
+
</FormProvider>
|
|
242
|
+
)
|
|
243
|
+
})
|
|
244
|
+
act(() => {
|
|
245
|
+
change('old')
|
|
246
|
+
change('new')
|
|
247
|
+
})
|
|
248
|
+
await act(async () => {
|
|
249
|
+
newer.resolve()
|
|
250
|
+
await Promise.resolve()
|
|
251
|
+
})
|
|
252
|
+
expect(ctx.getFieldValue('title')).toBe('new')
|
|
253
|
+
await act(async () => {
|
|
254
|
+
older.resolve()
|
|
255
|
+
await Promise.resolve()
|
|
256
|
+
})
|
|
257
|
+
expect(ctx.getFieldValue('title')).toBe('new')
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
describe('field rendering and snapshots', () => {
|
|
262
|
+
it('honors readOnly on a text widget', () => {
|
|
263
|
+
act(() => {
|
|
264
|
+
root.render(
|
|
265
|
+
<FormProvider initialData={{ title: 'Locked' }}>
|
|
266
|
+
<TextField field={{ name: 'title', type: 'text', readOnly: true }} />
|
|
267
|
+
</FormProvider>
|
|
268
|
+
)
|
|
269
|
+
})
|
|
270
|
+
expect(container.querySelector('input')?.readOnly).toBe(true)
|
|
271
|
+
})
|
|
272
|
+
it('inherits readOnly through groups, array groups and blocks while retaining collapse controls', () => {
|
|
273
|
+
const schema: Field[] = [
|
|
274
|
+
{ name: 'group', type: 'group', readOnly: true, fields: [{ name: 'text', type: 'text' }] },
|
|
275
|
+
{
|
|
276
|
+
name: 'rows',
|
|
277
|
+
type: 'array',
|
|
278
|
+
readOnly: true,
|
|
279
|
+
fields: [{ name: 'group', type: 'group', fields: [{ name: 'text', type: 'text' }] }],
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
name: 'content',
|
|
283
|
+
type: 'blocks',
|
|
284
|
+
readOnly: true,
|
|
285
|
+
blocks: [{ blockType: 'entry', fields: [{ name: 'text', type: 'text' }] }],
|
|
286
|
+
},
|
|
287
|
+
]
|
|
288
|
+
const data = {
|
|
289
|
+
group: { text: 'one' },
|
|
290
|
+
rows: [{ _id: 'row', group: { text: 'two' } }],
|
|
291
|
+
content: [{ _id: 'block', _type: 'entry', text: 'three' }],
|
|
292
|
+
}
|
|
293
|
+
act(() =>
|
|
294
|
+
root.render(
|
|
295
|
+
<I18nProvider
|
|
296
|
+
bundle={adminTranslations({ locales: ['en'] })}
|
|
297
|
+
activeLocale="en"
|
|
298
|
+
defaultLocale="en"
|
|
299
|
+
localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
|
|
300
|
+
>
|
|
301
|
+
<FormProvider initialData={data}>
|
|
302
|
+
{schema.map((field) => (
|
|
303
|
+
<FieldRenderer
|
|
304
|
+
key={field.name}
|
|
305
|
+
field={field}
|
|
306
|
+
defaultValue={data[field.name as keyof typeof data]}
|
|
307
|
+
/>
|
|
308
|
+
))}
|
|
309
|
+
</FormProvider>
|
|
310
|
+
</I18nProvider>
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
const inputs = [...container.querySelectorAll<HTMLInputElement>('input[type="text"]')]
|
|
314
|
+
expect(inputs).toHaveLength(3)
|
|
315
|
+
expect(inputs.every((input) => input.readOnly)).toBe(true)
|
|
316
|
+
expect(container.querySelector('[aria-label="Remove item"]')).toBeNull()
|
|
317
|
+
expect([...container.querySelectorAll('button')].some((button) => !button.disabled)).toBe(true)
|
|
318
|
+
})
|
|
319
|
+
it('observes a field write between rendering and passive subscription', () => {
|
|
320
|
+
const Display = () => <span>{useFieldValue<string>('title')}</span>
|
|
321
|
+
const Seed = () => {
|
|
322
|
+
const ctx = useFormContext()
|
|
323
|
+
useLayoutEffect(() => {
|
|
324
|
+
ctx.setFieldValue('title', 'new')
|
|
325
|
+
}, [ctx.setFieldValue])
|
|
326
|
+
return <Display />
|
|
327
|
+
}
|
|
328
|
+
act(() => {
|
|
329
|
+
root.render(
|
|
330
|
+
<FormProvider initialData={{ title: 'old' }}>
|
|
331
|
+
<Seed />
|
|
332
|
+
</FormProvider>
|
|
333
|
+
)
|
|
334
|
+
})
|
|
335
|
+
expect(container.textContent).toBe('new')
|
|
336
|
+
})
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
describe('submission and store contracts', () => {
|
|
340
|
+
it('waits for an outstanding change before validating and saving', async () => {
|
|
341
|
+
const gate = deferred()
|
|
342
|
+
const onSubmit = vi.fn()
|
|
343
|
+
const h = renderHookInForm({ onSubmit })
|
|
344
|
+
h.ctx.trackFieldChange(gate.promise.then(() => h.ctx.setFieldValue('title', 'finished')))
|
|
345
|
+
let submission!: Promise<void>
|
|
346
|
+
await act(async () => {
|
|
347
|
+
submission = h.result.submit()
|
|
348
|
+
await Promise.resolve()
|
|
349
|
+
})
|
|
350
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
351
|
+
await act(async () => {
|
|
352
|
+
gate.resolve()
|
|
353
|
+
await submission
|
|
354
|
+
})
|
|
355
|
+
expect(onSubmit.mock.calls[0]?.[0].data.title).toBe('finished')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('does not wait for a superseded pending change', async () => {
|
|
359
|
+
const gate = deferred()
|
|
360
|
+
const onSubmit = vi.fn()
|
|
361
|
+
const h = renderHookInForm({ onSubmit })
|
|
362
|
+
const release = h.ctx.trackFieldChange(gate.promise)
|
|
363
|
+
release()
|
|
364
|
+
await act(async () => {
|
|
365
|
+
await h.result.submit()
|
|
366
|
+
})
|
|
367
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
368
|
+
gate.resolve()
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it('allows metadata-only saves when older content lacks a required field', async () => {
|
|
372
|
+
const onSubmit = vi.fn()
|
|
373
|
+
const h = renderHookInForm({
|
|
374
|
+
mode: 'edit',
|
|
375
|
+
onSubmit,
|
|
376
|
+
fields: [{ name: 'newField', type: 'text' }],
|
|
377
|
+
})
|
|
378
|
+
act(() => h.ctx.setSystemPath('/changed'))
|
|
379
|
+
await act(async () => {
|
|
380
|
+
await h.result.submit()
|
|
381
|
+
})
|
|
382
|
+
expect(h.result.phase.kind).toBe('confirmingSystemFields')
|
|
383
|
+
await act(async () => {
|
|
384
|
+
await h.result.confirmSystemFields()
|
|
385
|
+
})
|
|
386
|
+
expect(onSubmit.mock.calls[0]?.[0].contentDirty).toBe(false)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it('does not resurrect initial descendants after an ancestor is cleared or saved', () => {
|
|
390
|
+
let ctx!: ReturnType<typeof useFormContext>
|
|
391
|
+
const Probe = () => {
|
|
392
|
+
ctx = useFormContext()
|
|
393
|
+
return <span>{useFieldValue<string>('details.title')}</span>
|
|
394
|
+
}
|
|
395
|
+
act(() =>
|
|
396
|
+
root.render(
|
|
397
|
+
<FormProvider initialData={{ details: { title: 'old' } }}>
|
|
398
|
+
<Probe />
|
|
399
|
+
</FormProvider>
|
|
400
|
+
)
|
|
401
|
+
)
|
|
402
|
+
expect(container.textContent).toBe('old')
|
|
403
|
+
act(() => ctx.setFieldValue('details', {}))
|
|
404
|
+
expect(container.textContent).toBe('')
|
|
405
|
+
act(() => ctx.resetHasChanges())
|
|
406
|
+
expect(ctx.getFieldValue('details.title')).toBeUndefined()
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
it('keeps a field visible when its condition throws, instead of taking the render down', () => {
|
|
410
|
+
// The predicate runs on every form edit against live, partly-filled data.
|
|
411
|
+
// A predicate assuming a shape the editor has not reached yet must not
|
|
412
|
+
// crash the form; failing closed matches the validation walker.
|
|
413
|
+
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
414
|
+
try {
|
|
415
|
+
const field: Field = {
|
|
416
|
+
name: 'doi',
|
|
417
|
+
type: 'text',
|
|
418
|
+
condition: (data: any) => data.missing.deep === 1,
|
|
419
|
+
}
|
|
420
|
+
const Probe = () => <span>{useFieldCondition(field) ? 'visible' : 'hidden'}</span>
|
|
421
|
+
act(() =>
|
|
422
|
+
root.render(
|
|
423
|
+
<FormProvider>
|
|
424
|
+
<Probe />
|
|
425
|
+
</FormProvider>
|
|
426
|
+
)
|
|
427
|
+
)
|
|
428
|
+
expect(container.textContent).toBe('visible')
|
|
429
|
+
expect(errors).toHaveBeenCalledTimes(1)
|
|
430
|
+
} finally {
|
|
431
|
+
errors.mockRestore()
|
|
432
|
+
}
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('only rerenders tab visibility when a predicate changes, and badges nested errors', () => {
|
|
436
|
+
let ctx!: ReturnType<typeof useFormContext>
|
|
437
|
+
let renders = 0
|
|
438
|
+
const tabs = [
|
|
439
|
+
{
|
|
440
|
+
name: 'main',
|
|
441
|
+
tabs: [
|
|
442
|
+
{ name: 'base', label: 'Base', fields: ['title'] },
|
|
443
|
+
{
|
|
444
|
+
name: 'extra',
|
|
445
|
+
label: 'Extra',
|
|
446
|
+
fields: ['details'],
|
|
447
|
+
condition: (data: any) => Boolean(data.show),
|
|
448
|
+
},
|
|
449
|
+
],
|
|
450
|
+
},
|
|
451
|
+
]
|
|
452
|
+
const mapping = new Map([['details', { tabSetName: 'main', tabName: 'extra' }]])
|
|
453
|
+
const Probe = () => {
|
|
454
|
+
ctx = useFormContext()
|
|
455
|
+
const state = useFormTabs(mapping, {}, tabs)
|
|
456
|
+
renders++
|
|
457
|
+
return (
|
|
458
|
+
<span>
|
|
459
|
+
{state.resolve(tabs[0]!).visibleTabs.length}:{state.errorCountsBySet.main?.extra ?? 0}
|
|
460
|
+
</span>
|
|
461
|
+
)
|
|
462
|
+
}
|
|
463
|
+
act(() =>
|
|
464
|
+
root.render(
|
|
465
|
+
<FormProvider>
|
|
466
|
+
<Probe />
|
|
467
|
+
</FormProvider>
|
|
468
|
+
)
|
|
469
|
+
)
|
|
470
|
+
const initialRenders = renders
|
|
471
|
+
act(() => ctx.setFieldValue('title', 'unrelated'))
|
|
472
|
+
expect(renders).toBe(initialRenders)
|
|
473
|
+
act(() => ctx.setFieldValue('show', true))
|
|
474
|
+
expect(container.textContent).toBe('2:0')
|
|
475
|
+
act(() => ctx.setFieldError('details.caption', 'Required'))
|
|
476
|
+
expect(container.textContent).toBe('2:1')
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('does not replace a parked confirmation and rejects a cancelled confirmation callback', async () => {
|
|
480
|
+
const onSubmit = vi.fn()
|
|
481
|
+
const h = renderHookInForm({ mode: 'edit', onSubmit })
|
|
482
|
+
const submit = h.result.submit
|
|
483
|
+
act(() => h.ctx.setSystemPath('/first'))
|
|
484
|
+
await act(async () => {
|
|
485
|
+
await submit()
|
|
486
|
+
})
|
|
487
|
+
const confirm = h.result.confirmSystemFields
|
|
488
|
+
act(() => h.ctx.setSystemPath('/second'))
|
|
489
|
+
await act(async () => {
|
|
490
|
+
await submit()
|
|
491
|
+
})
|
|
492
|
+
if (h.result.phase.kind !== 'confirmingSystemFields') throw new Error('Expected confirmation')
|
|
493
|
+
expect(h.result.phase.payload.systemPath).toBe('/first')
|
|
494
|
+
act(() => h.result.cancelSystemFields())
|
|
495
|
+
await act(async () => {
|
|
496
|
+
await confirm()
|
|
497
|
+
})
|
|
498
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
499
|
+
await act(async () => {
|
|
500
|
+
await submit()
|
|
501
|
+
})
|
|
502
|
+
await act(async () => {
|
|
503
|
+
await h.result.confirmSystemFields()
|
|
504
|
+
})
|
|
505
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
506
|
+
expect(onSubmit.mock.calls[0]?.[0].systemPath).toBe('/second')
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
it('displays serialized server field errors without clearing dirty state', async () => {
|
|
510
|
+
const h = renderHookInForm({
|
|
511
|
+
onSubmit: async () => {
|
|
512
|
+
throw {
|
|
513
|
+
code: 'ERR_VALIDATION',
|
|
514
|
+
details: {
|
|
515
|
+
reason: 'invalid_document_fields',
|
|
516
|
+
issues: [{ field: 'title', message: 'Required by server' }],
|
|
517
|
+
},
|
|
518
|
+
}
|
|
519
|
+
},
|
|
520
|
+
})
|
|
521
|
+
act(() => h.ctx.setFieldValue('title', 'changed'))
|
|
522
|
+
await act(async () => {
|
|
523
|
+
await h.result.submit()
|
|
524
|
+
})
|
|
525
|
+
expect(h.ctx.getErrors()).toContainEqual({ field: 'title', message: 'Required by server' })
|
|
526
|
+
expect(h.ctx.hasChanges()).toBe(true)
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('updates composite subscriptions without mutating old snapshots or rerendering unrelated values', () => {
|
|
530
|
+
let ctx!: ReturnType<typeof useFormContext>
|
|
531
|
+
let unrelatedRenders = 0
|
|
532
|
+
const Unrelated = () => {
|
|
533
|
+
unrelatedRenders++
|
|
534
|
+
return <span>{useFieldValue<string>('other')}</span>
|
|
535
|
+
}
|
|
536
|
+
const Composite = () => {
|
|
537
|
+
const value = useFieldValue<{ title: string }>('details')
|
|
538
|
+
return <b>{value?.title}</b>
|
|
539
|
+
}
|
|
540
|
+
const Capture = () => {
|
|
541
|
+
ctx = useFormContext()
|
|
542
|
+
return null
|
|
543
|
+
}
|
|
544
|
+
act(() =>
|
|
545
|
+
root.render(
|
|
546
|
+
<FormProvider initialData={{ fields: { details: { title: 'old' }, other: 'steady' } }}>
|
|
547
|
+
<Capture />
|
|
548
|
+
<Composite />
|
|
549
|
+
<Unrelated />
|
|
550
|
+
</FormProvider>
|
|
551
|
+
)
|
|
552
|
+
)
|
|
553
|
+
const previous = ctx.getFieldValue('details')
|
|
554
|
+
const count = unrelatedRenders
|
|
555
|
+
act(() => ctx.setFieldValue('details.title', 'new'))
|
|
556
|
+
expect(previous.title).toBe('old')
|
|
557
|
+
expect(container.querySelector('b')?.textContent).toBe('new')
|
|
558
|
+
expect(unrelatedRenders).toBe(count)
|
|
559
|
+
})
|
|
560
|
+
})
|