@byline/admin 6.2.0 → 6.3.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/checkbox/checkbox-field.js +3 -1
- package/dist/fields/code/code-field.js +2 -1
- package/dist/fields/datetime/datetime-field.js +4 -2
- package/dist/fields/field-renderer.js +2 -1
- package/dist/fields/field-services-types.d.ts +23 -0
- package/dist/fields/file/file-field.js +3 -2
- package/dist/fields/group/group-field.js +3 -1
- package/dist/fields/image/image-field.js +3 -2
- package/dist/fields/numerical/numerical-field.js +2 -1
- package/dist/fields/relation/relation-field.js +3 -2
- package/dist/fields/relation/relation-many-field.js +3 -2
- package/dist/fields/relation/relation-picker.js +38 -7
- package/dist/fields/relation/relation-picker.module.js +3 -0
- package/dist/fields/relation/relation-picker_module.css +20 -0
- package/dist/fields/select/select-field.js +3 -2
- package/dist/fields/text/text-field.js +2 -1
- package/dist/fields/text-area/text-area-field.js +2 -1
- package/dist/forms/form-context.js +4 -1
- package/dist/forms/form-dom-scope.d.ts +34 -0
- package/dist/forms/form-dom-scope.js +20 -0
- package/dist/forms/form-layout-styles.test.node.d.ts +8 -0
- package/dist/forms/form-layout.d.ts +39 -0
- package/dist/forms/form-layout.js +102 -0
- package/dist/forms/form-page-chrome.d.ts +110 -0
- package/dist/forms/form-page-chrome.js +253 -0
- package/dist/forms/form-renderer.d.ts +2 -18
- package/dist/forms/form-renderer.js +105 -423
- package/dist/forms/form-renderer.module.js +2 -0
- package/dist/forms/form-renderer_module.css +13 -0
- package/dist/forms/use-form-submission.d.ts +74 -0
- package/dist/forms/use-form-submission.js +166 -0
- package/dist/forms/use-form-tabs.d.ts +29 -0
- package/dist/forms/use-form-tabs.js +45 -0
- package/dist/presentation/tabs.js +2 -1
- package/dist/react.d.ts +1 -1
- package/package.json +7 -7
- package/src/fields/checkbox/checkbox-field.tsx +3 -1
- package/src/fields/code/code-field.tsx +2 -1
- package/src/fields/datetime/datetime-field.tsx +4 -2
- package/src/fields/direct-widget-dom-ids.test.tsx +142 -0
- package/src/fields/field-dom-ids.test.tsx +293 -0
- package/src/fields/field-renderer.tsx +2 -1
- package/src/fields/field-services-types.ts +25 -0
- package/src/fields/file/file-field.tsx +3 -2
- package/src/fields/group/group-field.tsx +3 -1
- package/src/fields/image/image-field.tsx +3 -2
- package/src/fields/numerical/numerical-field.tsx +2 -1
- package/src/fields/relation/relation-field.tsx +3 -2
- package/src/fields/relation/relation-many-field.tsx +3 -2
- package/src/fields/relation/relation-picker-create.test.tsx +203 -0
- package/src/fields/relation/relation-picker-refresh.test.tsx +325 -0
- package/src/fields/relation/relation-picker.module.css +32 -0
- package/src/fields/relation/relation-picker.tsx +59 -1
- package/src/fields/select/select-field.tsx +3 -2
- package/src/fields/text/text-field.tsx +2 -1
- package/src/fields/text-area/text-area-field.tsx +2 -1
- package/src/forms/form-context.tsx +2 -1
- package/src/forms/form-dom-scope.test.tsx +213 -0
- package/src/forms/form-dom-scope.tsx +70 -0
- package/src/forms/form-layout-styles.test.node.ts +32 -0
- package/src/forms/form-layout.test.tsx +276 -0
- package/src/forms/form-layout.tsx +204 -0
- package/src/forms/form-page-chrome.tsx +460 -0
- package/src/forms/form-renderer-submit.test.tsx +40 -0
- package/src/forms/form-renderer-tab-persistence.test.tsx +145 -0
- package/src/forms/form-renderer.module.css +18 -0
- package/src/forms/form-renderer.tsx +126 -600
- package/src/forms/form-submit-isolation.test.tsx +247 -0
- package/src/forms/use-form-submission.test.tsx +564 -0
- package/src/forms/use-form-submission.ts +293 -0
- package/src/forms/use-form-tabs.ts +89 -0
- package/src/presentation/tab-dom-ids.test.tsx +153 -0
- package/src/presentation/tabs.test.tsx +49 -2
- package/src/presentation/tabs.tsx +11 -1
- package/src/react.ts +2 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
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 type { AdminResourceConfig, Field } from '@byline/core'
|
|
12
|
+
import { defineAdminConfig } from '@byline/core'
|
|
13
|
+
import { adminTranslations } from '@byline/i18n/admin'
|
|
14
|
+
import { I18nProvider } from '@byline/i18n/react'
|
|
15
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
17
|
+
|
|
18
|
+
import { BylineFieldServicesProvider } from '../fields/field-services-context'
|
|
19
|
+
import { FormProvider } from '../forms/form-context'
|
|
20
|
+
import { FormLayout } from '../forms/form-layout'
|
|
21
|
+
|
|
22
|
+
// Deliberately not the `;(globalThis as any)` idiom used elsewhere in this
|
|
23
|
+
// suite: Biome's import sorting can move an import across that leading
|
|
24
|
+
// semicolon, and without semicolons the result parses as a call on the import.
|
|
25
|
+
const globalWithAct = globalThis as any
|
|
26
|
+
globalWithAct.IS_REACT_ACT_ENVIRONMENT = true
|
|
27
|
+
|
|
28
|
+
defineAdminConfig({
|
|
29
|
+
i18n: {
|
|
30
|
+
admin: { defaultLocale: 'en', locales: ['en'] },
|
|
31
|
+
content: { defaultLocale: 'en', locales: ['en'] },
|
|
32
|
+
},
|
|
33
|
+
collections: [
|
|
34
|
+
{
|
|
35
|
+
path: 'pages',
|
|
36
|
+
labels: { singular: 'Page', plural: 'Pages' },
|
|
37
|
+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
slugifier: (value: string) => value.toLowerCase().trim().replace(/\s+/g, '-'),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const fields: Field[] = [
|
|
44
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
45
|
+
{ name: 'seoTitle', label: 'SEO title', type: 'text' },
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Both forms declare a tab set under the same name, which is the ordinary case:
|
|
50
|
+
* two forms over one collection share its admin configuration verbatim.
|
|
51
|
+
*/
|
|
52
|
+
const adminConfig = {
|
|
53
|
+
layout: { main: ['main'] },
|
|
54
|
+
tabSets: [
|
|
55
|
+
{
|
|
56
|
+
name: 'main',
|
|
57
|
+
tabs: [
|
|
58
|
+
{ name: 'general', label: 'General', fields: ['title'] },
|
|
59
|
+
{ name: 'seo', label: 'SEO', fields: ['seoTitle'] },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
} as unknown as AdminResourceConfig
|
|
64
|
+
|
|
65
|
+
let container: HTMLDivElement
|
|
66
|
+
let root: Root
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
container = document.createElement('div')
|
|
70
|
+
document.body.appendChild(container)
|
|
71
|
+
root = createRoot(container)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
act(() => {
|
|
76
|
+
root.unmount()
|
|
77
|
+
})
|
|
78
|
+
container.remove()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const Form = ({ name }: { name: string }) => (
|
|
82
|
+
<form aria-label={name}>
|
|
83
|
+
<FormProvider initialData={{ fields: {} }} collectionPath="pages">
|
|
84
|
+
<FormLayout
|
|
85
|
+
fields={fields}
|
|
86
|
+
adminConfig={adminConfig}
|
|
87
|
+
activeTabBySet={{ main: 'general' }}
|
|
88
|
+
onTabChange={() => {}}
|
|
89
|
+
/>
|
|
90
|
+
</FormProvider>
|
|
91
|
+
</form>
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
const renderTwoForms = () => {
|
|
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: async () => ({}),
|
|
108
|
+
} as any
|
|
109
|
+
}
|
|
110
|
+
>
|
|
111
|
+
<Form name="parent" />
|
|
112
|
+
<Form name="child" />
|
|
113
|
+
</BylineFieldServicesProvider>
|
|
114
|
+
</I18nProvider>
|
|
115
|
+
)
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe('tab DOM ids across concurrently mounted forms', () => {
|
|
120
|
+
it('keeps tab trigger ids distinct across two forms with a "main" tab set', () => {
|
|
121
|
+
renderTwoForms()
|
|
122
|
+
|
|
123
|
+
const triggers = Array.from(container.querySelectorAll('[role="tab"]')).map((n) => n.id)
|
|
124
|
+
|
|
125
|
+
expect(triggers.length).toBeGreaterThan(0)
|
|
126
|
+
expect(triggers.every((id) => id !== '')).toBe(true)
|
|
127
|
+
expect(new Set(triggers).size).toBe(triggers.length)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('labels each tab panel from a trigger in its own form', () => {
|
|
131
|
+
renderTwoForms()
|
|
132
|
+
|
|
133
|
+
const panels = Array.from(container.querySelectorAll('[role="tabpanel"]'))
|
|
134
|
+
expect(panels).toHaveLength(2)
|
|
135
|
+
|
|
136
|
+
for (const panel of panels) {
|
|
137
|
+
const owner = document.getElementById(panel.getAttribute('aria-labelledby') as string)
|
|
138
|
+
expect(owner).not.toBeNull()
|
|
139
|
+
// Resolution is document-wide, so an unscoped id points the second form's
|
|
140
|
+
// panel at the first form's trigger.
|
|
141
|
+
expect(owner?.closest('form')).toBe(panel.closest('form'))
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('keeps panel ids distinct so aria-controls cannot cross forms', () => {
|
|
146
|
+
renderTwoForms()
|
|
147
|
+
|
|
148
|
+
const panelIds = Array.from(container.querySelectorAll('[role="tabpanel"]')).map((n) => n.id)
|
|
149
|
+
|
|
150
|
+
expect(panelIds.every((id) => id !== '')).toBe(true)
|
|
151
|
+
expect(new Set(panelIds).size).toBe(panelIds.length)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -51,7 +51,7 @@ import { AdminTabs } from './tabs'
|
|
|
51
51
|
* report overflow. Drive the two widths the component reads off its stable
|
|
52
52
|
* override handles.
|
|
53
53
|
*/
|
|
54
|
-
const layout = { container: 0, row: 0 }
|
|
54
|
+
const layout = { container: 0, row: 0, rowReads: 0 }
|
|
55
55
|
|
|
56
56
|
function installPointerStubs() {
|
|
57
57
|
// jsdom implements neither pointer capture nor scrollTo; the component guards
|
|
@@ -96,7 +96,11 @@ function installLayoutStubs() {
|
|
|
96
96
|
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
|
|
97
97
|
configurable: true,
|
|
98
98
|
get(this: HTMLElement) {
|
|
99
|
-
|
|
99
|
+
if (!this.classList.contains('byline-admin-tablist')) return 0
|
|
100
|
+
// Counted, not just answered: in a browser this getter is what forces
|
|
101
|
+
// the synchronous layout, so the count is the cost under test.
|
|
102
|
+
layout.rowReads += 1
|
|
103
|
+
return layout.row
|
|
100
104
|
},
|
|
101
105
|
})
|
|
102
106
|
}
|
|
@@ -135,6 +139,7 @@ describe('AdminTabs', () => {
|
|
|
135
139
|
observed.length = 0
|
|
136
140
|
layout.container = 1000
|
|
137
141
|
layout.row = 400
|
|
142
|
+
layout.rowReads = 0
|
|
138
143
|
onChange = vi.fn()
|
|
139
144
|
container = document.createElement('div')
|
|
140
145
|
document.body.appendChild(container)
|
|
@@ -367,6 +372,48 @@ describe('AdminTabs', () => {
|
|
|
367
372
|
expect(observedClasses.some((c) => c.includes('byline-admin-tabs '))).toBe(true)
|
|
368
373
|
})
|
|
369
374
|
|
|
375
|
+
/**
|
|
376
|
+
* The tab array arrives with a fresh identity on every render of the form
|
|
377
|
+
* layout: `useFormTabs.resolve()` filters `set.tabs` each time it is called,
|
|
378
|
+
* and the live form data it filters against updates per keystroke. Keying the
|
|
379
|
+
* measurement effect on that identity made every unrelated render tear down
|
|
380
|
+
* the ResizeObserver and force a synchronous layout read — measured in Chrome
|
|
381
|
+
* against a seeded `pages` document as one forced read per keystroke, and two
|
|
382
|
+
* on an editor mount.
|
|
383
|
+
*/
|
|
384
|
+
it('does not re-measure when the parent re-renders with an equal tab array', () => {
|
|
385
|
+
render()
|
|
386
|
+
const readsAfterMount = layout.rowReads
|
|
387
|
+
const observedAfterMount = observed.length
|
|
388
|
+
|
|
389
|
+
render({ tabs: TABS.map((tab) => ({ ...tab })) })
|
|
390
|
+
|
|
391
|
+
expect(layout.rowReads).toBe(readsAfterMount)
|
|
392
|
+
expect(observed.length).toBe(observedAfterMount)
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('re-measures when a tab is added or removed', () => {
|
|
396
|
+
render()
|
|
397
|
+
const readsAfterMount = layout.rowReads
|
|
398
|
+
|
|
399
|
+
render({ tabs: [...TABS, { name: 'indexing', label: 'Indexing' }] })
|
|
400
|
+
|
|
401
|
+
expect(layout.rowReads).toBeGreaterThan(readsAfterMount)
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* A label carries the width, so a translation change has to re-measure even
|
|
406
|
+
* though the tab names are untouched.
|
|
407
|
+
*/
|
|
408
|
+
it('re-measures when a label changes', () => {
|
|
409
|
+
render()
|
|
410
|
+
const readsAfterMount = layout.rowReads
|
|
411
|
+
|
|
412
|
+
render({ tabs: TABS.map((tab, i) => (i === 0 ? { ...tab, label: 'Détails' } : tab)) })
|
|
413
|
+
|
|
414
|
+
expect(layout.rowReads).toBeGreaterThan(readsAfterMount)
|
|
415
|
+
})
|
|
416
|
+
|
|
370
417
|
// ─── Drag-to-scroll (desktop) ─────────────────────────────────────────
|
|
371
418
|
|
|
372
419
|
const viewport = () =>
|
|
@@ -124,6 +124,16 @@ export const AdminTabs = ({
|
|
|
124
124
|
|
|
125
125
|
const [strip, setStrip] = useState<StripState>(INITIAL_STRIP_STATE)
|
|
126
126
|
|
|
127
|
+
// The measurement effect is keyed on what the tabs *are*, not on the identity
|
|
128
|
+
// of the array carrying them. A form layout rebuilds that array on every
|
|
129
|
+
// render — `useFormTabs.resolve()` filters `set.tabs` against live form data,
|
|
130
|
+
// so a new array arrives per keystroke — and re-running the effect for one of
|
|
131
|
+
// those renders would disconnect the observer and force a synchronous layout
|
|
132
|
+
// read on a tree the browser has just invalidated. Labels are part of the key
|
|
133
|
+
// because the label is what carries the width: a translation change has to
|
|
134
|
+
// re-measure, a keystroke elsewhere in the form does not.
|
|
135
|
+
const tabsKey = tabs.map((tab) => `${tab.name}:${tab.label}`).join('\u0000')
|
|
136
|
+
|
|
127
137
|
const measure = useCallback(() => {
|
|
128
138
|
const container = containerRef.current
|
|
129
139
|
const row = rowRef.current
|
|
@@ -162,7 +172,7 @@ export const AdminTabs = ({
|
|
|
162
172
|
observer.observe(container)
|
|
163
173
|
observer.observe(row)
|
|
164
174
|
return () => observer.disconnect()
|
|
165
|
-
}, [measure,
|
|
175
|
+
}, [measure, tabsKey])
|
|
166
176
|
|
|
167
177
|
const reveal = useCallback((name: string) => {
|
|
168
178
|
const viewport = viewportRef.current
|
package/src/react.ts
CHANGED
|
@@ -81,10 +81,12 @@ export * from './widgets/status-badge/status-badge.js'
|
|
|
81
81
|
// Field-side service contract types.
|
|
82
82
|
export type {
|
|
83
83
|
BylineFieldServices,
|
|
84
|
+
CanCreateInCollectionFn,
|
|
84
85
|
CollectionListDoc,
|
|
85
86
|
CollectionListParams,
|
|
86
87
|
CollectionListResponse,
|
|
87
88
|
GetCollectionDocumentsFn,
|
|
89
|
+
GetCreateDocumentUrlFn,
|
|
88
90
|
GetTreeAncestorsFn,
|
|
89
91
|
GetTreeParentFn,
|
|
90
92
|
PlaceTreeNodeFn,
|