@byline/admin 6.2.0 → 6.3.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/checkbox/checkbox-field.js +3 -1
- package/dist/fields/code/code-field.js +2 -1
- package/dist/fields/datetime/datetime-field.js +4 -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 +3 -2
- package/dist/fields/group/group-field.js +3 -1
- package/dist/fields/image/image-field.js +3 -2
- package/dist/fields/numerical/numerical-field.js +2 -1
- package/dist/fields/relation/relation-field.js +3 -2
- package/dist/fields/relation/relation-many-field.js +3 -2
- 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 +3 -2
- package/dist/fields/text/text-field.js +2 -1
- package/dist/fields/text-area/text-area-field.js +2 -1
- package/dist/forms/form-context.js +4 -1
- package/dist/forms/form-dom-scope.d.ts +34 -0
- package/dist/forms/form-dom-scope.js +20 -0
- package/dist/forms/form-layout-styles.test.node.d.ts +8 -0
- package/dist/forms/form-layout.d.ts +39 -0
- package/dist/forms/form-layout.js +102 -0
- package/dist/forms/form-page-chrome.d.ts +110 -0
- package/dist/forms/form-page-chrome.js +253 -0
- package/dist/forms/form-renderer.d.ts +2 -18
- package/dist/forms/form-renderer.js +105 -423
- package/dist/forms/form-renderer.module.js +2 -0
- package/dist/forms/form-renderer_module.css +13 -0
- package/dist/forms/use-form-submission.d.ts +74 -0
- package/dist/forms/use-form-submission.js +166 -0
- package/dist/forms/use-form-tabs.d.ts +29 -0
- package/dist/forms/use-form-tabs.js +45 -0
- package/dist/presentation/tabs.js +2 -1
- package/dist/react.d.ts +1 -1
- package/package.json +7 -7
- package/src/fields/checkbox/checkbox-field.tsx +3 -1
- package/src/fields/code/code-field.tsx +2 -1
- package/src/fields/datetime/datetime-field.tsx +4 -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 +3 -2
- package/src/fields/group/group-field.tsx +3 -1
- package/src/fields/image/image-field.tsx +3 -2
- package/src/fields/numerical/numerical-field.tsx +2 -1
- package/src/fields/relation/relation-field.tsx +3 -2
- package/src/fields/relation/relation-many-field.tsx +3 -2
- 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 +3 -2
- package/src/fields/text/text-field.tsx +2 -1
- package/src/fields/text-area/text-area-field.tsx +2 -1
- package/src/forms/form-context.tsx +2 -1
- package/src/forms/form-dom-scope.test.tsx +213 -0
- package/src/forms/form-dom-scope.tsx +70 -0
- package/src/forms/form-layout-styles.test.node.ts +32 -0
- package/src/forms/form-layout.test.tsx +276 -0
- package/src/forms/form-layout.tsx +204 -0
- package/src/forms/form-page-chrome.tsx +460 -0
- package/src/forms/form-renderer-submit.test.tsx +40 -0
- package/src/forms/form-renderer-tab-persistence.test.tsx +145 -0
- package/src/forms/form-renderer.module.css +18 -0
- package/src/forms/form-renderer.tsx +126 -600
- package/src/forms/form-submit-isolation.test.tsx +247 -0
- package/src/forms/use-form-submission.test.tsx +564 -0
- package/src/forms/use-form-submission.ts +293 -0
- package/src/forms/use-form-tabs.ts +89 -0
- 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,564 @@
|
|
|
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 } from 'react'
|
|
10
|
+
|
|
11
|
+
import { defineAdminConfig } 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 { BylineFieldServicesProvider } from '../fields/field-services-context'
|
|
18
|
+
import { FormProvider, useFormContext } from './form-context'
|
|
19
|
+
import { type UseFormSubmissionResult, useFormSubmission } from './use-form-submission'
|
|
20
|
+
|
|
21
|
+
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
|
22
|
+
|
|
23
|
+
defineAdminConfig({
|
|
24
|
+
i18n: {
|
|
25
|
+
admin: { defaultLocale: 'en', locales: ['en'] },
|
|
26
|
+
content: { defaultLocale: 'en', locales: ['en'] },
|
|
27
|
+
},
|
|
28
|
+
collections: [
|
|
29
|
+
{
|
|
30
|
+
path: 'pages',
|
|
31
|
+
labels: { singular: 'Page', plural: 'Pages' },
|
|
32
|
+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
slugifier: (value: string) => value.toLowerCase().trim().replace(/\s+/g, '-'),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const fields = [{ name: 'title', label: 'Title', type: 'text' as const }]
|
|
39
|
+
|
|
40
|
+
const deferred = () => {
|
|
41
|
+
let resolve: () => void = () => {}
|
|
42
|
+
const promise = new Promise<void>((r) => {
|
|
43
|
+
resolve = r
|
|
44
|
+
})
|
|
45
|
+
return { promise, resolve }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let container: HTMLDivElement
|
|
49
|
+
let root: Root
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
container = document.createElement('div')
|
|
53
|
+
document.body.appendChild(container)
|
|
54
|
+
root = createRoot(container)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
act(() => {
|
|
59
|
+
root.unmount()
|
|
60
|
+
})
|
|
61
|
+
container.remove()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
interface Harness {
|
|
65
|
+
result: UseFormSubmissionResult
|
|
66
|
+
ctx: ReturnType<typeof useFormContext>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const renderHookInForm = (
|
|
70
|
+
options: Partial<Parameters<typeof useFormSubmission>[0]> = {},
|
|
71
|
+
{ uploadField = async () => ({}) }: { uploadField?: (...args: any[]) => Promise<any> } = {}
|
|
72
|
+
): Harness => {
|
|
73
|
+
const harness = {} as Harness
|
|
74
|
+
|
|
75
|
+
const Probe = () => {
|
|
76
|
+
const ctx = useFormContext()
|
|
77
|
+
const result = useFormSubmission({
|
|
78
|
+
mode: 'create',
|
|
79
|
+
fields,
|
|
80
|
+
onSubmit: async () => {},
|
|
81
|
+
isBlocked: () => false,
|
|
82
|
+
...options,
|
|
83
|
+
} as Parameters<typeof useFormSubmission>[0])
|
|
84
|
+
harness.ctx = ctx
|
|
85
|
+
harness.result = result
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
act(() => {
|
|
90
|
+
root.render(
|
|
91
|
+
<I18nProvider
|
|
92
|
+
bundle={adminTranslations({ locales: ['en'] })}
|
|
93
|
+
activeLocale="en"
|
|
94
|
+
defaultLocale="en"
|
|
95
|
+
localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
|
|
96
|
+
>
|
|
97
|
+
<BylineFieldServicesProvider
|
|
98
|
+
services={
|
|
99
|
+
{
|
|
100
|
+
getCollectionDocuments: async () => ({ docs: [], total: 0 }),
|
|
101
|
+
uploadField,
|
|
102
|
+
} as any
|
|
103
|
+
}
|
|
104
|
+
>
|
|
105
|
+
<FormProvider initialData={{ fields: { title: 'hello' } }} collectionPath="pages">
|
|
106
|
+
<Probe />
|
|
107
|
+
</FormProvider>
|
|
108
|
+
</BylineFieldServicesProvider>
|
|
109
|
+
</I18nProvider>
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
return harness
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe('useFormSubmission phases', () => {
|
|
116
|
+
it('moves idle → validating → submitting → idle when nothing is queued for upload', async () => {
|
|
117
|
+
const validation = deferred()
|
|
118
|
+
const submission = deferred()
|
|
119
|
+
const order: string[] = []
|
|
120
|
+
const gatedFields = [
|
|
121
|
+
{
|
|
122
|
+
name: 'title',
|
|
123
|
+
label: 'Title',
|
|
124
|
+
type: 'text' as const,
|
|
125
|
+
hooks: {
|
|
126
|
+
beforeValidate: async () => {
|
|
127
|
+
order.push('validate')
|
|
128
|
+
await validation.promise
|
|
129
|
+
return undefined
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
]
|
|
134
|
+
const h = renderHookInForm({
|
|
135
|
+
fields: gatedFields,
|
|
136
|
+
onSubmit: async () => {
|
|
137
|
+
order.push('submit')
|
|
138
|
+
await submission.promise
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
expect(h.result.phase.kind).toBe('idle')
|
|
143
|
+
|
|
144
|
+
let pending!: Promise<void>
|
|
145
|
+
await act(async () => {
|
|
146
|
+
pending = h.result.submit()
|
|
147
|
+
await Promise.resolve()
|
|
148
|
+
})
|
|
149
|
+
expect(h.result.phase.kind).toBe('validating')
|
|
150
|
+
|
|
151
|
+
await act(async () => {
|
|
152
|
+
validation.resolve()
|
|
153
|
+
await Promise.resolve()
|
|
154
|
+
})
|
|
155
|
+
expect(h.result.phase.kind).toBe('submitting')
|
|
156
|
+
|
|
157
|
+
await act(async () => {
|
|
158
|
+
submission.resolve()
|
|
159
|
+
await pending
|
|
160
|
+
})
|
|
161
|
+
expect(h.result.phase.kind).toBe('idle')
|
|
162
|
+
expect(order).toEqual(['validate', 'submit'])
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('is not busy while validating, so the form is not made inert before work starts', async () => {
|
|
166
|
+
const validation = deferred()
|
|
167
|
+
const gatedFields = [
|
|
168
|
+
{
|
|
169
|
+
name: 'title',
|
|
170
|
+
label: 'Title',
|
|
171
|
+
type: 'text' as const,
|
|
172
|
+
hooks: {
|
|
173
|
+
beforeValidate: async () => {
|
|
174
|
+
await validation.promise
|
|
175
|
+
return undefined
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
]
|
|
180
|
+
const h = renderHookInForm({ fields: gatedFields })
|
|
181
|
+
|
|
182
|
+
let pending!: Promise<void>
|
|
183
|
+
await act(async () => {
|
|
184
|
+
pending = h.result.submit()
|
|
185
|
+
await Promise.resolve()
|
|
186
|
+
})
|
|
187
|
+
expect(h.result.phase.kind).toBe('validating')
|
|
188
|
+
expect(h.result.isBusy).toBe(false)
|
|
189
|
+
|
|
190
|
+
await act(async () => {
|
|
191
|
+
validation.resolve()
|
|
192
|
+
await pending
|
|
193
|
+
})
|
|
194
|
+
expect(h.result.isBusy).toBe(false)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('is busy while submitting', async () => {
|
|
198
|
+
const gate = deferred()
|
|
199
|
+
const h = renderHookInForm({ onSubmit: async () => gate.promise })
|
|
200
|
+
|
|
201
|
+
expect(h.result.isBusy).toBe(false)
|
|
202
|
+
let pending!: Promise<void>
|
|
203
|
+
await act(async () => {
|
|
204
|
+
pending = h.result.submit()
|
|
205
|
+
await Promise.resolve()
|
|
206
|
+
})
|
|
207
|
+
expect(h.result.phase.kind).toBe('submitting')
|
|
208
|
+
expect(h.result.isBusy).toBe(true)
|
|
209
|
+
|
|
210
|
+
await act(async () => {
|
|
211
|
+
gate.resolve()
|
|
212
|
+
await pending
|
|
213
|
+
})
|
|
214
|
+
expect(h.result.isBusy).toBe(false)
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
describe('useFormSubmission re-entry protection', () => {
|
|
219
|
+
it('admits only one submission when two are issued in the same turn', async () => {
|
|
220
|
+
const onSubmit = vi.fn(async () => {})
|
|
221
|
+
const h = renderHookInForm({ onSubmit })
|
|
222
|
+
|
|
223
|
+
await act(async () => {
|
|
224
|
+
const first = h.result.submit()
|
|
225
|
+
const second = h.result.submit() // no await between them; no rerender either
|
|
226
|
+
await Promise.all([first, second])
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('guards the whole window, not just the final request', async () => {
|
|
233
|
+
// The second call lands while the first is still validating. Rendered state
|
|
234
|
+
// still says 'idle' at that moment, so only a synchronous ref can refuse it.
|
|
235
|
+
const onSubmit = vi.fn(async () => {})
|
|
236
|
+
const h = renderHookInForm({ onSubmit })
|
|
237
|
+
|
|
238
|
+
await act(async () => {
|
|
239
|
+
const first = h.result.submit()
|
|
240
|
+
const duringValidation = h.result.submit()
|
|
241
|
+
await Promise.all([first, duringValidation])
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('accepts a second submission once the first has finished', async () => {
|
|
248
|
+
const onSubmit = vi.fn(async () => {})
|
|
249
|
+
const h = renderHookInForm({ onSubmit })
|
|
250
|
+
|
|
251
|
+
await act(async () => {
|
|
252
|
+
await h.result.submit()
|
|
253
|
+
})
|
|
254
|
+
await act(async () => {
|
|
255
|
+
await h.result.submit()
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
expect(onSubmit).toHaveBeenCalledTimes(2)
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
describe('useFormSubmission mutation blocking', () => {
|
|
263
|
+
it('refuses to submit while mutations are blocked', async () => {
|
|
264
|
+
const onSubmit = vi.fn(async () => {})
|
|
265
|
+
const h = renderHookInForm({ onSubmit, isBlocked: () => true })
|
|
266
|
+
|
|
267
|
+
await act(async () => {
|
|
268
|
+
await h.result.submit()
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
272
|
+
expect(h.result.phase.kind).toBe('idle')
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('abandons a submission if mutations become blocked during validation', async () => {
|
|
276
|
+
const onSubmit = vi.fn(async () => {})
|
|
277
|
+
let blocked = false
|
|
278
|
+
const h = renderHookInForm({ onSubmit, isBlocked: () => blocked })
|
|
279
|
+
|
|
280
|
+
await act(async () => {
|
|
281
|
+
const pending = h.result.submit()
|
|
282
|
+
blocked = true
|
|
283
|
+
await pending
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
describe('useFormSubmission settles the phase on every exit', () => {
|
|
291
|
+
// Every non-delivery exit must leave a settled phase. A return that skips the
|
|
292
|
+
// reset strands the form: `isBusy` stays true, so FormContent keeps it inert
|
|
293
|
+
// and the save indicator spinning with nothing in flight.
|
|
294
|
+
it('returns to idle when a mutation block arrives during upload', async () => {
|
|
295
|
+
const upload = deferred()
|
|
296
|
+
const onSubmit = vi.fn(async () => {})
|
|
297
|
+
let blocked = false
|
|
298
|
+
const h = renderHookInForm(
|
|
299
|
+
{ onSubmit, isBlocked: () => blocked },
|
|
300
|
+
{
|
|
301
|
+
uploadField: async () => {
|
|
302
|
+
await upload.promise
|
|
303
|
+
return { id: 'file-1', filename: 'x.png' }
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
)
|
|
307
|
+
act(() => {
|
|
308
|
+
h.ctx.setFieldValue('title', 'dirty title')
|
|
309
|
+
h.ctx.addPendingUpload('cover', {
|
|
310
|
+
file: new File(['x'], 'x.png', { type: 'image/png' }),
|
|
311
|
+
previewUrl: 'blob:preview',
|
|
312
|
+
} as any)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
let pending!: Promise<void>
|
|
316
|
+
await act(async () => {
|
|
317
|
+
pending = h.result.submit()
|
|
318
|
+
await Promise.resolve()
|
|
319
|
+
})
|
|
320
|
+
expect(h.result.phase.kind).toBe('uploading')
|
|
321
|
+
|
|
322
|
+
await act(async () => {
|
|
323
|
+
blocked = true
|
|
324
|
+
upload.resolve()
|
|
325
|
+
await pending
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
329
|
+
expect(h.ctx.hasChanges()).toBe(true)
|
|
330
|
+
expect(h.result.phase.kind).toBe('idle')
|
|
331
|
+
expect(h.result.isBusy).toBe(false)
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('returns to idle when a mutation block arrives while confirming system fields', async () => {
|
|
335
|
+
const onSubmit = vi.fn(async () => {})
|
|
336
|
+
let blocked = false
|
|
337
|
+
const h = renderHookInForm({
|
|
338
|
+
mode: 'edit',
|
|
339
|
+
documentId: 'doc-1',
|
|
340
|
+
onSubmit,
|
|
341
|
+
isBlocked: () => blocked,
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
act(() => {
|
|
345
|
+
h.ctx.setSystemPath('/moved')
|
|
346
|
+
})
|
|
347
|
+
await act(async () => {
|
|
348
|
+
await h.result.submit()
|
|
349
|
+
})
|
|
350
|
+
expect(h.result.phase.kind).toBe('confirmingSystemFields')
|
|
351
|
+
|
|
352
|
+
await act(async () => {
|
|
353
|
+
blocked = true
|
|
354
|
+
await h.result.confirmSystemFields()
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
358
|
+
expect(h.result.phase.kind).toBe('idle')
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it('returns to idle when no submit handler is supplied', async () => {
|
|
362
|
+
const h = renderHookInForm({ onSubmit: undefined as any })
|
|
363
|
+
|
|
364
|
+
await act(async () => {
|
|
365
|
+
await h.result.submit()
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
expect(h.result.phase.kind).toBe('idle')
|
|
369
|
+
expect(h.result.isBusy).toBe(false)
|
|
370
|
+
})
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
describe('useFormSubmission system-field confirmation', () => {
|
|
374
|
+
const editOptions = { mode: 'edit' as const, documentId: 'doc-1' }
|
|
375
|
+
|
|
376
|
+
it('holds the payload for confirmation when only system fields are dirty', async () => {
|
|
377
|
+
const onSubmit = vi.fn(async () => {})
|
|
378
|
+
const h = renderHookInForm({ ...editOptions, onSubmit })
|
|
379
|
+
|
|
380
|
+
act(() => {
|
|
381
|
+
h.ctx.setSystemPath('/moved')
|
|
382
|
+
})
|
|
383
|
+
await act(async () => {
|
|
384
|
+
await h.result.submit()
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
388
|
+
expect(h.result.phase.kind).toBe('confirmingSystemFields')
|
|
389
|
+
if (h.result.phase.kind !== 'confirmingSystemFields') throw new Error('unreachable')
|
|
390
|
+
expect(h.result.phase.payload.pathDirty).toBe(true)
|
|
391
|
+
expect(h.result.phase.payload.systemPath).toBe('/moved')
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('submits the held payload on confirmation', async () => {
|
|
395
|
+
const onSubmit = vi.fn(async () => {})
|
|
396
|
+
const h = renderHookInForm({ ...editOptions, onSubmit })
|
|
397
|
+
|
|
398
|
+
act(() => {
|
|
399
|
+
h.ctx.setSystemPath('/moved')
|
|
400
|
+
})
|
|
401
|
+
await act(async () => {
|
|
402
|
+
await h.result.submit()
|
|
403
|
+
})
|
|
404
|
+
await act(async () => {
|
|
405
|
+
await h.result.confirmSystemFields()
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
409
|
+
expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ pathDirty: true, systemPath: '/moved' })
|
|
410
|
+
expect(h.result.phase.kind).toBe('idle')
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('discards the held payload on cancellation', async () => {
|
|
414
|
+
const onSubmit = vi.fn(async () => {})
|
|
415
|
+
const h = renderHookInForm({ ...editOptions, onSubmit })
|
|
416
|
+
|
|
417
|
+
act(() => {
|
|
418
|
+
h.ctx.setSystemPath('/moved')
|
|
419
|
+
})
|
|
420
|
+
await act(async () => {
|
|
421
|
+
await h.result.submit()
|
|
422
|
+
})
|
|
423
|
+
act(() => {
|
|
424
|
+
h.result.cancelSystemFields()
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
428
|
+
expect(h.result.phase.kind).toBe('idle')
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
it('does not confirm in create mode', async () => {
|
|
432
|
+
const onSubmit = vi.fn(async () => {})
|
|
433
|
+
const h = renderHookInForm({ mode: 'create', onSubmit })
|
|
434
|
+
|
|
435
|
+
act(() => {
|
|
436
|
+
h.ctx.setSystemPath('/moved')
|
|
437
|
+
})
|
|
438
|
+
await act(async () => {
|
|
439
|
+
await h.result.submit()
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
443
|
+
})
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
describe('useFormSubmission busy transitions', () => {
|
|
447
|
+
it('signals onBeforeBusy before the form becomes busy', async () => {
|
|
448
|
+
const order: string[] = []
|
|
449
|
+
const h = renderHookInForm({
|
|
450
|
+
onBeforeBusy: () => order.push('beforeBusy'),
|
|
451
|
+
onSubmit: async () => {
|
|
452
|
+
order.push('submit')
|
|
453
|
+
},
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
await act(async () => {
|
|
457
|
+
await h.result.submit()
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
expect(order).toEqual(['beforeBusy', 'submit'])
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
it('does not signal onBeforeBusy when validation fails', async () => {
|
|
464
|
+
const onBeforeBusy = vi.fn()
|
|
465
|
+
const onSubmit = vi.fn(async () => {})
|
|
466
|
+
const required = [{ name: 'title', label: 'Title', type: 'text' as const, required: true }]
|
|
467
|
+
const h = renderHookInForm({ fields: required, onBeforeBusy, onSubmit })
|
|
468
|
+
|
|
469
|
+
act(() => {
|
|
470
|
+
h.ctx.setFieldValue('title', '')
|
|
471
|
+
})
|
|
472
|
+
await act(async () => {
|
|
473
|
+
await h.result.submit()
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
477
|
+
expect(onBeforeBusy).not.toHaveBeenCalled()
|
|
478
|
+
expect(h.result.phase.kind).toBe('idle')
|
|
479
|
+
})
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
describe('useFormSubmission upload sequencing', () => {
|
|
483
|
+
const queueUpload = (h: Harness) => {
|
|
484
|
+
act(() => {
|
|
485
|
+
h.ctx.addPendingUpload('cover', {
|
|
486
|
+
file: new File(['x'], 'x.png', { type: 'image/png' }),
|
|
487
|
+
previewUrl: 'blob:preview',
|
|
488
|
+
} as any)
|
|
489
|
+
})
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
it('uploads before submitting and returns to idle', async () => {
|
|
493
|
+
const order: string[] = []
|
|
494
|
+
const h = renderHookInForm(
|
|
495
|
+
{
|
|
496
|
+
onSubmit: async () => {
|
|
497
|
+
order.push('submit')
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
uploadField: async () => {
|
|
502
|
+
order.push('upload')
|
|
503
|
+
return { id: 'file-1', filename: 'x.png' }
|
|
504
|
+
},
|
|
505
|
+
}
|
|
506
|
+
)
|
|
507
|
+
queueUpload(h)
|
|
508
|
+
|
|
509
|
+
await act(async () => {
|
|
510
|
+
await h.result.submit()
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
expect(order).toEqual(['upload', 'submit'])
|
|
514
|
+
expect(h.result.phase.kind).toBe('idle')
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
it('reports the uploading phase while uploads are in flight', async () => {
|
|
518
|
+
const gate = deferred()
|
|
519
|
+
const h = renderHookInForm(
|
|
520
|
+
{},
|
|
521
|
+
{
|
|
522
|
+
uploadField: async () => {
|
|
523
|
+
await gate.promise
|
|
524
|
+
return { id: 'file-1', filename: 'x.png' }
|
|
525
|
+
},
|
|
526
|
+
}
|
|
527
|
+
)
|
|
528
|
+
queueUpload(h)
|
|
529
|
+
|
|
530
|
+
let pending!: Promise<void>
|
|
531
|
+
await act(async () => {
|
|
532
|
+
pending = h.result.submit()
|
|
533
|
+
await Promise.resolve()
|
|
534
|
+
})
|
|
535
|
+
expect(h.result.phase.kind).toBe('uploading')
|
|
536
|
+
expect(h.result.isBusy).toBe(true)
|
|
537
|
+
|
|
538
|
+
await act(async () => {
|
|
539
|
+
gate.resolve()
|
|
540
|
+
await pending
|
|
541
|
+
})
|
|
542
|
+
expect(h.result.phase.kind).toBe('idle')
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
it('does not submit when an upload fails', async () => {
|
|
546
|
+
const onSubmit = vi.fn(async () => {})
|
|
547
|
+
const h = renderHookInForm(
|
|
548
|
+
{ onSubmit },
|
|
549
|
+
{
|
|
550
|
+
uploadField: async () => {
|
|
551
|
+
throw new Error('upload exploded')
|
|
552
|
+
},
|
|
553
|
+
}
|
|
554
|
+
)
|
|
555
|
+
queueUpload(h)
|
|
556
|
+
|
|
557
|
+
await act(async () => {
|
|
558
|
+
await h.result.submit()
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
562
|
+
expect(h.result.phase.kind).toBe('idle')
|
|
563
|
+
})
|
|
564
|
+
})
|