@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.
Files changed (82) 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 +4 -1
  4. package/dist/fields/code/code-field.js +2 -1
  5. package/dist/fields/datetime/datetime-field.js +5 -2
  6. package/dist/fields/field-renderer.js +2 -1
  7. package/dist/fields/field-services-types.d.ts +23 -0
  8. package/dist/fields/file/file-field.js +6 -4
  9. package/dist/fields/group/group-field.js +8 -3
  10. package/dist/fields/image/image-field.js +6 -4
  11. package/dist/fields/numerical/numerical-field.js +2 -1
  12. package/dist/fields/relation/relation-field.js +8 -2
  13. package/dist/fields/relation/relation-many-field.js +12 -4
  14. package/dist/fields/relation/relation-picker.js +38 -7
  15. package/dist/fields/relation/relation-picker.module.js +3 -0
  16. package/dist/fields/relation/relation-picker_module.css +20 -0
  17. package/dist/fields/select/select-field.js +4 -2
  18. package/dist/fields/text/text-field.js +6 -3
  19. package/dist/fields/text-area/text-area-field.js +6 -3
  20. package/dist/fields/use-field-change-handler.js +24 -6
  21. package/dist/fields/use-field-condition.d.ts +0 -15
  22. package/dist/fields/use-field-condition.js +10 -1
  23. package/dist/forms/form-context.d.ts +3 -12
  24. package/dist/forms/form-context.js +115 -130
  25. package/dist/forms/form-dom-scope.d.ts +34 -0
  26. package/dist/forms/form-dom-scope.js +20 -0
  27. package/dist/forms/form-layout.js +10 -3
  28. package/dist/forms/form-page-chrome.js +14 -24
  29. package/dist/forms/form-renderer.d.ts +2 -1
  30. package/dist/forms/form-renderer.js +13 -56
  31. package/dist/forms/nested-path.d.ts +2 -0
  32. package/dist/forms/nested-path.js +26 -1
  33. package/dist/forms/use-document-reload-recovery.d.ts +22 -0
  34. package/dist/forms/use-document-reload-recovery.js +67 -0
  35. package/dist/forms/use-form-submission.d.ts +1 -1
  36. package/dist/forms/use-form-submission.js +45 -27
  37. package/dist/forms/use-form-tabs.d.ts +2 -2
  38. package/dist/forms/use-form-tabs.js +18 -11
  39. package/dist/presentation/tabs.js +2 -1
  40. package/dist/react.d.ts +1 -1
  41. package/package.json +14 -14
  42. package/src/fields/array/array-field.tsx +24 -12
  43. package/src/fields/blocks/blocks-field.tsx +18 -8
  44. package/src/fields/checkbox/checkbox-field.tsx +4 -1
  45. package/src/fields/code/code-field.tsx +2 -1
  46. package/src/fields/datetime/datetime-field.tsx +5 -2
  47. package/src/fields/direct-widget-dom-ids.test.tsx +142 -0
  48. package/src/fields/field-dom-ids.test.tsx +293 -0
  49. package/src/fields/field-renderer.tsx +2 -1
  50. package/src/fields/field-services-types.ts +25 -0
  51. package/src/fields/file/file-field.tsx +6 -4
  52. package/src/fields/group/group-field.tsx +5 -3
  53. package/src/fields/image/image-field.tsx +6 -4
  54. package/src/fields/numerical/numerical-field.tsx +2 -1
  55. package/src/fields/relation/relation-field.tsx +8 -2
  56. package/src/fields/relation/relation-many-field.tsx +19 -4
  57. package/src/fields/relation/relation-picker-create.test.tsx +203 -0
  58. package/src/fields/relation/relation-picker-refresh.test.tsx +325 -0
  59. package/src/fields/relation/relation-picker.module.css +32 -0
  60. package/src/fields/relation/relation-picker.tsx +59 -1
  61. package/src/fields/select/select-field.tsx +4 -2
  62. package/src/fields/text/text-field.tsx +5 -3
  63. package/src/fields/text-area/text-area-field.tsx +5 -3
  64. package/src/fields/use-field-change-handler.ts +43 -9
  65. package/src/fields/use-field-condition.ts +17 -1
  66. package/src/forms/form-context.tsx +180 -234
  67. package/src/forms/form-correctness.test.tsx +560 -0
  68. package/src/forms/form-dom-scope.test.tsx +213 -0
  69. package/src/forms/form-dom-scope.tsx +70 -0
  70. package/src/forms/form-layout.tsx +10 -3
  71. package/src/forms/form-page-chrome.tsx +17 -34
  72. package/src/forms/form-renderer-submit.test.tsx +36 -0
  73. package/src/forms/form-renderer.tsx +26 -64
  74. package/src/forms/form-submit-isolation.test.tsx +247 -0
  75. package/src/forms/nested-path.ts +35 -0
  76. package/src/forms/use-document-reload-recovery.ts +92 -0
  77. package/src/forms/use-form-submission.ts +56 -27
  78. package/src/forms/use-form-tabs.ts +25 -17
  79. package/src/presentation/tab-dom-ids.test.tsx +153 -0
  80. package/src/presentation/tabs.test.tsx +49 -2
  81. package/src/presentation/tabs.tsx +11 -1
  82. package/src/react.ts +2 -0
@@ -0,0 +1,247 @@
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 { createPortal } from 'react-dom'
15
+ import { createRoot, type Root } from 'react-dom/client'
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
17
+
18
+ import { BylineFieldServicesProvider } from '../fields/field-services-context'
19
+ import { FormRenderer } from './form-renderer'
20
+
21
+ const globalWithAct = globalThis as any
22
+ globalWithAct.IS_REACT_ACT_ENVIRONMENT = true
23
+
24
+ defineAdminConfig({
25
+ i18n: {
26
+ admin: { defaultLocale: 'en', locales: ['en'] },
27
+ content: { defaultLocale: 'en', locales: ['en'] },
28
+ },
29
+ collections: [
30
+ {
31
+ path: 'pages',
32
+ labels: { singular: 'Page', plural: 'Pages' },
33
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
34
+ },
35
+ ],
36
+ slugifier: (value: string) => value.toLowerCase().trim().replace(/\s+/g, '-'),
37
+ })
38
+
39
+ const fieldServices = {
40
+ getCollectionDocuments: async () => ({ docs: [], total: 0 }),
41
+ uploadField: async () => ({}),
42
+ } as any
43
+
44
+ // Optional so submission is not blocked by validation: this file tests which
45
+ // form a submit event belongs to, not what the form does with it.
46
+ const fields = [{ name: 'title', label: 'Title', type: 'text' as const, optional: true }]
47
+
48
+ let container: HTMLDivElement
49
+ let root: Root
50
+ let portalHost: HTMLDivElement
51
+
52
+ beforeEach(() => {
53
+ container = document.createElement('div')
54
+ document.body.appendChild(container)
55
+ portalHost = document.createElement('div')
56
+ document.body.appendChild(portalHost)
57
+ root = createRoot(container)
58
+ })
59
+
60
+ afterEach(() => {
61
+ act(() => {
62
+ root.unmount()
63
+ })
64
+ container.remove()
65
+ portalHost.remove()
66
+ })
67
+
68
+ /**
69
+ * A nested form rendered through a portal: outside the parent `<form>` in the
70
+ * DOM, so no invalid nested-form markup arises, but still a descendant in the
71
+ * *React* tree — which is what React propagates synthetic events along.
72
+ */
73
+ const PortalledInnerForm = ({
74
+ stopPropagation,
75
+ onInnerSubmit,
76
+ }: {
77
+ stopPropagation: boolean
78
+ onInnerSubmit: () => void
79
+ }) =>
80
+ createPortal(
81
+ <form
82
+ data-testid="inner-form"
83
+ onSubmit={(event) => {
84
+ event.preventDefault()
85
+ if (stopPropagation) event.stopPropagation()
86
+ onInnerSubmit()
87
+ }}
88
+ >
89
+ <input name="innerTitle" defaultValue="" />
90
+ <button type="submit">Save inner</button>
91
+ </form>,
92
+ portalHost
93
+ )
94
+
95
+ const renderWithInnerForm = ({
96
+ parentSubmit,
97
+ onInnerSubmit = () => {},
98
+ stopPropagation = false,
99
+ }: {
100
+ parentSubmit: () => Promise<void>
101
+ onInnerSubmit?: () => void
102
+ stopPropagation?: boolean
103
+ }) => {
104
+ act(() => {
105
+ root.render(
106
+ <I18nProvider
107
+ bundle={adminTranslations({ locales: ['en'] })}
108
+ activeLocale="en"
109
+ defaultLocale="en"
110
+ localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
111
+ >
112
+ <BylineFieldServicesProvider services={fieldServices}>
113
+ <FormRenderer
114
+ mode="create"
115
+ fields={fields}
116
+ collectionPath="pages"
117
+ onCancel={() => {}}
118
+ onSubmit={parentSubmit}
119
+ headerSlot={
120
+ <PortalledInnerForm stopPropagation={stopPropagation} onInnerSubmit={onInnerSubmit} />
121
+ }
122
+ />
123
+ </BylineFieldServicesProvider>
124
+ </I18nProvider>
125
+ )
126
+ })
127
+ }
128
+
129
+ const parentForm = () => {
130
+ const form = container.querySelector('form')
131
+ if (form == null) throw new Error('parent form not found')
132
+ return form
133
+ }
134
+
135
+ const innerForm = () => {
136
+ const form = portalHost.querySelector<HTMLFormElement>('[data-testid="inner-form"]')
137
+ if (form == null) throw new Error('inner form not found')
138
+ return form
139
+ }
140
+
141
+ const submit = async (form: HTMLFormElement) => {
142
+ await act(async () => {
143
+ form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
144
+ })
145
+ }
146
+
147
+ describe('submit isolation across a portal', () => {
148
+ /**
149
+ * The inner form here deliberately does **not** call `stopPropagation`, so
150
+ * these two cases exercise the parent's own guard. With the child-side defence
151
+ * in place as well, a broken parent guard would be invisible.
152
+ */
153
+ it('does not run the parent submit handler when a portalled inner form submits', async () => {
154
+ const parentSubmit = vi.fn(async () => {})
155
+ renderWithInnerForm({ parentSubmit })
156
+
157
+ await submit(innerForm())
158
+
159
+ expect(parentSubmit).not.toHaveBeenCalled()
160
+ })
161
+
162
+ it('still runs the inner form’s own handler', async () => {
163
+ const parentSubmit = vi.fn(async () => {})
164
+ const onInnerSubmit = vi.fn()
165
+ renderWithInnerForm({ parentSubmit, onInnerSubmit })
166
+
167
+ await submit(innerForm())
168
+
169
+ expect(onInnerSubmit).toHaveBeenCalledTimes(1)
170
+ expect(parentSubmit).not.toHaveBeenCalled()
171
+ })
172
+
173
+ it('still submits normally from the parent form itself', async () => {
174
+ const parentSubmit = vi.fn(async () => {})
175
+ renderWithInnerForm({ parentSubmit })
176
+
177
+ await submit(parentForm())
178
+
179
+ expect(parentSubmit).toHaveBeenCalledTimes(1)
180
+ })
181
+
182
+ it('leaves the parent form submitting on repeat after an inner submit', async () => {
183
+ const parentSubmit = vi.fn(async () => {})
184
+ renderWithInnerForm({ parentSubmit })
185
+
186
+ await submit(innerForm())
187
+ await submit(parentForm())
188
+ await submit(parentForm())
189
+
190
+ expect(parentSubmit).toHaveBeenCalledTimes(2)
191
+ })
192
+ })
193
+
194
+ /**
195
+ * The child-side defence, proved without the parent guard in the picture. If this
196
+ * were asserted against `FormRenderer`, a working parent guard would satisfy it
197
+ * whether or not the child stopped propagation, and the test would prove nothing
198
+ * about the child.
199
+ */
200
+ describe('the child-side stopPropagation defence', () => {
201
+ const PlainParent = ({
202
+ onParentSubmit,
203
+ stopPropagation,
204
+ }: {
205
+ onParentSubmit: () => void
206
+ stopPropagation: boolean
207
+ }) => (
208
+ <form
209
+ data-testid="plain-parent"
210
+ onSubmit={(event) => {
211
+ event.preventDefault()
212
+ onParentSubmit()
213
+ }}
214
+ >
215
+ <PortalledInnerForm stopPropagation={stopPropagation} onInnerSubmit={() => {}} />
216
+ </form>
217
+ )
218
+
219
+ const renderPlain = (props: { onParentSubmit: () => void; stopPropagation: boolean }) => {
220
+ act(() => {
221
+ root.render(<PlainParent {...props} />)
222
+ })
223
+ }
224
+
225
+ it('keeps an unguarded parent handler from running when the child stops propagation', async () => {
226
+ const onParentSubmit = vi.fn()
227
+ renderPlain({ onParentSubmit, stopPropagation: true })
228
+
229
+ await submit(innerForm())
230
+
231
+ expect(onParentSubmit).not.toHaveBeenCalled()
232
+ })
233
+
234
+ /**
235
+ * The control: the same unguarded parent, with the child defence removed. It
236
+ * fails in exactly the way the feature is meant to prevent, which is what makes
237
+ * the assertion above meaningful.
238
+ */
239
+ it('runs an unguarded parent handler when the child does not stop propagation', async () => {
240
+ const onParentSubmit = vi.fn()
241
+ renderPlain({ onParentSubmit, stopPropagation: false })
242
+
243
+ await submit(innerForm())
244
+
245
+ expect(onParentSubmit).toHaveBeenCalledTimes(1)
246
+ })
247
+ })
@@ -146,3 +146,38 @@ export function set<T extends object>(object: T, path: string, value: unknown):
146
146
  setWithResult(object, path, value)
147
147
  return object
148
148
  }
149
+
150
+ /** Clone only the containers along a write path, preserving other snapshots. */
151
+ export function withValue<T extends object>(
152
+ object: T,
153
+ path: string,
154
+ value: unknown
155
+ ): T | undefined {
156
+ const parsed = parseInstancePath(path)
157
+ if (!parsed.ok || !hasExistingIdTargets(object, path)) return undefined
158
+ const result: any = Array.isArray(object) ? [...object] : { ...object }
159
+ let current = result
160
+ for (const [index, segment] of parsed.segments.entries()) {
161
+ const key =
162
+ segment.kind === 'field'
163
+ ? segment.name
164
+ : segment.kind === 'index'
165
+ ? segment.index
166
+ : segment.kind === 'id'
167
+ ? selectId(current, segment.id)
168
+ : undefined
169
+ if (key === undefined || key === -1) return undefined
170
+ if (index === parsed.segments.length - 1) {
171
+ current[key] = value
172
+ return result
173
+ }
174
+ const existing = current[key]
175
+ current[key] = Array.isArray(existing)
176
+ ? [...existing]
177
+ : existing != null && typeof existing === 'object'
178
+ ? { ...existing }
179
+ : newContainer(parsed.segments[index + 1])
180
+ current = current[key]
181
+ }
182
+ return undefined
183
+ }
@@ -0,0 +1,92 @@
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 { useCallback, useEffect, useRef, useState } from 'react'
10
+
11
+ /** Reload admission and focus recovery remain synchronous at event boundaries. */
12
+ export function useDocumentReloadRecovery({
13
+ mutationsBlocked,
14
+ mutationIssue,
15
+ onReloadDocument,
16
+ isBusy,
17
+ }: {
18
+ mutationsBlocked: boolean
19
+ mutationIssue?: string | null
20
+ onReloadDocument?: () => void | Promise<void>
21
+ isBusy: boolean
22
+ }) {
23
+ const formRef = useRef<HTMLFormElement>(null)
24
+ const focusBeforeBusyRef = useRef<HTMLElement | null>(null)
25
+ const restoreFocusAfterBusyRef = useRef(false)
26
+ const [discarding, setDiscarding] = useState(false)
27
+ const [reloadFailed, setReloadFailed] = useState(false)
28
+ const warningRef = useRef<HTMLDivElement>(null)
29
+ const mutationBlockedRef = useRef(mutationsBlocked)
30
+ mutationBlockedRef.current = mutationsBlocked || discarding
31
+ useEffect(() => {
32
+ if (mutationIssue) warningRef.current?.focus()
33
+ }, [mutationIssue])
34
+ useEffect(() => {
35
+ if (!discarding) return
36
+ // Let the guard's beforeunload listener detach before the explicit discard.
37
+ Promise.resolve()
38
+ .then(() => (onReloadDocument ? onReloadDocument() : window.location.reload()))
39
+ .catch(() => {
40
+ setDiscarding(false)
41
+ setReloadFailed(true)
42
+ })
43
+ }, [discarding, onReloadDocument])
44
+
45
+ const captureFocusBeforeBusy = useCallback(() => {
46
+ if (focusBeforeBusyRef.current != null) return
47
+ const activeElement = document.activeElement
48
+ if (!(activeElement instanceof HTMLElement) || !formRef.current?.contains(activeElement)) return
49
+ focusBeforeBusyRef.current = activeElement
50
+ restoreFocusAfterBusyRef.current = true
51
+ }, [])
52
+
53
+ // `inert` removes the active control from the tab order while a save is in
54
+ // flight. Restore the editor's position after React has removed `inert`;
55
+ // when the original control became disabled, fall back to the first usable
56
+ // form control instead of leaving focus on <body>.
57
+ useEffect(() => {
58
+ if (isBusy || !restoreFocusAfterBusyRef.current) return
59
+ restoreFocusAfterBusyRef.current = false
60
+
61
+ if (mutationIssue) {
62
+ warningRef.current?.focus()
63
+ focusBeforeBusyRef.current = null
64
+ return
65
+ }
66
+ const original = focusBeforeBusyRef.current
67
+ focusBeforeBusyRef.current = null
68
+ const originalCanReceiveFocus =
69
+ original?.isConnected === true && !original.matches(':disabled, [aria-disabled="true"]')
70
+ const target = originalCanReceiveFocus
71
+ ? original
72
+ : formRef.current?.querySelector<HTMLElement>(
73
+ 'input:not(:disabled), textarea:not(:disabled), select:not(:disabled), button:not(:disabled), [tabindex]:not([tabindex="-1"])'
74
+ )
75
+ target?.focus({ preventScroll: true })
76
+ }, [isBusy, mutationIssue])
77
+
78
+ const requestDiscard = useCallback(() => {
79
+ mutationBlockedRef.current = true
80
+ setReloadFailed(false)
81
+ setDiscarding(true)
82
+ }, [])
83
+ return {
84
+ formRef,
85
+ warningRef,
86
+ mutationBlockedRef,
87
+ discarding,
88
+ reloadFailed,
89
+ requestDiscard,
90
+ captureFocusBeforeBusy,
91
+ }
92
+ }
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { useCallback, useRef, useState } from 'react'
12
12
 
13
- import type { Field } from '@byline/core'
13
+ import { type Field, getDocumentFieldValidationDetails } from '@byline/core'
14
14
  import type { DocumentPatch } from '@byline/core/patches'
15
15
  import { useTranslation } from '@byline/i18n/react'
16
16
 
@@ -105,7 +105,9 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
105
105
  setFieldValue,
106
106
  setFieldError,
107
107
  getPendingUploads,
108
- clearPendingUploads,
108
+ removePendingUpload,
109
+ clearFieldError,
110
+ waitForFieldChanges,
109
111
  setFieldUploading,
110
112
  } = useFormContext()
111
113
  const { t } = useTranslation('byline-admin')
@@ -113,6 +115,7 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
113
115
 
114
116
  const [phase, setPhase] = useState<SubmissionPhase>(IDLE)
115
117
  const inFlightRef = useRef(false)
118
+ const confirmationRef = useRef<SystemFieldsSubmitPayload | null>(null)
116
119
 
117
120
  const isBusy = phase.kind === 'uploading' || phase.kind === 'submitting'
118
121
 
@@ -136,14 +139,16 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
136
139
  try {
137
140
  await onSubmit(payload)
138
141
  resetHasChanges()
139
- } catch {
142
+ } catch (error) {
143
+ const validation = getDocumentFieldValidationDetails(error)
144
+ for (const issue of validation?.issues ?? []) setFieldError(issue.field, issue.message)
140
145
  // Intentionally swallowed here — the host has already reported the
141
146
  // failure to the user. Dirty state is preserved by not resetting.
142
147
  } finally {
143
148
  setPhase(IDLE)
144
149
  }
145
150
  },
146
- [isBlocked, onBeforeBusy, onSubmit, resetHasChanges]
151
+ [isBlocked, onBeforeBusy, onSubmit, resetHasChanges, setFieldError]
147
152
  )
148
153
 
149
154
  const runUploads = useCallback(async (): Promise<boolean> => {
@@ -170,28 +175,31 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
170
175
  }
171
176
  )
172
177
 
178
+ // Adopt each success before reporting partial failure, so retries only
179
+ // transport failed files. Do not overwrite a selection replaced in flight.
180
+ for (const [fieldPath, storedFile] of uploadResult.successful) {
181
+ if (getPendingUploads().get(fieldPath) !== pendingUploads.get(fieldPath)) continue
182
+ setFieldValue(fieldPath, storedFile)
183
+ clearFieldError(fieldPath)
184
+ removePendingUpload(fieldPath)
185
+ }
173
186
  if (!uploadResult.allSucceeded) {
174
- for (const [fieldPath, errorMessage] of uploadResult.errors.entries()) {
175
- setFieldError(fieldPath, t('forms.uploadFailedFieldError', { message: errorMessage }))
187
+ for (const [fieldPath, errorMessage] of uploadResult.errors) {
188
+ if (getPendingUploads().get(fieldPath) === pendingUploads.get(fieldPath)) {
189
+ setFieldError(fieldPath, t('forms.uploadFailedFieldError', { message: errorMessage }))
190
+ }
176
191
  }
177
- console.error('One or more uploads failed:', uploadResult.errors)
178
192
  return false
179
193
  }
180
-
181
- // Replace pending StoredFileValues with real ones in form data
182
- for (const [fieldPath, storedFile] of uploadResult.successful.entries()) {
183
- setFieldValue(fieldPath, storedFile)
184
- }
185
-
186
- // Clear pending uploads (blob URLs already revoked by clearPendingUploads)
187
- clearPendingUploads()
194
+ if (getPendingUploads().size > 0) return false
188
195
  return true
189
196
  } catch (err) {
190
197
  console.error('Upload execution error:', err)
191
198
  return false
192
199
  }
193
200
  }, [
194
- clearPendingUploads,
201
+ removePendingUpload,
202
+ clearFieldError,
195
203
  documentId,
196
204
  fields,
197
205
  getFieldValues,
@@ -210,8 +218,8 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
210
218
  return {
211
219
  reason,
212
220
  payload: {
213
- data: getFieldValues(),
214
- patches: getPatches(),
221
+ data: structuredClone(getFieldValues()),
222
+ patches: structuredClone(getPatches()),
215
223
  contentDirty,
216
224
  pathDirty,
217
225
  systemPath: getSystemPath(),
@@ -230,21 +238,28 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
230
238
 
231
239
  const submit = useCallback(async () => {
232
240
  if (isBlocked()) return
233
- if (inFlightRef.current) return
241
+ if (inFlightRef.current || confirmationRef.current) return
234
242
  inFlightRef.current = true
235
243
  try {
236
244
  setPhase({ kind: 'validating' })
237
245
 
238
246
  // Run field-level beforeValidate hooks (submit-time), then validate
239
- const hookErrors = await runFieldHooks(fields)
240
- const formErrors = validateForm(fields)
241
- const allErrors = [...hookErrors, ...formErrors]
242
-
243
- if (allErrors.length > 0) {
244
- console.error('Form validation failed:', allErrors)
247
+ await waitForFieldChanges()
248
+ if (isBlocked()) {
245
249
  setPhase(IDLE)
246
250
  return
247
251
  }
252
+ // Metadata-only saves do not create a content version and must remain
253
+ // available even when an older document lacks newly required fields.
254
+ if (mode !== 'edit' || getDirtyBreakdown().reason !== 'direct-write') {
255
+ const hookErrors = await runFieldHooks(fields)
256
+ const formErrors = validateForm(fields, hookErrors)
257
+ if (formErrors.length > 0) {
258
+ console.error('Form validation failed:', formErrors)
259
+ setPhase(IDLE)
260
+ return
261
+ }
262
+ }
248
263
 
249
264
  if (isBlocked()) {
250
265
  setPhase(IDLE)
@@ -263,6 +278,7 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
263
278
  // so confirm it before saving. Create mode writes everything as part of
264
279
  // the initial version, so no confirmation applies there.
265
280
  if (mode === 'edit' && (reason === 'direct-write' || reason === 'both')) {
281
+ confirmationRef.current = payload
266
282
  setPhase({ kind: 'confirmingSystemFields', payload })
267
283
  return
268
284
  }
@@ -271,13 +287,25 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
271
287
  } finally {
272
288
  inFlightRef.current = false
273
289
  }
274
- }, [buildPayload, deliver, fields, isBlocked, mode, runUploads, runFieldHooks, validateForm])
290
+ }, [
291
+ buildPayload,
292
+ getDirtyBreakdown,
293
+ deliver,
294
+ fields,
295
+ isBlocked,
296
+ mode,
297
+ runUploads,
298
+ runFieldHooks,
299
+ validateForm,
300
+ waitForFieldChanges,
301
+ ])
275
302
 
276
303
  const confirmSystemFields = useCallback(async () => {
277
- if (phase.kind !== 'confirmingSystemFields') return
304
+ if (phase.kind !== 'confirmingSystemFields' || confirmationRef.current !== phase.payload) return
278
305
  if (inFlightRef.current) return
279
306
  inFlightRef.current = true
280
307
  const { payload } = phase
308
+ confirmationRef.current = null
281
309
  try {
282
310
  await deliver(payload)
283
311
  } finally {
@@ -286,6 +314,7 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
286
314
  }, [deliver, phase])
287
315
 
288
316
  const cancelSystemFields = useCallback(() => {
317
+ confirmationRef.current = null
289
318
  setPhase((current) => (current.kind === 'confirmingSystemFields' ? IDLE : current))
290
319
  }, [])
291
320
 
@@ -8,9 +8,9 @@
8
8
  * Copyright (c) Infonomic Company Limited
9
9
  */
10
10
 
11
- import { useEffect, useMemo, useState } from 'react'
11
+ import { useCallback, useMemo, useSyncExternalStore } from 'react'
12
12
 
13
- import type { TabDefinition, TabSetDefinition } from '@byline/core'
13
+ import { parseInstancePath, type TabDefinition, type TabSetDefinition } from '@byline/core'
14
14
 
15
15
  import { useFormContext } from './form-context'
16
16
 
@@ -42,27 +42,35 @@ export interface UseFormTabsResult {
42
42
  */
43
43
  export function useFormTabs(
44
44
  fieldToTabPath: Map<string, { tabSetName: string; tabName: string }>,
45
- activeTabBySet: Record<string, string>
45
+ activeTabBySet: Record<string, string>,
46
+ tabSets: TabSetDefinition[]
46
47
  ): UseFormTabsResult {
47
- const { errors: initialErrors, subscribeErrors, subscribeMeta, getFieldValues } = useFormContext()
48
-
49
- const [errors, setErrors] = useState(initialErrors)
50
- useEffect(() => {
51
- return subscribeErrors((newErrors) => setErrors(newErrors))
52
- }, [subscribeErrors])
53
-
54
- // Live form data, so TabDefinition.condition re-evaluates per keystroke.
55
- const [formData, setFormData] = useState<Record<string, any>>(() => getFieldValues())
56
- useEffect(() => {
57
- return subscribeMeta(() => setFormData(getFieldValues()))
58
- }, [subscribeMeta, getFieldValues])
48
+ const { getErrors, subscribeErrors, subscribeMeta, getFieldValues } = useFormContext()
49
+ const errors = useSyncExternalStore(subscribeErrors, getErrors, getErrors)
50
+ const conditionalTabs = useMemo(
51
+ () => tabSets.flatMap((set) => set.tabs.filter((tab) => tab.condition)),
52
+ [tabSets]
53
+ )
54
+ const snapshot = useCallback(
55
+ () => JSON.stringify(conditionalTabs.map((tab) => Boolean(tab.condition?.(getFieldValues())))),
56
+ [conditionalTabs, getFieldValues]
57
+ )
58
+ const subscribe = useCallback(
59
+ (notify: () => void) => (conditionalTabs.length ? subscribeMeta(notify) : () => {}),
60
+ [conditionalTabs, subscribeMeta]
61
+ )
62
+ useSyncExternalStore(subscribe, snapshot, snapshot)
59
63
 
60
64
  // Per-tab-set error counts: { [tabSetName]: { [tabName]: count } }.
61
65
  // Each tab bar consumes its own slice.
62
66
  const errorCountsBySet = useMemo<Record<string, Record<string, number>>>(() => {
63
67
  const result: Record<string, Record<string, number>> = {}
64
68
  for (const err of errors) {
65
- const path = fieldToTabPath.get(err.field)
69
+ const parsed = parseInstancePath(err.field)
70
+ const root = parsed.ok ? parsed.segments[0] : undefined
71
+ const path =
72
+ fieldToTabPath.get(err.field) ??
73
+ (root?.kind === 'field' ? fieldToTabPath.get(root.name) : undefined)
66
74
  if (!path) continue
67
75
  result[path.tabSetName] ??= {}
68
76
  result[path.tabSetName]![path.tabName] = (result[path.tabSetName]?.[path.tabName] ?? 0) + 1
@@ -71,7 +79,7 @@ export function useFormTabs(
71
79
  }, [errors, fieldToTabPath])
72
80
 
73
81
  const resolve = (set: TabSetDefinition): ResolvedTabSet => {
74
- const visibleTabs = set.tabs.filter((tab) => !tab.condition || tab.condition(formData))
82
+ const visibleTabs = set.tabs.filter((tab) => !tab.condition || tab.condition(getFieldValues()))
75
83
  const requested = activeTabBySet[set.name] ?? ''
76
84
  const activeTabName =
77
85
  visibleTabs.length > 0 && !visibleTabs.some((t) => t.name === requested)