@byline/admin 6.3.0 → 6.5.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.
Files changed (62) hide show
  1. package/dist/fields/array/array-field.js +29 -12
  2. package/dist/fields/blocks/blocks-field.js +20 -8
  3. package/dist/fields/checkbox/checkbox-field.js +1 -0
  4. package/dist/fields/datetime/datetime-field.js +1 -0
  5. package/dist/fields/file/file-field.js +3 -2
  6. package/dist/fields/group/group-field.js +5 -2
  7. package/dist/fields/image/image-field.js +3 -2
  8. package/dist/fields/relation/relation-field.js +5 -0
  9. package/dist/fields/relation/relation-many-field.js +9 -2
  10. package/dist/fields/select/select-field.js +1 -0
  11. package/dist/fields/text/text-field.js +4 -2
  12. package/dist/fields/text-area/text-area-field.js +4 -2
  13. package/dist/fields/use-field-change-handler.js +24 -6
  14. package/dist/fields/use-field-condition.d.ts +0 -15
  15. package/dist/fields/use-field-condition.js +10 -1
  16. package/dist/forms/form-context.d.ts +3 -12
  17. package/dist/forms/form-context.js +111 -129
  18. package/dist/forms/form-layout.js +7 -2
  19. package/dist/forms/form-page-chrome.js +14 -24
  20. package/dist/forms/form-renderer.d.ts +2 -1
  21. package/dist/forms/form-renderer.js +12 -56
  22. package/dist/forms/nested-path.d.ts +2 -0
  23. package/dist/forms/nested-path.js +26 -1
  24. package/dist/forms/use-document-reload-recovery.d.ts +22 -0
  25. package/dist/forms/use-document-reload-recovery.js +67 -0
  26. package/dist/forms/use-form-submission.d.ts +1 -1
  27. package/dist/forms/use-form-submission.js +45 -27
  28. package/dist/forms/use-form-tabs.d.ts +2 -2
  29. package/dist/forms/use-form-tabs.js +18 -11
  30. package/dist/modules/admin-account/components/change-password.js +6 -0
  31. package/dist/modules/admin-users/components/create.js +4 -3
  32. package/dist/modules/admin-users/components/set-password.js +4 -0
  33. package/dist/modules/auth/components/sign-in-form.js +4 -3
  34. package/package.json +16 -16
  35. package/src/fields/array/array-field.tsx +24 -12
  36. package/src/fields/blocks/blocks-field.tsx +18 -8
  37. package/src/fields/checkbox/checkbox-field.tsx +1 -0
  38. package/src/fields/datetime/datetime-field.tsx +1 -0
  39. package/src/fields/file/file-field.tsx +3 -2
  40. package/src/fields/group/group-field.tsx +2 -2
  41. package/src/fields/image/image-field.tsx +3 -2
  42. package/src/fields/relation/relation-field.tsx +5 -0
  43. package/src/fields/relation/relation-many-field.tsx +16 -2
  44. package/src/fields/select/select-field.tsx +1 -0
  45. package/src/fields/text/text-field.tsx +3 -2
  46. package/src/fields/text-area/text-area-field.tsx +3 -2
  47. package/src/fields/use-field-change-handler.ts +43 -9
  48. package/src/fields/use-field-condition.ts +17 -1
  49. package/src/forms/form-context.tsx +178 -233
  50. package/src/forms/form-correctness.test.tsx +560 -0
  51. package/src/forms/form-layout.tsx +3 -2
  52. package/src/forms/form-page-chrome.tsx +17 -34
  53. package/src/forms/form-renderer-submit.test.tsx +36 -0
  54. package/src/forms/form-renderer.tsx +15 -64
  55. package/src/forms/nested-path.ts +35 -0
  56. package/src/forms/use-document-reload-recovery.ts +92 -0
  57. package/src/forms/use-form-submission.ts +56 -27
  58. package/src/forms/use-form-tabs.ts +25 -17
  59. package/src/modules/admin-account/components/change-password.tsx +6 -0
  60. package/src/modules/admin-users/components/create.tsx +4 -3
  61. package/src/modules/admin-users/components/set-password.tsx +4 -0
  62. package/src/modules/auth/components/sign-in-form.tsx +4 -3
@@ -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
+ })
@@ -8,7 +8,7 @@
8
8
  * Copyright (c) Infonomic Company Limited
9
9
  */
10
10
 
11
- import { Fragment, type ReactNode } from 'react'
11
+ import { Fragment, type ReactNode, useMemo } from 'react'
12
12
 
13
13
  import type {
14
14
  AdminResourceConfig,
@@ -75,7 +75,8 @@ export const FormLayout = ({
75
75
  }: FormLayoutProps): ReactNode => {
76
76
  const { fieldByName, tabSetByName, rowByName, groupByName, layout, fieldToTabPath } =
77
77
  useFormLayout(adminConfig, fields)
78
- const tabs = useFormTabs(fieldToTabPath, activeTabBySet)
78
+ const tabSets = useMemo(() => [...tabSetByName.values()], [tabSetByName])
79
+ const tabs = useFormTabs(fieldToTabPath, activeTabBySet, tabSets)
79
80
  // Two forms over one collection share its admin configuration, so their tab
80
81
  // sets carry the same name and would otherwise derive the same trigger and
81
82
  // panel ids. `aria-labelledby` resolves document-wide, so the second form's
@@ -117,6 +117,21 @@ export const FormStatusBar = ({
117
117
  }: FormStatusBarProps): ReactNode => {
118
118
  const { t } = useTranslation('byline-admin')
119
119
  const { primaryStatus, secondaryStatuses, isTerminal } = transitions
120
+ const runTransition = async (status: string) => {
121
+ if (isBlocked() || !onStatusChange) return
122
+ if (hasChanges) {
123
+ onUnsavedChanges()
124
+ return
125
+ }
126
+ onStatusBusyChange(true)
127
+ try {
128
+ await onStatusChange(status)
129
+ } catch (error) {
130
+ onMutationError?.(error)
131
+ } finally {
132
+ onStatusBusyChange(false)
133
+ }
134
+ }
120
135
 
121
136
  return (
122
137
  <div className={cx('byline-form-status-bar', styles['status-bar'])}>
@@ -192,40 +207,8 @@ export const FormStatusBar = ({
192
207
  type="button"
193
208
  intent={isTerminal ? 'info' : 'success'}
194
209
  disabled={disabled || statusBusy}
195
- onOptionSelect={async (value: string) => {
196
- if (isBlocked()) return
197
- if (hasChanges) {
198
- onUnsavedChanges()
199
- return
200
- }
201
- onStatusBusyChange(true)
202
- try {
203
- await onStatusChange(value)
204
- } catch (error) {
205
- onMutationError?.(error)
206
- } finally {
207
- onStatusBusyChange(false)
208
- }
209
- }}
210
- onButtonClick={
211
- isTerminal
212
- ? undefined
213
- : async () => {
214
- if (isBlocked()) return
215
- if (hasChanges) {
216
- onUnsavedChanges()
217
- return
218
- }
219
- onStatusBusyChange(true)
220
- try {
221
- await onStatusChange(primaryStatus.name)
222
- } catch (error) {
223
- onMutationError?.(error)
224
- } finally {
225
- onStatusBusyChange(false)
226
- }
227
- }
228
- }
210
+ onOptionSelect={runTransition}
211
+ onButtonClick={isTerminal ? undefined : () => runTransition(primaryStatus.name)}
229
212
  >
230
213
  {statusBusy
231
214
  ? '...'
@@ -306,6 +306,42 @@ describe('FormRenderer submit contract', () => {
306
306
  document.body.removeAttribute('tabindex')
307
307
  })
308
308
 
309
+ it('starts a clean baseline after a successful save installs a new document version', async () => {
310
+ const onSubmit = vi.fn(async () => {})
311
+ const props = {
312
+ ...baseProps,
313
+ mode: 'edit',
314
+ onSubmit,
315
+ initialData: { id: 'doc', versionId: 'v1', revision: 1, fields: { title: 'Original' } },
316
+ }
317
+ render(props)
318
+ typeIntoTitle('Saved')
319
+ submitForm()
320
+ await act(async () => {})
321
+ expect(onSubmit.mock.calls).toHaveLength(1)
322
+ render({
323
+ ...props,
324
+ initialData: {
325
+ ...props.initialData,
326
+ versionId: 'v2',
327
+ revision: 2,
328
+ fields: { title: 'Saved' },
329
+ },
330
+ })
331
+ expect(container.querySelector<HTMLInputElement>('input[name="title"]')?.value).toBe('Saved')
332
+ typeIntoTitle('Next edit')
333
+ submitForm()
334
+ await act(async () => {})
335
+ const payload = (onSubmit.mock.calls as unknown as Array<[any]>)[1]?.[0]
336
+ expect(payload.data.title).toBe('Next edit')
337
+ expect(payload.patches).toHaveLength(1)
338
+ expect(payload.patches[0]).toMatchObject({
339
+ kind: 'field.set',
340
+ path: 'title',
341
+ value: 'Next edit',
342
+ })
343
+ })
344
+
309
345
  it('ignores a second submit while the first is still in flight', async () => {
310
346
  const submission = deferred()
311
347
  const onSubmit = vi.fn(async () => {