@byline/admin 6.2.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.
@@ -0,0 +1,293 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ import { useCallback, useRef, useState } from 'react'
12
+
13
+ import type { Field } from '@byline/core'
14
+ import type { DocumentPatch } from '@byline/core/patches'
15
+ import { useTranslation } from '@byline/i18n/react'
16
+
17
+ import { useBylineFieldServices } from '../fields/field-services-context'
18
+ import { useFormContext } from './form-context'
19
+ import { executeUploadsWithProgress } from './upload-executor'
20
+
21
+ /**
22
+ * Payload emitted by the form on Save. Carries the content (field data +
23
+ * patches) alongside the document-grain system fields (path / advertised
24
+ * locales) and per-bucket dirty flags so the host can route each piece to the
25
+ * right write path — versioned for content, immediate/non-versioned for the
26
+ * system fields. See docs/08-internationalization/index.md.
27
+ */
28
+ export interface SystemFieldsSubmitPayload {
29
+ // biome-ignore lint/suspicious/noExplicitAny: data is collection-specific
30
+ data: any
31
+ patches: DocumentPatch[]
32
+ contentDirty: boolean
33
+ pathDirty: boolean
34
+ systemPath?: string | null
35
+ availableLocalesDirty: boolean
36
+ systemAvailableLocales?: string[]
37
+ }
38
+
39
+ /**
40
+ * Where a save has got to. Mutually exclusive by construction: the form cannot
41
+ * be validating and uploading at once, which two independent booleans allowed.
42
+ */
43
+ export type SubmissionPhase =
44
+ | { kind: 'idle' }
45
+ | { kind: 'validating' }
46
+ | { kind: 'uploading' }
47
+ | { kind: 'confirmingSystemFields'; payload: SystemFieldsSubmitPayload }
48
+ | { kind: 'submitting' }
49
+
50
+ export interface UseFormSubmissionOptions {
51
+ mode: 'create' | 'edit'
52
+ fields: Field[]
53
+ /** Persisted document id (edit mode only); feeds server-side upload hooks. */
54
+ documentId?: string
55
+ /**
56
+ * Only collections that opted into the advertised-locale widget emit the
57
+ * locale set, so the write path never touches
58
+ * `byline_document_available_locales` for the others.
59
+ */
60
+ advertiseLocales?: boolean
61
+ onSubmit: (payload: SystemFieldsSubmitPayload) => void | Promise<void>
62
+ /** Mutations blocked (stale/lock/discarding). Read synchronously, per call. */
63
+ isBlocked: () => boolean
64
+ /**
65
+ * Called immediately before the form becomes `inert` — once before uploads
66
+ * and once before submission — so the caller can record the focused element
67
+ * and restore it when busy ends. The DOM refs stay in the component; the
68
+ * hook only signals the transition.
69
+ */
70
+ onBeforeBusy?: () => void
71
+ }
72
+
73
+ export interface UseFormSubmissionResult {
74
+ phase: SubmissionPhase
75
+ isBusy: boolean
76
+ submit: () => Promise<void>
77
+ confirmSystemFields: () => Promise<void>
78
+ cancelSystemFields: () => void
79
+ }
80
+
81
+ const IDLE: SubmissionPhase = { kind: 'idle' }
82
+
83
+ /**
84
+ * Owns one save: validate → upload → (confirm) → submit.
85
+ *
86
+ * Admission is decided by a ref, not by `phase`. Rendered state is not a
87
+ * re-entry guard: two `submit()` calls in the same turn both observe
88
+ * `phase.kind === 'idle'` because React has not rerendered between them. The
89
+ * ref is set synchronously at the top of `submit()` and cleared in `finally`,
90
+ * spanning validation through submission — the whole window, not just the
91
+ * final request. `phase` drives the UI; the ref decides admission.
92
+ */
93
+ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSubmissionResult {
94
+ const { mode, fields, documentId, advertiseLocales, onSubmit, isBlocked, onBeforeBusy } = options
95
+
96
+ const {
97
+ getFieldValues,
98
+ runFieldHooks,
99
+ validateForm,
100
+ resetHasChanges,
101
+ getPatches,
102
+ getDirtyBreakdown,
103
+ getSystemPath,
104
+ getSystemAvailableLocales,
105
+ setFieldValue,
106
+ setFieldError,
107
+ getPendingUploads,
108
+ clearPendingUploads,
109
+ setFieldUploading,
110
+ } = useFormContext()
111
+ const { t } = useTranslation('byline-admin')
112
+ const { uploadField } = useBylineFieldServices()
113
+
114
+ const [phase, setPhase] = useState<SubmissionPhase>(IDLE)
115
+ const inFlightRef = useRef(false)
116
+
117
+ const isBusy = phase.kind === 'uploading' || phase.kind === 'submitting'
118
+
119
+ // Await the host handler. Resolution means the save succeeded and the clean
120
+ // baseline can be committed; rejection preserves dirty state so the editor
121
+ // does not lose work and the navigation guard keeps blocking. Host handlers
122
+ // that surface their own toast MUST rethrow afterwards.
123
+ const deliver = useCallback(
124
+ async (payload: SystemFieldsSubmitPayload) => {
125
+ // Settle before returning. `runUploads` may have left the phase at
126
+ // `uploading`, and this is the only reset on the delivery path: returning
127
+ // without it strands the form busy and inert with nothing in flight.
128
+ // Dirty state is deliberately untouched, so the save can be retried once
129
+ // the block clears.
130
+ if (isBlocked() || typeof onSubmit !== 'function') {
131
+ setPhase(IDLE)
132
+ return
133
+ }
134
+ onBeforeBusy?.()
135
+ setPhase({ kind: 'submitting' })
136
+ try {
137
+ await onSubmit(payload)
138
+ resetHasChanges()
139
+ } catch {
140
+ // Intentionally swallowed here — the host has already reported the
141
+ // failure to the user. Dirty state is preserved by not resetting.
142
+ } finally {
143
+ setPhase(IDLE)
144
+ }
145
+ },
146
+ [isBlocked, onBeforeBusy, onSubmit, resetHasChanges]
147
+ )
148
+
149
+ const runUploads = useCallback(async (): Promise<boolean> => {
150
+ const pendingUploads = getPendingUploads()
151
+ if (pendingUploads.size === 0) return true
152
+
153
+ onBeforeBusy?.()
154
+ setPhase({ kind: 'uploading' })
155
+ try {
156
+ const uploadResult = await executeUploadsWithProgress(
157
+ pendingUploads,
158
+ uploadField,
159
+ ({ fieldPath, status }) => {
160
+ setFieldUploading(fieldPath, status === 'uploading')
161
+ },
162
+ {
163
+ // Document context for server-side upload hooks: the persisted
164
+ // document id (edit mode only) plus any `upload.context` form
165
+ // values declared on the schema field. See UploadConfig.context
166
+ // in @byline/core.
167
+ documentId: mode === 'edit' ? documentId : undefined,
168
+ fields,
169
+ getFormValues: getFieldValues,
170
+ }
171
+ )
172
+
173
+ if (!uploadResult.allSucceeded) {
174
+ for (const [fieldPath, errorMessage] of uploadResult.errors.entries()) {
175
+ setFieldError(fieldPath, t('forms.uploadFailedFieldError', { message: errorMessage }))
176
+ }
177
+ console.error('One or more uploads failed:', uploadResult.errors)
178
+ return false
179
+ }
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()
188
+ return true
189
+ } catch (err) {
190
+ console.error('Upload execution error:', err)
191
+ return false
192
+ }
193
+ }, [
194
+ clearPendingUploads,
195
+ documentId,
196
+ fields,
197
+ getFieldValues,
198
+ getPendingUploads,
199
+ mode,
200
+ onBeforeBusy,
201
+ setFieldError,
202
+ setFieldUploading,
203
+ setFieldValue,
204
+ t,
205
+ uploadField,
206
+ ])
207
+
208
+ const buildPayload = useCallback((): { payload: SystemFieldsSubmitPayload; reason: string } => {
209
+ const { contentDirty, pathDirty, availableLocalesDirty, reason } = getDirtyBreakdown()
210
+ return {
211
+ reason,
212
+ payload: {
213
+ data: getFieldValues(),
214
+ patches: getPatches(),
215
+ contentDirty,
216
+ pathDirty,
217
+ systemPath: getSystemPath(),
218
+ availableLocalesDirty,
219
+ systemAvailableLocales: advertiseLocales ? getSystemAvailableLocales() : undefined,
220
+ },
221
+ }
222
+ }, [
223
+ advertiseLocales,
224
+ getDirtyBreakdown,
225
+ getFieldValues,
226
+ getPatches,
227
+ getSystemAvailableLocales,
228
+ getSystemPath,
229
+ ])
230
+
231
+ const submit = useCallback(async () => {
232
+ if (isBlocked()) return
233
+ if (inFlightRef.current) return
234
+ inFlightRef.current = true
235
+ try {
236
+ setPhase({ kind: 'validating' })
237
+
238
+ // 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)
245
+ setPhase(IDLE)
246
+ return
247
+ }
248
+
249
+ if (isBlocked()) {
250
+ setPhase(IDLE)
251
+ return
252
+ }
253
+
254
+ if (!(await runUploads())) {
255
+ setPhase(IDLE)
256
+ return
257
+ }
258
+
259
+ const { payload, reason } = buildPayload()
260
+
261
+ // Editing the document-grain system fields (path / advertised locales) is
262
+ // an immediate, non-versioned write that does NOT reset workflow status,
263
+ // so confirm it before saving. Create mode writes everything as part of
264
+ // the initial version, so no confirmation applies there.
265
+ if (mode === 'edit' && (reason === 'direct-write' || reason === 'both')) {
266
+ setPhase({ kind: 'confirmingSystemFields', payload })
267
+ return
268
+ }
269
+
270
+ await deliver(payload)
271
+ } finally {
272
+ inFlightRef.current = false
273
+ }
274
+ }, [buildPayload, deliver, fields, isBlocked, mode, runUploads, runFieldHooks, validateForm])
275
+
276
+ const confirmSystemFields = useCallback(async () => {
277
+ if (phase.kind !== 'confirmingSystemFields') return
278
+ if (inFlightRef.current) return
279
+ inFlightRef.current = true
280
+ const { payload } = phase
281
+ try {
282
+ await deliver(payload)
283
+ } finally {
284
+ inFlightRef.current = false
285
+ }
286
+ }, [deliver, phase])
287
+
288
+ const cancelSystemFields = useCallback(() => {
289
+ setPhase((current) => (current.kind === 'confirmingSystemFields' ? IDLE : current))
290
+ }, [])
291
+
292
+ return { phase, isBusy, submit, confirmSystemFields, cancelSystemFields }
293
+ }
@@ -0,0 +1,89 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ import { useEffect, useMemo, useState } from 'react'
12
+
13
+ import type { TabDefinition, TabSetDefinition } from '@byline/core'
14
+
15
+ import { useFormContext } from './form-context'
16
+
17
+ /** One tab set, resolved against live form data and current errors. */
18
+ export interface ResolvedTabSet {
19
+ /** Tabs whose `condition` is satisfied, in declaration order. */
20
+ visibleTabs: TabDefinition[]
21
+ /** The tab that should render, after falling back off a hidden selection. */
22
+ activeTabName: string
23
+ activeTab: TabDefinition | undefined
24
+ /** Error count per tab name, for the tab bar's badges. */
25
+ errorCounts: Record<string, number> | undefined
26
+ }
27
+
28
+ export interface UseFormTabsResult {
29
+ resolve: (set: TabSetDefinition) => ResolvedTabSet
30
+ errorCountsBySet: Record<string, Record<string, number>>
31
+ }
32
+
33
+ /**
34
+ * Tab visibility, per-tab error counts, and fallback when the selected tab is
35
+ * hidden.
36
+ *
37
+ * It deliberately does **not** own the selection. `FormRenderer` lifts that
38
+ * above the keyed `FormProvider` so the editor's tab choices survive the
39
+ * remount a locale change triggers; a hook living inside that subtree would
40
+ * lose them on every switch. The selection arrives as `activeTabBySet` and
41
+ * changes are reported upward.
42
+ */
43
+ export function useFormTabs(
44
+ fieldToTabPath: Map<string, { tabSetName: string; tabName: string }>,
45
+ activeTabBySet: Record<string, string>
46
+ ): 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])
59
+
60
+ // Per-tab-set error counts: { [tabSetName]: { [tabName]: count } }.
61
+ // Each tab bar consumes its own slice.
62
+ const errorCountsBySet = useMemo<Record<string, Record<string, number>>>(() => {
63
+ const result: Record<string, Record<string, number>> = {}
64
+ for (const err of errors) {
65
+ const path = fieldToTabPath.get(err.field)
66
+ if (!path) continue
67
+ result[path.tabSetName] ??= {}
68
+ result[path.tabSetName]![path.tabName] = (result[path.tabSetName]?.[path.tabName] ?? 0) + 1
69
+ }
70
+ return result
71
+ }, [errors, fieldToTabPath])
72
+
73
+ const resolve = (set: TabSetDefinition): ResolvedTabSet => {
74
+ const visibleTabs = set.tabs.filter((tab) => !tab.condition || tab.condition(formData))
75
+ const requested = activeTabBySet[set.name] ?? ''
76
+ const activeTabName =
77
+ visibleTabs.length > 0 && !visibleTabs.some((t) => t.name === requested)
78
+ ? (visibleTabs[0]?.name ?? requested)
79
+ : requested
80
+ return {
81
+ visibleTabs,
82
+ activeTabName,
83
+ activeTab: visibleTabs.find((t) => t.name === activeTabName),
84
+ errorCounts: errorCountsBySet[set.name],
85
+ }
86
+ }
87
+
88
+ return { resolve, errorCountsBySet }
89
+ }