@byline/admin 6.1.0 → 6.2.1

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 (119) hide show
  1. package/dist/forms/form-layout-styles.test.node.d.ts +8 -0
  2. package/dist/forms/form-layout.d.ts +39 -0
  3. package/dist/forms/form-layout.js +100 -0
  4. package/dist/forms/form-page-chrome.d.ts +110 -0
  5. package/dist/forms/form-page-chrome.js +253 -0
  6. package/dist/forms/form-renderer.d.ts +2 -18
  7. package/dist/forms/form-renderer.js +104 -418
  8. package/dist/forms/form-renderer.module.js +2 -0
  9. package/dist/forms/form-renderer_module.css +13 -0
  10. package/dist/forms/use-form-submission.d.ts +74 -0
  11. package/dist/forms/use-form-submission.js +166 -0
  12. package/dist/forms/use-form-tabs.d.ts +29 -0
  13. package/dist/forms/use-form-tabs.js +45 -0
  14. package/dist/presentation/tab-strip-drag.d.ts +75 -0
  15. package/dist/presentation/tab-strip-drag.js +25 -0
  16. package/dist/presentation/tab-strip-drag.test.node.d.ts +8 -0
  17. package/dist/presentation/tab-strip-geometry.d.ts +75 -0
  18. package/dist/presentation/tab-strip-geometry.js +36 -0
  19. package/dist/presentation/tab-strip-geometry.test.node.d.ts +8 -0
  20. package/dist/presentation/tabs.d.ts +30 -7
  21. package/dist/presentation/tabs.js +260 -29
  22. package/dist/presentation/tabs.module.js +11 -1
  23. package/dist/presentation/tabs_module.css +166 -5
  24. package/package.json +7 -7
  25. package/src/forms/form-layout-styles.test.node.ts +32 -0
  26. package/src/forms/form-layout.test.tsx +276 -0
  27. package/src/forms/form-layout.tsx +198 -0
  28. package/src/forms/form-page-chrome.tsx +460 -0
  29. package/src/forms/form-renderer-submit.test.tsx +40 -0
  30. package/src/forms/form-renderer-tab-persistence.test.tsx +145 -0
  31. package/src/forms/form-renderer.module.css +18 -0
  32. package/src/forms/form-renderer.tsx +115 -593
  33. package/src/forms/use-form-submission.test.tsx +564 -0
  34. package/src/forms/use-form-submission.ts +293 -0
  35. package/src/forms/use-form-tabs.ts +89 -0
  36. package/src/presentation/tab-strip-drag.test.node.ts +143 -0
  37. package/src/presentation/tab-strip-drag.ts +114 -0
  38. package/src/presentation/tab-strip-geometry.test.node.ts +254 -0
  39. package/src/presentation/tab-strip-geometry.ts +158 -0
  40. package/src/presentation/tabs.module.css +209 -11
  41. package/src/presentation/tabs.test.tsx +481 -0
  42. package/src/presentation/tabs.tsx +369 -34
  43. package/dist/exports-parity.test.node.js +0 -37
  44. package/dist/fields/array/array-field.jsx +0 -209
  45. package/dist/fields/blocks/blocks-field.jsx +0 -248
  46. package/dist/fields/checkbox/checkbox-field.jsx +0 -24
  47. package/dist/fields/code/code-editor.jsx +0 -211
  48. package/dist/fields/code/code-field.jsx +0 -109
  49. package/dist/fields/column-formatter.jsx +0 -25
  50. package/dist/fields/date-time-formatter.jsx +0 -18
  51. package/dist/fields/datetime/datetime-field.jsx +0 -22
  52. package/dist/fields/draggable-context-menu.jsx +0 -50
  53. package/dist/fields/field-admin.test.node.js +0 -40
  54. package/dist/fields/field-renderer.jsx +0 -136
  55. package/dist/fields/field-services-context.jsx +0 -17
  56. package/dist/fields/file/file-field.jsx +0 -176
  57. package/dist/fields/file/file-upload-field.jsx +0 -89
  58. package/dist/fields/group/group-field.jsx +0 -46
  59. package/dist/fields/image/image-field.jsx +0 -183
  60. package/dist/fields/image/image-upload-field.jsx +0 -157
  61. package/dist/fields/local-date-time.jsx +0 -69
  62. package/dist/fields/locale-badge.jsx +0 -19
  63. package/dist/fields/numerical/numerical-field.jsx +0 -93
  64. package/dist/fields/relation/relation-column-formatter.jsx +0 -74
  65. package/dist/fields/relation/relation-display.jsx +0 -118
  66. package/dist/fields/relation/relation-field.jsx +0 -108
  67. package/dist/fields/relation/relation-many-field.jsx +0 -142
  68. package/dist/fields/relation/relation-picker.jsx +0 -240
  69. package/dist/fields/relation/relation-summary.jsx +0 -45
  70. package/dist/fields/select/select-field.jsx +0 -27
  71. package/dist/fields/sortable-item.jsx +0 -54
  72. package/dist/fields/text/text-field.jsx +0 -79
  73. package/dist/fields/text-area/text-area-field.jsx +0 -79
  74. package/dist/forms/available-locales-reconcile.test.node.js +0 -43
  75. package/dist/forms/available-locales-widget.jsx +0 -63
  76. package/dist/forms/document-actions.jsx +0 -486
  77. package/dist/forms/form-context.jsx +0 -598
  78. package/dist/forms/form-modals.jsx +0 -128
  79. package/dist/forms/form-renderer.jsx +0 -532
  80. package/dist/forms/form-status-display.jsx +0 -69
  81. package/dist/forms/navigation-guard.jsx +0 -68
  82. package/dist/forms/nested-path.test.node.js +0 -115
  83. package/dist/forms/path-widget.jsx +0 -125
  84. package/dist/forms/pending-uploads.test.node.js +0 -19
  85. package/dist/forms/repeating-items.test.node.js +0 -29
  86. package/dist/forms/scheduled-publication-control.jsx +0 -389
  87. package/dist/forms/scheduled-publication-state.test.node.js +0 -121
  88. package/dist/forms/scheduled-publication-time.test.node.js +0 -70
  89. package/dist/forms/status-transitions.test.node.js +0 -76
  90. package/dist/forms/tree-placement-widget.jsx +0 -160
  91. package/dist/forms/upload-executor.test.node.js +0 -368
  92. package/dist/forms/use-form-layout.test.node.js +0 -69
  93. package/dist/modules/admin-account/components/change-password.jsx +0 -169
  94. package/dist/modules/admin-account/components/container.jsx +0 -164
  95. package/dist/modules/admin-account/components/preferences.jsx +0 -148
  96. package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
  97. package/dist/modules/admin-account/components/update.jsx +0 -186
  98. package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
  99. package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
  100. package/dist/modules/admin-roles/components/create.jsx +0 -155
  101. package/dist/modules/admin-roles/components/permissions.jsx +0 -239
  102. package/dist/modules/admin-roles/components/update.jsx +0 -148
  103. package/dist/modules/admin-users/components/create.jsx +0 -206
  104. package/dist/modules/admin-users/components/roles.jsx +0 -126
  105. package/dist/modules/admin-users/components/set-password.jsx +0 -133
  106. package/dist/modules/admin-users/components/update.jsx +0 -215
  107. package/dist/modules/analytics/components/dashboard.jsx +0 -208
  108. package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
  109. package/dist/modules/analytics/components/timeseries.jsx +0 -193
  110. package/dist/modules/auth/components/sign-in-form.jsx +0 -89
  111. package/dist/modules/auth/safe-redirect.test.node.js +0 -40
  112. package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
  113. package/dist/presentation/group.jsx +0 -26
  114. package/dist/presentation/row.jsx +0 -23
  115. package/dist/presentation/tabs.jsx +0 -39
  116. package/dist/services/admin-services-context.jsx +0 -17
  117. package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
  118. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
  119. package/dist/widgets/status-badge/status-badge.jsx +0 -46
@@ -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
+ })