@byline/admin 6.3.0 → 6.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/fields/array/array-field.js +29 -12
- package/dist/fields/blocks/blocks-field.js +20 -8
- package/dist/fields/checkbox/checkbox-field.js +1 -0
- package/dist/fields/datetime/datetime-field.js +1 -0
- package/dist/fields/file/file-field.js +3 -2
- package/dist/fields/group/group-field.js +5 -2
- package/dist/fields/image/image-field.js +3 -2
- package/dist/fields/relation/relation-field.js +5 -0
- package/dist/fields/relation/relation-many-field.js +9 -2
- package/dist/fields/select/select-field.js +1 -0
- package/dist/fields/text/text-field.js +4 -2
- package/dist/fields/text-area/text-area-field.js +4 -2
- package/dist/fields/use-field-change-handler.js +24 -6
- package/dist/fields/use-field-condition.d.ts +0 -15
- package/dist/fields/use-field-condition.js +10 -1
- package/dist/forms/form-context.d.ts +3 -12
- package/dist/forms/form-context.js +111 -129
- package/dist/forms/form-layout.js +7 -2
- package/dist/forms/form-page-chrome.js +14 -24
- package/dist/forms/form-renderer.d.ts +2 -1
- package/dist/forms/form-renderer.js +12 -56
- package/dist/forms/nested-path.d.ts +2 -0
- package/dist/forms/nested-path.js +26 -1
- package/dist/forms/use-document-reload-recovery.d.ts +22 -0
- package/dist/forms/use-document-reload-recovery.js +67 -0
- package/dist/forms/use-form-submission.d.ts +1 -1
- package/dist/forms/use-form-submission.js +45 -27
- package/dist/forms/use-form-tabs.d.ts +2 -2
- package/dist/forms/use-form-tabs.js +18 -11
- package/package.json +14 -14
- package/src/fields/array/array-field.tsx +24 -12
- package/src/fields/blocks/blocks-field.tsx +18 -8
- package/src/fields/checkbox/checkbox-field.tsx +1 -0
- package/src/fields/datetime/datetime-field.tsx +1 -0
- package/src/fields/file/file-field.tsx +3 -2
- package/src/fields/group/group-field.tsx +2 -2
- package/src/fields/image/image-field.tsx +3 -2
- package/src/fields/relation/relation-field.tsx +5 -0
- package/src/fields/relation/relation-many-field.tsx +16 -2
- package/src/fields/select/select-field.tsx +1 -0
- package/src/fields/text/text-field.tsx +3 -2
- package/src/fields/text-area/text-area-field.tsx +3 -2
- package/src/fields/use-field-change-handler.ts +43 -9
- package/src/fields/use-field-condition.ts +17 -1
- package/src/forms/form-context.tsx +178 -233
- package/src/forms/form-correctness.test.tsx +560 -0
- package/src/forms/form-layout.tsx +3 -2
- package/src/forms/form-page-chrome.tsx +17 -34
- package/src/forms/form-renderer-submit.test.tsx +36 -0
- package/src/forms/form-renderer.tsx +15 -64
- package/src/forms/nested-path.ts +35 -0
- package/src/forms/use-document-reload-recovery.ts +92 -0
- package/src/forms/use-form-submission.ts +56 -27
- package/src/forms/use-form-tabs.ts +25 -17
|
@@ -33,7 +33,8 @@ import {
|
|
|
33
33
|
useScheduledPublication,
|
|
34
34
|
} from './scheduled-publication-control'
|
|
35
35
|
import { computeStatusTransitions } from './status-transitions'
|
|
36
|
-
import {
|
|
36
|
+
import { useDocumentReloadRecovery } from './use-document-reload-recovery'
|
|
37
|
+
import { type SystemFieldsSubmitPayload, useFormSubmission } from './use-form-submission'
|
|
37
38
|
import type { DocumentActionsLocaleOption } from './document-actions'
|
|
38
39
|
import type { UseNavigationGuard } from './navigation-guard'
|
|
39
40
|
|
|
@@ -63,7 +64,7 @@ export interface FormRendererProps {
|
|
|
63
64
|
onReloadDocument?: () => void | Promise<void>
|
|
64
65
|
mode: 'create' | 'edit'
|
|
65
66
|
fields: Field[]
|
|
66
|
-
onSubmit: (data:
|
|
67
|
+
onSubmit: (data: SystemFieldsSubmitPayload) => void | Promise<void>
|
|
67
68
|
onCancel: () => void
|
|
68
69
|
onStatusChange?: (nextStatus: string) => Promise<void>
|
|
69
70
|
onUnpublish?: () => Promise<void>
|
|
@@ -238,9 +239,6 @@ const FormContent = ({
|
|
|
238
239
|
|
|
239
240
|
const [hasChanges, setHasChanges] = useState(hasChangesFn())
|
|
240
241
|
const [statusBusy, setStatusBusy] = useState(false)
|
|
241
|
-
const formRef = useRef<HTMLFormElement>(null)
|
|
242
|
-
const focusBeforeBusyRef = useRef<HTMLElement | null>(null)
|
|
243
|
-
const restoreFocusAfterBusyRef = useRef(false)
|
|
244
242
|
// Block-only "save first" guard. Set true when the editor triggers a
|
|
245
243
|
// guarded action (status change, duplicate, copy-to-locale) while the form
|
|
246
244
|
// is dirty — those actions operate on the saved version, so unsaved edits
|
|
@@ -332,26 +330,6 @@ const FormContent = ({
|
|
|
332
330
|
// The guard hook is injected by the consuming framework (prop > context > no-op fallback).
|
|
333
331
|
const guardFromContext = useNavigationGuardAdapter()
|
|
334
332
|
const useGuard = useNavigationGuardProp ?? guardFromContext
|
|
335
|
-
const [discarding, setDiscarding] = useState(false)
|
|
336
|
-
const [reloadFailed, setReloadFailed] = useState(false)
|
|
337
|
-
const warningRef = useRef<HTMLDivElement>(null)
|
|
338
|
-
const mutationBlockedRef = useRef(mutationsBlocked)
|
|
339
|
-
mutationBlockedRef.current = mutationsBlocked || discarding
|
|
340
|
-
const guard = useGuard(hasChanges && !discarding)
|
|
341
|
-
useEffect(() => {
|
|
342
|
-
if (mutationIssue) warningRef.current?.focus()
|
|
343
|
-
}, [mutationIssue])
|
|
344
|
-
useEffect(() => {
|
|
345
|
-
if (!discarding) return
|
|
346
|
-
// Let the guard's beforeunload listener detach before the explicit discard.
|
|
347
|
-
Promise.resolve()
|
|
348
|
-
.then(() => (onReloadDocument ? onReloadDocument() : window.location.reload()))
|
|
349
|
-
.catch(() => {
|
|
350
|
-
setDiscarding(false)
|
|
351
|
-
setReloadFailed(true)
|
|
352
|
-
})
|
|
353
|
-
}, [discarding, onReloadDocument])
|
|
354
|
-
|
|
355
333
|
// Compute available status transitions
|
|
356
334
|
const currentStatus = initialData?.status
|
|
357
335
|
const { primaryStatus, secondaryStatuses, isTerminal } = computeStatusTransitions(
|
|
@@ -364,14 +342,6 @@ const FormContent = ({
|
|
|
364
342
|
return subscribeMeta(() => setHasChanges(hasChangesFn()))
|
|
365
343
|
}, [subscribeMeta, hasChangesFn])
|
|
366
344
|
|
|
367
|
-
const captureFocusBeforeBusy = useCallback(() => {
|
|
368
|
-
if (focusBeforeBusyRef.current != null) return
|
|
369
|
-
const activeElement = document.activeElement
|
|
370
|
-
if (!(activeElement instanceof HTMLElement) || !formRef.current?.contains(activeElement)) return
|
|
371
|
-
focusBeforeBusyRef.current = activeElement
|
|
372
|
-
restoreFocusAfterBusyRef.current = true
|
|
373
|
-
}, [])
|
|
374
|
-
|
|
375
345
|
// One save at a time: validate -> upload -> (confirm) -> submit. Admission is
|
|
376
346
|
// decided synchronously inside the hook, so two Saves in the same turn cannot
|
|
377
347
|
// both get through. `phase` is what the UI renders.
|
|
@@ -381,40 +351,24 @@ const FormContent = ({
|
|
|
381
351
|
documentId: mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : undefined,
|
|
382
352
|
advertiseLocales,
|
|
383
353
|
onSubmit,
|
|
384
|
-
isBlocked: () => mutationBlockedRef.current,
|
|
385
|
-
onBeforeBusy: captureFocusBeforeBusy,
|
|
354
|
+
isBlocked: (): boolean => recovery.mutationBlockedRef.current,
|
|
355
|
+
onBeforeBusy: (): void => recovery.captureFocusBeforeBusy(),
|
|
356
|
+
})
|
|
357
|
+
const recovery = useDocumentReloadRecovery({
|
|
358
|
+
mutationsBlocked,
|
|
359
|
+
mutationIssue,
|
|
360
|
+
onReloadDocument,
|
|
361
|
+
isBusy: submission.isBusy,
|
|
386
362
|
})
|
|
363
|
+
const { formRef, warningRef, mutationBlockedRef, discarding, reloadFailed, requestDiscard } =
|
|
364
|
+
recovery
|
|
365
|
+
const guard = useGuard(hasChanges && !discarding)
|
|
387
366
|
const isUploading = submission.phase.kind === 'uploading'
|
|
388
367
|
const isSubmitting = submission.phase.kind === 'submitting'
|
|
389
368
|
const isBusy = submission.isBusy
|
|
390
369
|
const pendingSystemFieldsSubmit =
|
|
391
370
|
submission.phase.kind === 'confirmingSystemFields' ? submission.phase.payload : null
|
|
392
371
|
|
|
393
|
-
// `inert` removes the active control from the tab order while a save is in
|
|
394
|
-
// flight. Restore the editor's position after React has removed `inert`;
|
|
395
|
-
// when the original control became disabled, fall back to the first usable
|
|
396
|
-
// form control instead of leaving focus on <body>.
|
|
397
|
-
useEffect(() => {
|
|
398
|
-
if (isBusy || !restoreFocusAfterBusyRef.current) return
|
|
399
|
-
restoreFocusAfterBusyRef.current = false
|
|
400
|
-
|
|
401
|
-
if (mutationIssue) {
|
|
402
|
-
warningRef.current?.focus()
|
|
403
|
-
focusBeforeBusyRef.current = null
|
|
404
|
-
return
|
|
405
|
-
}
|
|
406
|
-
const original = focusBeforeBusyRef.current
|
|
407
|
-
focusBeforeBusyRef.current = null
|
|
408
|
-
const originalCanReceiveFocus =
|
|
409
|
-
original?.isConnected === true && !original.matches(':disabled, [aria-disabled="true"]')
|
|
410
|
-
const target = originalCanReceiveFocus
|
|
411
|
-
? original
|
|
412
|
-
: formRef.current?.querySelector<HTMLElement>(
|
|
413
|
-
'input:not(:disabled), textarea:not(:disabled), select:not(:disabled), button:not(:disabled), [tabindex]:not([tabindex="-1"])'
|
|
414
|
-
)
|
|
415
|
-
target?.focus({ preventScroll: true })
|
|
416
|
-
}, [isBusy, mutationIssue])
|
|
417
|
-
|
|
418
372
|
const handleCancel = () => {
|
|
419
373
|
if (onCancel && typeof onCancel === 'function') {
|
|
420
374
|
onCancel()
|
|
@@ -498,10 +452,7 @@ const FormContent = ({
|
|
|
498
452
|
warningRef={warningRef}
|
|
499
453
|
discarding={discarding}
|
|
500
454
|
reloadFailed={reloadFailed}
|
|
501
|
-
onDiscardRequested={
|
|
502
|
-
setReloadFailed(false)
|
|
503
|
-
setDiscarding(true)
|
|
504
|
-
}}
|
|
455
|
+
onDiscardRequested={requestDiscard}
|
|
505
456
|
scheduling={scheduling}
|
|
506
457
|
restoreWarnings={restoreWarnings}
|
|
507
458
|
/>
|
package/src/forms/nested-path.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
}, [
|
|
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 {
|
|
11
|
+
import { useCallback, useMemo, useSyncExternalStore } from 'react'
|
|
12
12
|
|
|
13
|
-
import type
|
|
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 {
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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(
|
|
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)
|