@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.
- 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 +100 -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 +104 -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/package.json +7 -7
- 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 +198 -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 +115 -600
- 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
|
@@ -10,47 +10,37 @@
|
|
|
10
10
|
|
|
11
11
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
12
12
|
|
|
13
|
-
import type {
|
|
14
|
-
AdminResourceConfig,
|
|
15
|
-
Field,
|
|
16
|
-
GroupDefinition,
|
|
17
|
-
RowDefinition,
|
|
18
|
-
TabSetDefinition,
|
|
19
|
-
WorkflowStatus,
|
|
20
|
-
} from '@byline/core'
|
|
13
|
+
import type { AdminResourceConfig, Field, WorkflowStatus } from '@byline/core'
|
|
21
14
|
import { getAdminConfig } from '@byline/core'
|
|
22
|
-
import type { DocumentPatch } from '@byline/core/patches'
|
|
23
15
|
import { useTranslation } from '@byline/i18n/react'
|
|
24
|
-
import { Alert, Button, ComboButton, LoaderEllipsis } from '@byline/ui/react'
|
|
25
16
|
import cx from 'clsx'
|
|
26
17
|
|
|
27
|
-
import { sliceFieldAdmin } from '../fields/field-admin'
|
|
28
|
-
import { FieldRenderer } from '../fields/field-renderer'
|
|
29
18
|
import { useBylineFieldServices } from '../fields/field-services-context'
|
|
30
|
-
import { AdminGroup } from '../presentation/group'
|
|
31
|
-
import { AdminRow } from '../presentation/row'
|
|
32
|
-
import { AdminTabs, tabPanelId, tabTriggerId } from '../presentation/tabs'
|
|
33
|
-
import { AvailableLocalesWidget } from './available-locales-widget'
|
|
34
|
-
import { DocumentActions, type DocumentActionsLocaleOption } from './document-actions'
|
|
35
19
|
import { FormProvider, useFieldValue, useFormContext } from './form-context'
|
|
20
|
+
import { FormLayout } from './form-layout'
|
|
36
21
|
import { NavigationGuardModal, SystemFieldsConfirmModal, UnsavedChangesModal } from './form-modals'
|
|
22
|
+
import {
|
|
23
|
+
FormConcurrencyNotices,
|
|
24
|
+
FormHeadingRow,
|
|
25
|
+
FormSidebarWidgets,
|
|
26
|
+
FormStatusBar,
|
|
27
|
+
} from './form-page-chrome'
|
|
37
28
|
import styles from './form-renderer.module.css'
|
|
38
|
-
import { FormStatusDisplay } from './form-status-display'
|
|
39
29
|
import { useNavigationGuardAdapter } from './navigation-guard'
|
|
40
|
-
import { PathWidget } from './path-widget'
|
|
41
30
|
import {
|
|
42
|
-
ScheduledPublicationCell,
|
|
43
31
|
type ScheduledPublicationInfo,
|
|
44
|
-
ScheduledPublicationNotice,
|
|
45
32
|
type SchedulePublicationInput,
|
|
46
33
|
useScheduledPublication,
|
|
47
34
|
} from './scheduled-publication-control'
|
|
48
35
|
import { computeStatusTransitions } from './status-transitions'
|
|
49
|
-
import {
|
|
50
|
-
import {
|
|
51
|
-
import { useFormLayout } from './use-form-layout'
|
|
36
|
+
import { useFormSubmission } from './use-form-submission'
|
|
37
|
+
import type { DocumentActionsLocaleOption } from './document-actions'
|
|
52
38
|
import type { UseNavigationGuard } from './navigation-guard'
|
|
53
39
|
|
|
40
|
+
// Re-exported so existing importers of this module keep working; the type now
|
|
41
|
+
// lives with `useFormSubmission`, which produces it.
|
|
42
|
+
export type { SystemFieldsSubmitPayload } from './use-form-submission'
|
|
43
|
+
|
|
54
44
|
/** Metadata about a previously published version that is still live. */
|
|
55
45
|
export interface PublishedVersionInfo {
|
|
56
46
|
id: string
|
|
@@ -60,24 +50,6 @@ export interface PublishedVersionInfo {
|
|
|
60
50
|
updatedAt: string | Date
|
|
61
51
|
}
|
|
62
52
|
|
|
63
|
-
/**
|
|
64
|
-
* Payload emitted by the form on Save. Carries the content (field data +
|
|
65
|
-
* patches) alongside the document-grain system fields (path / advertised
|
|
66
|
-
* locales) and per-bucket dirty flags so the host can route each piece to the
|
|
67
|
-
* right write path — versioned for content, immediate/non-versioned for the
|
|
68
|
-
* system fields. See docs/08-internationalization/index.md.
|
|
69
|
-
*/
|
|
70
|
-
export interface SystemFieldsSubmitPayload {
|
|
71
|
-
// biome-ignore lint/suspicious/noExplicitAny: data is collection-specific
|
|
72
|
-
data: any
|
|
73
|
-
patches: DocumentPatch[]
|
|
74
|
-
contentDirty: boolean
|
|
75
|
-
pathDirty: boolean
|
|
76
|
-
systemPath?: string | null
|
|
77
|
-
availableLocalesDirty: boolean
|
|
78
|
-
systemAvailableLocales?: string[]
|
|
79
|
-
}
|
|
80
|
-
|
|
81
53
|
/** Props shared by both the public FormRenderer and its internal FormContent component. */
|
|
82
54
|
export interface FormRendererProps {
|
|
83
55
|
mutationIssue?: 'stale' | 'reload' | 'lock' | 'unavailable' | 'committed' | null
|
|
@@ -259,34 +231,13 @@ const FormContent = ({
|
|
|
259
231
|
_activeTabBySet?: Record<string, string>
|
|
260
232
|
_onTabChange?: (tabSetName: string, tabName: string) => void
|
|
261
233
|
}) => {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
validateForm,
|
|
266
|
-
errors: initialErrors,
|
|
267
|
-
hasChanges: hasChangesFn,
|
|
268
|
-
resetHasChanges,
|
|
269
|
-
getPatches,
|
|
270
|
-
getDirtyBreakdown,
|
|
271
|
-
getSystemPath,
|
|
272
|
-
getSystemAvailableLocales,
|
|
273
|
-
subscribeErrors,
|
|
274
|
-
subscribeMeta,
|
|
275
|
-
setFieldValue,
|
|
276
|
-
setFieldError,
|
|
277
|
-
getPendingUploads,
|
|
278
|
-
clearPendingUploads,
|
|
279
|
-
setFieldUploading,
|
|
280
|
-
} = useFormContext()
|
|
234
|
+
// Field state now reaches the submission hook and the layout walk directly;
|
|
235
|
+
// FormContent keeps only what its own chrome needs.
|
|
236
|
+
const { hasChanges: hasChangesFn, subscribeMeta } = useFormContext()
|
|
281
237
|
const { t } = useTranslation('byline-admin')
|
|
282
238
|
|
|
283
|
-
const [errors, setErrors] = useState(initialErrors)
|
|
284
239
|
const [hasChanges, setHasChanges] = useState(hasChangesFn())
|
|
285
240
|
const [statusBusy, setStatusBusy] = useState(false)
|
|
286
|
-
const [isUploading, setIsUploading] = useState(false)
|
|
287
|
-
const submittingRef = useRef(false)
|
|
288
|
-
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
289
|
-
const isBusy = isUploading || isSubmitting
|
|
290
241
|
const formRef = useRef<HTMLFormElement>(null)
|
|
291
242
|
const focusBeforeBusyRef = useRef<HTMLElement | null>(null)
|
|
292
243
|
const restoreFocusAfterBusyRef = useRef(false)
|
|
@@ -295,11 +246,6 @@ const FormContent = ({
|
|
|
295
246
|
// is dirty — those actions operate on the saved version, so unsaved edits
|
|
296
247
|
// would be silently excluded.
|
|
297
248
|
const [showUnsavedModal, setShowUnsavedModal] = useState(false)
|
|
298
|
-
// Holds the pending Save payload while the editor confirms an immediate,
|
|
299
|
-
// non-versioned system-field write (path / advertised locales). Non-null
|
|
300
|
-
// means the confirmation modal is open. See docs/08-internationalization/index.md.
|
|
301
|
-
const [pendingSystemFieldsSubmit, setPendingSystemFieldsSubmit] =
|
|
302
|
-
useState<SystemFieldsSubmitPayload | null>(null)
|
|
303
249
|
const [contentLocale, setContentLocale] = useState(initialLocale ?? defaultLocale)
|
|
304
250
|
|
|
305
251
|
// Scheduled publication owns three placements — a status-bar cell, an
|
|
@@ -334,13 +280,6 @@ const FormContent = ({
|
|
|
334
280
|
if (initialLocale) setContentLocale(initialLocale)
|
|
335
281
|
}, [initialLocale])
|
|
336
282
|
|
|
337
|
-
// Layout primitives + lookup tables — pure derivations of `adminConfig` +
|
|
338
|
-
// `fields`. The validator at startup guarantees every reachable name
|
|
339
|
-
// resolves and every schema field is placed at most once, so the render-time
|
|
340
|
-
// lookups below are unguarded. See ./use-form-layout.
|
|
341
|
-
const { fieldByName, tabSetByName, rowByName, groupByName, layout, fieldToTabPath } =
|
|
342
|
-
useFormLayout(adminConfig, fields)
|
|
343
|
-
|
|
344
283
|
// ---------------------------------------------------------------------
|
|
345
284
|
// Active-tab state — one tab name per declared tab set.
|
|
346
285
|
// Lifted into FormRenderer via `_activeTabBySet` / `_onTabChange` so the
|
|
@@ -376,10 +315,6 @@ const FormContent = ({
|
|
|
376
315
|
[_onTabChange]
|
|
377
316
|
)
|
|
378
317
|
|
|
379
|
-
// Track live form data so TabDefinition.condition functions can react to
|
|
380
|
-
// field changes. Re-evaluated per keystroke via the meta-subscribe loop.
|
|
381
|
-
const [formData, setFormData] = useState<Record<string, any>>(() => getFieldValues())
|
|
382
|
-
|
|
383
318
|
// Live document heading — tracks the useAsTitle field as the user types
|
|
384
319
|
const liveTitle = useFieldValue<string>(useAsTitle ?? '')
|
|
385
320
|
const computedHeading =
|
|
@@ -425,18 +360,35 @@ const FormContent = ({
|
|
|
425
360
|
nextStatus
|
|
426
361
|
)
|
|
427
362
|
|
|
428
|
-
useEffect(() => {
|
|
429
|
-
return subscribeErrors((newErrors) => setErrors(newErrors))
|
|
430
|
-
}, [subscribeErrors])
|
|
431
|
-
|
|
432
363
|
useEffect(() => {
|
|
433
364
|
return subscribeMeta(() => setHasChanges(hasChangesFn()))
|
|
434
365
|
}, [subscribeMeta, hasChangesFn])
|
|
435
366
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
+
// One save at a time: validate -> upload -> (confirm) -> submit. Admission is
|
|
376
|
+
// decided synchronously inside the hook, so two Saves in the same turn cannot
|
|
377
|
+
// both get through. `phase` is what the UI renders.
|
|
378
|
+
const submission = useFormSubmission({
|
|
379
|
+
mode,
|
|
380
|
+
fields,
|
|
381
|
+
documentId: mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : undefined,
|
|
382
|
+
advertiseLocales,
|
|
383
|
+
onSubmit,
|
|
384
|
+
isBlocked: () => mutationBlockedRef.current,
|
|
385
|
+
onBeforeBusy: captureFocusBeforeBusy,
|
|
386
|
+
})
|
|
387
|
+
const isUploading = submission.phase.kind === 'uploading'
|
|
388
|
+
const isSubmitting = submission.phase.kind === 'submitting'
|
|
389
|
+
const isBusy = submission.isBusy
|
|
390
|
+
const pendingSystemFieldsSubmit =
|
|
391
|
+
submission.phase.kind === 'confirmingSystemFields' ? submission.phase.payload : null
|
|
440
392
|
|
|
441
393
|
// `inert` removes the active control from the tab order while a save is in
|
|
442
394
|
// flight. Restore the editor's position after React has removed `inert`;
|
|
@@ -463,240 +415,16 @@ const FormContent = ({
|
|
|
463
415
|
target?.focus({ preventScroll: true })
|
|
464
416
|
}, [isBusy, mutationIssue])
|
|
465
417
|
|
|
466
|
-
const captureFocusBeforeBusy = useCallback(() => {
|
|
467
|
-
if (focusBeforeBusyRef.current != null) return
|
|
468
|
-
const activeElement = document.activeElement
|
|
469
|
-
if (!(activeElement instanceof HTMLElement) || !formRef.current?.contains(activeElement)) return
|
|
470
|
-
focusBeforeBusyRef.current = activeElement
|
|
471
|
-
restoreFocusAfterBusyRef.current = true
|
|
472
|
-
}, [])
|
|
473
|
-
|
|
474
418
|
const handleCancel = () => {
|
|
475
419
|
if (onCancel && typeof onCancel === 'function') {
|
|
476
420
|
onCancel()
|
|
477
421
|
}
|
|
478
422
|
}
|
|
479
423
|
|
|
480
|
-
// Await the host handler. Resolution means the save succeeded and the clean
|
|
481
|
-
// baseline can be committed; rejection preserves dirty state so the editor
|
|
482
|
-
// does not lose work and the navigation guard keeps blocking. Host handlers
|
|
483
|
-
// that surface their own toast MUST rethrow afterwards.
|
|
484
|
-
// Re-entry guard lives in a ref so the callback identity does not change
|
|
485
|
-
// mid-flight; the mirrored state drives the Save button's disabled prop.
|
|
486
|
-
const submitPayload = useCallback(
|
|
487
|
-
async (payload: SystemFieldsSubmitPayload) => {
|
|
488
|
-
if (mutationBlockedRef.current || typeof onSubmit !== 'function') return
|
|
489
|
-
if (submittingRef.current) return
|
|
490
|
-
submittingRef.current = true
|
|
491
|
-
captureFocusBeforeBusy()
|
|
492
|
-
setIsSubmitting(true)
|
|
493
|
-
try {
|
|
494
|
-
await onSubmit(payload)
|
|
495
|
-
resetHasChanges()
|
|
496
|
-
} catch {
|
|
497
|
-
// Intentionally swallowed here — the host has already reported the
|
|
498
|
-
// failure to the user. Dirty state is preserved by not resetting.
|
|
499
|
-
} finally {
|
|
500
|
-
submittingRef.current = false
|
|
501
|
-
setIsSubmitting(false)
|
|
502
|
-
}
|
|
503
|
-
},
|
|
504
|
-
[captureFocusBeforeBusy, onSubmit, resetHasChanges]
|
|
505
|
-
)
|
|
506
|
-
|
|
507
424
|
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
|
508
|
-
if (mutationBlockedRef.current) {
|
|
509
|
-
e.preventDefault()
|
|
510
|
-
return
|
|
511
|
-
}
|
|
512
425
|
e.preventDefault()
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
void (async () => {
|
|
516
|
-
const hookErrors = await runFieldHooks(fields)
|
|
517
|
-
const formErrors = validateForm(fields)
|
|
518
|
-
const allErrors = [...hookErrors, ...formErrors]
|
|
519
|
-
|
|
520
|
-
if (allErrors.length > 0) {
|
|
521
|
-
console.error('Form validation failed:', allErrors)
|
|
522
|
-
return
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
if (mutationBlockedRef.current) return
|
|
526
|
-
|
|
527
|
-
// Execute any pending uploads before submitting
|
|
528
|
-
const pendingUploads = getPendingUploads()
|
|
529
|
-
if (pendingUploads.size > 0) {
|
|
530
|
-
captureFocusBeforeBusy()
|
|
531
|
-
setIsUploading(true)
|
|
532
|
-
try {
|
|
533
|
-
const uploadResult = await executeUploadsWithProgress(
|
|
534
|
-
pendingUploads,
|
|
535
|
-
uploadField,
|
|
536
|
-
({ fieldPath, status }) => {
|
|
537
|
-
setFieldUploading(fieldPath, status === 'uploading')
|
|
538
|
-
},
|
|
539
|
-
{
|
|
540
|
-
// Document context for server-side upload hooks: the persisted
|
|
541
|
-
// document id (edit mode only) plus any `upload.context` form
|
|
542
|
-
// values declared on the schema field. See UploadConfig.context
|
|
543
|
-
// in @byline/core.
|
|
544
|
-
documentId:
|
|
545
|
-
mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : undefined,
|
|
546
|
-
fields,
|
|
547
|
-
getFormValues: getFieldValues,
|
|
548
|
-
}
|
|
549
|
-
)
|
|
550
|
-
|
|
551
|
-
// Check for upload errors
|
|
552
|
-
if (!uploadResult.allSucceeded) {
|
|
553
|
-
// Set field-level errors for failed uploads
|
|
554
|
-
for (const [fieldPath, errorMessage] of uploadResult.errors.entries()) {
|
|
555
|
-
setFieldError(fieldPath, t('forms.uploadFailedFieldError', { message: errorMessage }))
|
|
556
|
-
}
|
|
557
|
-
console.error('One or more uploads failed:', uploadResult.errors)
|
|
558
|
-
setIsUploading(false)
|
|
559
|
-
return
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
// Replace pending StoredFileValues with real ones in form data
|
|
563
|
-
for (const [fieldPath, storedFile] of uploadResult.successful.entries()) {
|
|
564
|
-
setFieldValue(fieldPath, storedFile)
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// Clear pending uploads (blob URLs already revoked by clearPendingUploads)
|
|
568
|
-
clearPendingUploads()
|
|
569
|
-
} catch (err) {
|
|
570
|
-
console.error('Upload execution error:', err)
|
|
571
|
-
setIsUploading(false)
|
|
572
|
-
return
|
|
573
|
-
}
|
|
574
|
-
setIsUploading(false)
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
const data = getFieldValues()
|
|
578
|
-
const patches = getPatches()
|
|
579
|
-
const { contentDirty, pathDirty, availableLocalesDirty, reason } = getDirtyBreakdown()
|
|
580
|
-
const systemPath = getSystemPath()
|
|
581
|
-
// Only emit the advertised-locale set for collections that opted into the
|
|
582
|
-
// widget — otherwise leave it undefined so the write path never touches
|
|
583
|
-
// `byline_document_available_locales` for non-advertising collections.
|
|
584
|
-
const systemAvailableLocales = advertiseLocales ? getSystemAvailableLocales() : undefined
|
|
585
|
-
|
|
586
|
-
const payload: SystemFieldsSubmitPayload = {
|
|
587
|
-
data,
|
|
588
|
-
patches,
|
|
589
|
-
contentDirty,
|
|
590
|
-
pathDirty,
|
|
591
|
-
systemPath,
|
|
592
|
-
availableLocalesDirty,
|
|
593
|
-
systemAvailableLocales,
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// Editing the document-grain system fields (path / advertised locales) is
|
|
597
|
-
// an immediate, non-versioned write that does NOT reset workflow status,
|
|
598
|
-
// so confirm it before saving. Create mode writes everything as part of
|
|
599
|
-
// the initial version, so no confirmation applies there.
|
|
600
|
-
if (mode === 'edit' && (reason === 'direct-write' || reason === 'both')) {
|
|
601
|
-
setPendingSystemFieldsSubmit(payload)
|
|
602
|
-
return
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
await submitPayload(payload)
|
|
606
|
-
})()
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// Per-tab-set error counts: { [tabSetName]: { [tabName]: count } }.
|
|
610
|
-
// Each <Tabs> bar consumes its own slice.
|
|
611
|
-
const tabErrorCountsBySet = useMemo<Record<string, Record<string, number>>>(() => {
|
|
612
|
-
const result: Record<string, Record<string, number>> = {}
|
|
613
|
-
for (const err of errors) {
|
|
614
|
-
const path = fieldToTabPath.get(err.field)
|
|
615
|
-
if (!path) continue
|
|
616
|
-
result[path.tabSetName] ??= {}
|
|
617
|
-
result[path.tabSetName]![path.tabName] = (result[path.tabSetName]?.[path.tabName] ?? 0) + 1
|
|
618
|
-
}
|
|
619
|
-
return result
|
|
620
|
-
}, [errors, fieldToTabPath])
|
|
621
|
-
|
|
622
|
-
// -------------------------------------------------------------------
|
|
623
|
-
// Layout walk — recursively dispatches each name in a region to the
|
|
624
|
-
// appropriate primitive renderer or to <FieldRenderer>.
|
|
625
|
-
// -------------------------------------------------------------------
|
|
626
|
-
|
|
627
|
-
const renderField = (fieldName: string): ReactNode => {
|
|
628
|
-
const field = fieldByName.get(fieldName)
|
|
629
|
-
if (!field) return null
|
|
630
|
-
return (
|
|
631
|
-
<FieldRenderer
|
|
632
|
-
key={field.name}
|
|
633
|
-
field={field}
|
|
634
|
-
defaultValue={initialData?.fields?.[field.name]}
|
|
635
|
-
contentLocale={contentLocale}
|
|
636
|
-
components={adminConfig?.fields?.[field.name]?.components}
|
|
637
|
-
editor={adminConfig?.fields?.[field.name]?.editor}
|
|
638
|
-
fieldAdmin={sliceFieldAdmin(adminConfig?.fields, field.name)}
|
|
639
|
-
/>
|
|
640
|
-
)
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
const renderItem = (name: string): ReactNode => {
|
|
644
|
-
const tabSet = tabSetByName.get(name)
|
|
645
|
-
if (tabSet) return renderTabSet(tabSet)
|
|
646
|
-
|
|
647
|
-
const group = groupByName.get(name)
|
|
648
|
-
if (group) return renderGroup(group)
|
|
649
|
-
|
|
650
|
-
const row = rowByName.get(name)
|
|
651
|
-
if (row) return renderRow(row)
|
|
652
|
-
|
|
653
|
-
return renderField(name)
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
const renderRow = (row: RowDefinition): ReactNode => (
|
|
657
|
-
<AdminRow key={`row:${row.name}`}>{row.fields.map((name) => renderField(name))}</AdminRow>
|
|
658
|
-
)
|
|
659
|
-
|
|
660
|
-
const renderGroup = (group: GroupDefinition): ReactNode => (
|
|
661
|
-
<AdminGroup key={`group:${group.name}`} label={group.label}>
|
|
662
|
-
{group.fields.map((name) => renderItem(name))}
|
|
663
|
-
</AdminGroup>
|
|
664
|
-
)
|
|
665
|
-
|
|
666
|
-
const renderTabSet = (set: TabSetDefinition): ReactNode => {
|
|
667
|
-
const visibleTabs = set.tabs.filter((tab) => !tab.condition || tab.condition(formData))
|
|
668
|
-
const requested = activeTabBySet[set.name] ?? ''
|
|
669
|
-
const resolvedActive =
|
|
670
|
-
visibleTabs.length > 0 && !visibleTabs.some((t) => t.name === requested)
|
|
671
|
-
? (visibleTabs[0]?.name ?? requested)
|
|
672
|
-
: requested
|
|
673
|
-
const activeTab = visibleTabs.find((t) => t.name === resolvedActive)
|
|
674
|
-
const idBase = `tabset:${set.name}`
|
|
675
|
-
|
|
676
|
-
return (
|
|
677
|
-
<div key={idBase} className={cx('byline-form-tabset', styles.tabset)}>
|
|
678
|
-
{visibleTabs.length > 0 && (
|
|
679
|
-
<AdminTabs
|
|
680
|
-
idBase={idBase}
|
|
681
|
-
tabs={visibleTabs}
|
|
682
|
-
activeTab={resolvedActive}
|
|
683
|
-
onChange={(tabName) => handleTabChange(set.name, tabName)}
|
|
684
|
-
errorCounts={tabErrorCountsBySet[set.name]}
|
|
685
|
-
className={cx('byline-form-tabset-tabs', styles['tabset-tabs'])}
|
|
686
|
-
/>
|
|
687
|
-
)}
|
|
688
|
-
{activeTab && (
|
|
689
|
-
<div
|
|
690
|
-
role="tabpanel"
|
|
691
|
-
id={tabPanelId(idBase, activeTab.name)}
|
|
692
|
-
aria-labelledby={tabTriggerId(idBase, activeTab.name)}
|
|
693
|
-
className={cx('byline-form-tabset-fields', styles['tabset-fields'])}
|
|
694
|
-
>
|
|
695
|
-
{activeTab.fields.map((name) => renderItem(name))}
|
|
696
|
-
</div>
|
|
697
|
-
)}
|
|
698
|
-
</div>
|
|
699
|
-
)
|
|
426
|
+
if (mutationBlockedRef.current) return
|
|
427
|
+
void submission.submit()
|
|
700
428
|
}
|
|
701
429
|
|
|
702
430
|
const busyAnnouncement = isUploading
|
|
@@ -723,302 +451,89 @@ const FormContent = ({
|
|
|
723
451
|
className={cx('byline-form', styles.form)}
|
|
724
452
|
inert={isBusy ? true : undefined}
|
|
725
453
|
>
|
|
726
|
-
<
|
|
727
|
-
|
|
728
|
-
{
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
{
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
454
|
+
<FormHeadingRow heading={computedHeading} headerSlot={headerSlot} />
|
|
455
|
+
<FormStatusBar
|
|
456
|
+
disabled={mutationsBlocked || discarding}
|
|
457
|
+
initialData={initialData}
|
|
458
|
+
workflowStatuses={workflowStatuses}
|
|
459
|
+
publishedVersion={publishedVersion}
|
|
460
|
+
scheduling={scheduling}
|
|
461
|
+
hasChanges={hasChanges}
|
|
462
|
+
isUploading={isUploading}
|
|
463
|
+
isSubmitting={isSubmitting}
|
|
464
|
+
onCancel={handleCancel}
|
|
465
|
+
transitions={{ primaryStatus, secondaryStatuses, isTerminal }}
|
|
466
|
+
statusBusy={statusBusy}
|
|
467
|
+
onStatusBusyChange={setStatusBusy}
|
|
468
|
+
onStatusChange={onStatusChange}
|
|
469
|
+
onMutationError={onMutationError}
|
|
470
|
+
isBlocked={() => mutationBlockedRef.current}
|
|
471
|
+
onUnsavedChanges={() => setShowUnsavedModal(true)}
|
|
472
|
+
onUnpublish={onUnpublish}
|
|
473
|
+
onDelete={onDelete}
|
|
474
|
+
onDuplicate={onDuplicate}
|
|
475
|
+
onCopyToLocale={onCopyToLocale}
|
|
476
|
+
onDeleteLocale={onDeleteLocale}
|
|
477
|
+
useAsTitle={useAsTitle}
|
|
478
|
+
contentLocale={contentLocale}
|
|
479
|
+
contentLocales={contentLocales}
|
|
480
|
+
defaultLocale={defaultLocale}
|
|
481
|
+
/>
|
|
482
|
+
<FormConcurrencyNotices
|
|
483
|
+
disabled={mutationsBlocked || discarding}
|
|
484
|
+
mutationIssue={mutationIssue}
|
|
485
|
+
scheduledPublicationsNeedReconfirmation={scheduledPublicationsNeedReconfirmation}
|
|
486
|
+
scheduledPublicationsHref={scheduledPublicationsHref}
|
|
487
|
+
warningRef={warningRef}
|
|
488
|
+
discarding={discarding}
|
|
489
|
+
reloadFailed={reloadFailed}
|
|
490
|
+
onDiscardRequested={() => {
|
|
491
|
+
setReloadFailed(false)
|
|
492
|
+
setDiscarding(true)
|
|
493
|
+
}}
|
|
494
|
+
scheduling={scheduling}
|
|
495
|
+
restoreWarnings={restoreWarnings}
|
|
496
|
+
/>
|
|
497
|
+
<FormLayout
|
|
498
|
+
fields={fields}
|
|
499
|
+
adminConfig={adminConfig}
|
|
500
|
+
initialData={initialData}
|
|
501
|
+
activeLocale={contentLocale}
|
|
502
|
+
collectionPath={collectionPath ?? undefined}
|
|
503
|
+
activeTabBySet={activeTabBySet}
|
|
504
|
+
onTabChange={handleTabChange}
|
|
505
|
+
sidebarSlot={
|
|
506
|
+
<FormSidebarWidgets
|
|
737
507
|
disabled={mutationsBlocked || discarding}
|
|
508
|
+
mode={mode}
|
|
738
509
|
initialData={initialData}
|
|
739
|
-
|
|
740
|
-
publishedVersion={publishedVersion}
|
|
741
|
-
onUnpublish={onUnpublish}
|
|
742
|
-
afterStatusCells={
|
|
743
|
-
<ScheduledPublicationCell
|
|
744
|
-
state={scheduling.state}
|
|
745
|
-
timeZone={scheduling.timeZone}
|
|
746
|
-
/>
|
|
747
|
-
}
|
|
748
|
-
/>
|
|
749
|
-
</div>
|
|
750
|
-
<div className={cx('byline-form-actions', styles.actions)}>
|
|
751
|
-
<Button
|
|
752
|
-
className={cx('byline-form-actions-button', styles['actions-button'])}
|
|
753
|
-
size="sm"
|
|
754
|
-
intent="noeffect"
|
|
755
|
-
type="button"
|
|
756
|
-
onClick={handleCancel}
|
|
757
|
-
>
|
|
758
|
-
{hasChanges === false ? t('common.actions.close') : t('common.actions.cancel')}
|
|
759
|
-
</Button>
|
|
760
|
-
<Button
|
|
761
|
-
className={cx('byline-form-actions-button', styles['actions-button'])}
|
|
762
|
-
size="sm"
|
|
763
|
-
type="submit"
|
|
764
|
-
disabled={
|
|
765
|
-
mutationsBlocked ||
|
|
766
|
-
discarding ||
|
|
767
|
-
hasChanges === false ||
|
|
768
|
-
isUploading ||
|
|
769
|
-
isSubmitting
|
|
770
|
-
}
|
|
771
|
-
aria-label={isSubmitting ? t('common.actions.save') : undefined}
|
|
772
|
-
>
|
|
773
|
-
{isUploading ? (
|
|
774
|
-
t('forms.actions.uploading')
|
|
775
|
-
) : (
|
|
776
|
-
<span className={cx('byline-form-save-content', styles['save-content'])}>
|
|
777
|
-
<span
|
|
778
|
-
className={cx(
|
|
779
|
-
'byline-form-save-label',
|
|
780
|
-
styles['save-label'],
|
|
781
|
-
isSubmitting && styles['save-label-hidden']
|
|
782
|
-
)}
|
|
783
|
-
>
|
|
784
|
-
{t('common.actions.save')}
|
|
785
|
-
</span>
|
|
786
|
-
{isSubmitting ? (
|
|
787
|
-
<span className={cx('byline-form-save-loader', styles['save-loader'])}>
|
|
788
|
-
<LoaderEllipsis size={28} aria-hidden="true" />
|
|
789
|
-
</span>
|
|
790
|
-
) : null}
|
|
791
|
-
</span>
|
|
792
|
-
)}
|
|
793
|
-
</Button>
|
|
794
|
-
{primaryStatus && onStatusChange && (
|
|
795
|
-
<div
|
|
796
|
-
className={cx('byline-form-actions-status-wrap', styles['actions-status-wrap'])}
|
|
797
|
-
>
|
|
798
|
-
<ComboButton
|
|
799
|
-
buttonClassName={cx(
|
|
800
|
-
'byline-form-actions-combo-button',
|
|
801
|
-
styles['actions-combo-button']
|
|
802
|
-
)}
|
|
803
|
-
triggerClassName={cx(
|
|
804
|
-
'byline-form-actions-combo-trigger',
|
|
805
|
-
styles['actions-combo-trigger']
|
|
806
|
-
)}
|
|
807
|
-
options={secondaryStatuses.map((s) => ({
|
|
808
|
-
label: isTerminal
|
|
809
|
-
? t('forms.actions.revertTo', { label: s.label ?? s.name })
|
|
810
|
-
: (s.verb ?? s.label ?? s.name),
|
|
811
|
-
value: s.name,
|
|
812
|
-
}))}
|
|
813
|
-
sideOffset={5}
|
|
814
|
-
size="sm"
|
|
815
|
-
type="button"
|
|
816
|
-
intent={isTerminal ? 'info' : 'success'}
|
|
817
|
-
disabled={mutationsBlocked || discarding || statusBusy}
|
|
818
|
-
onOptionSelect={async (value: string) => {
|
|
819
|
-
if (mutationBlockedRef.current) return
|
|
820
|
-
if (hasChanges) {
|
|
821
|
-
setShowUnsavedModal(true)
|
|
822
|
-
return
|
|
823
|
-
}
|
|
824
|
-
setStatusBusy(true)
|
|
825
|
-
try {
|
|
826
|
-
await onStatusChange(value)
|
|
827
|
-
} catch (error) {
|
|
828
|
-
onMutationError?.(error)
|
|
829
|
-
} finally {
|
|
830
|
-
setStatusBusy(false)
|
|
831
|
-
}
|
|
832
|
-
}}
|
|
833
|
-
onButtonClick={
|
|
834
|
-
isTerminal
|
|
835
|
-
? undefined
|
|
836
|
-
: async () => {
|
|
837
|
-
if (mutationBlockedRef.current) return
|
|
838
|
-
if (hasChanges) {
|
|
839
|
-
setShowUnsavedModal(true)
|
|
840
|
-
return
|
|
841
|
-
}
|
|
842
|
-
setStatusBusy(true)
|
|
843
|
-
try {
|
|
844
|
-
await onStatusChange(primaryStatus.name)
|
|
845
|
-
} catch (error) {
|
|
846
|
-
onMutationError?.(error)
|
|
847
|
-
} finally {
|
|
848
|
-
setStatusBusy(false)
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
>
|
|
853
|
-
{statusBusy
|
|
854
|
-
? '...'
|
|
855
|
-
: isTerminal
|
|
856
|
-
? (primaryStatus.label ?? primaryStatus.name)
|
|
857
|
-
: (primaryStatus.verb ?? primaryStatus.label ?? primaryStatus.name)}
|
|
858
|
-
</ComboButton>
|
|
859
|
-
</div>
|
|
860
|
-
)}
|
|
861
|
-
<DocumentActions
|
|
862
|
-
disabled={mutationsBlocked || discarding}
|
|
863
|
-
publishedVersion={publishedVersion}
|
|
864
|
-
onUnpublish={onUnpublish}
|
|
865
|
-
onDelete={onDelete}
|
|
866
|
-
onDuplicate={onDuplicate}
|
|
867
|
-
sourceTitle={
|
|
868
|
-
useAsTitle != null && initialData != null
|
|
869
|
-
? ((initialData as Record<string, unknown>)[useAsTitle] as
|
|
870
|
-
| string
|
|
871
|
-
| null
|
|
872
|
-
| undefined)
|
|
873
|
-
: null
|
|
874
|
-
}
|
|
875
|
-
onCopyToLocale={onCopyToLocale}
|
|
876
|
-
sourceLocale={contentLocale}
|
|
877
|
-
contentLocales={contentLocales}
|
|
878
|
-
hasUnsavedChanges={hasChanges}
|
|
879
|
-
onUnsavedChanges={() => setShowUnsavedModal(true)}
|
|
880
|
-
onDeleteLocale={onDeleteLocale}
|
|
510
|
+
collectionPath={collectionPath ?? ''}
|
|
881
511
|
defaultLocale={defaultLocale}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
512
|
+
contentLocale={contentLocale}
|
|
513
|
+
contentLocales={contentLocales}
|
|
514
|
+
showPath={showPath}
|
|
515
|
+
useAsPath={useAsPath}
|
|
516
|
+
lockPath={adminConfig?.lockPath}
|
|
517
|
+
pathSlugifier={pathSlugifier}
|
|
518
|
+
pathSourceLocked={pathSourceLocked}
|
|
519
|
+
tree={tree}
|
|
520
|
+
useAsTitle={useAsTitle}
|
|
521
|
+
advertiseLocales={advertiseLocales}
|
|
522
|
+
observedRevision={observedRevision}
|
|
523
|
+
onMutationError={onMutationError}
|
|
524
|
+
onTreeMutationCommitted={onTreeMutationCommitted}
|
|
887
525
|
/>
|
|
888
|
-
|
|
889
|
-
</div>
|
|
890
|
-
{(mutationIssue || scheduledPublicationsNeedReconfirmation) && (
|
|
891
|
-
<div
|
|
892
|
-
ref={warningRef}
|
|
893
|
-
tabIndex={-1}
|
|
894
|
-
role="alert"
|
|
895
|
-
aria-live="assertive"
|
|
896
|
-
className={cx('byline-document-concurrency', styles.concurrency)}
|
|
897
|
-
>
|
|
898
|
-
{mutationIssue && (
|
|
899
|
-
<Alert
|
|
900
|
-
intent="warning"
|
|
901
|
-
icon
|
|
902
|
-
close={false}
|
|
903
|
-
title={t(`documentConcurrency.${mutationIssue}Title`)}
|
|
904
|
-
>
|
|
905
|
-
<p>{t(`documentConcurrency.${mutationIssue}`)}</p>
|
|
906
|
-
{mutationIssue !== 'committed' && (
|
|
907
|
-
<Button
|
|
908
|
-
type="button"
|
|
909
|
-
disabled={discarding}
|
|
910
|
-
onClick={() => {
|
|
911
|
-
setReloadFailed(false)
|
|
912
|
-
setDiscarding(true)
|
|
913
|
-
}}
|
|
914
|
-
>
|
|
915
|
-
{t('documentConcurrency.reloadAction')}
|
|
916
|
-
</Button>
|
|
917
|
-
)}
|
|
918
|
-
{reloadFailed && <p>{t('documentConcurrency.reloadFailed')}</p>}
|
|
919
|
-
</Alert>
|
|
920
|
-
)}
|
|
921
|
-
{scheduledPublicationsNeedReconfirmation && (
|
|
922
|
-
<Alert
|
|
923
|
-
intent="warning"
|
|
924
|
-
icon
|
|
925
|
-
close={false}
|
|
926
|
-
title={t('documentConcurrency.schedulesTitle')}
|
|
927
|
-
>
|
|
928
|
-
<p>{t('documentConcurrency.schedules')}</p>
|
|
929
|
-
{scheduledPublicationsHref && (
|
|
930
|
-
<a href={scheduledPublicationsHref}>
|
|
931
|
-
{t('documentConcurrency.reviewSchedules')}
|
|
932
|
-
</a>
|
|
933
|
-
)}
|
|
934
|
-
</Alert>
|
|
935
|
-
)}
|
|
936
|
-
</div>
|
|
937
|
-
)}
|
|
938
|
-
<ScheduledPublicationNotice
|
|
939
|
-
state={scheduling.state}
|
|
940
|
-
timeZone={scheduling.timeZone}
|
|
941
|
-
busy={mutationsBlocked || discarding || scheduling.busy}
|
|
942
|
-
onConfirm={scheduling.confirm}
|
|
943
|
-
onReschedule={scheduling.openSchedule}
|
|
944
|
-
onCancel={scheduling.cancel}
|
|
526
|
+
}
|
|
945
527
|
/>
|
|
946
|
-
{scheduling.modal}
|
|
947
|
-
{restoreWarnings && restoreWarnings.length > 0 && (
|
|
948
|
-
<Alert
|
|
949
|
-
className="m-0 mt-4"
|
|
950
|
-
intent="warning"
|
|
951
|
-
icon={true}
|
|
952
|
-
close={false}
|
|
953
|
-
title={t('forms.restoreWarnings.title')}
|
|
954
|
-
>
|
|
955
|
-
<p>{t('forms.restoreWarnings.body', { count: restoreWarnings.length })}</p>
|
|
956
|
-
<ul>
|
|
957
|
-
{restoreWarnings.map((w) => (
|
|
958
|
-
<li key={w}>{w}</li>
|
|
959
|
-
))}
|
|
960
|
-
</ul>
|
|
961
|
-
</Alert>
|
|
962
|
-
)}
|
|
963
|
-
<div className={cx('byline-form-layout', styles.layout)}>
|
|
964
|
-
<div className={cx('byline-form-content', styles.content)}>
|
|
965
|
-
{layout.main.map((name) => renderItem(name))}
|
|
966
|
-
</div>
|
|
967
|
-
<div className={cx('byline-form-sidebar', styles.sidebar)}>
|
|
968
|
-
{/* A locked collection's widget renders even with no `useAsPath`
|
|
969
|
-
and nothing stored yet: its path is managed, and the editor
|
|
970
|
-
needs to see that. `showPath: false` still wins — it marks a
|
|
971
|
-
path that must never be presented at all. */}
|
|
972
|
-
{showPath &&
|
|
973
|
-
(useAsPath ||
|
|
974
|
-
adminConfig?.lockPath === true ||
|
|
975
|
-
(typeof initialData?.path === 'string' && initialData.path.length > 0)) && (
|
|
976
|
-
<PathWidget
|
|
977
|
-
disabled={mutationsBlocked || discarding}
|
|
978
|
-
useAsPath={useAsPath}
|
|
979
|
-
collectionPath={collectionPath ?? ''}
|
|
980
|
-
defaultLocale={defaultLocale}
|
|
981
|
-
activeLocale={contentLocale}
|
|
982
|
-
mode={mode}
|
|
983
|
-
slugifier={pathSlugifier}
|
|
984
|
-
sourceLocked={pathSourceLocked}
|
|
985
|
-
lockPath={adminConfig?.lockPath}
|
|
986
|
-
/>
|
|
987
|
-
)}
|
|
988
|
-
{tree && mode === 'edit' && typeof initialData?.id === 'string' && (
|
|
989
|
-
<TreePlacementWidget
|
|
990
|
-
disabled={mutationsBlocked || discarding}
|
|
991
|
-
onMutationError={onMutationError}
|
|
992
|
-
onCommitted={onTreeMutationCommitted}
|
|
993
|
-
expectedRevision={observedRevision ?? initialData.revision}
|
|
994
|
-
collectionPath={collectionPath ?? ''}
|
|
995
|
-
documentId={initialData.id as string}
|
|
996
|
-
useAsTitle={useAsTitle}
|
|
997
|
-
/>
|
|
998
|
-
)}
|
|
999
|
-
{advertiseLocales && (
|
|
1000
|
-
<AvailableLocalesWidget
|
|
1001
|
-
disabled={mutationsBlocked || discarding}
|
|
1002
|
-
contentLocales={contentLocales ?? []}
|
|
1003
|
-
availableVersionLocales={
|
|
1004
|
-
(initialData?._availableVersionLocales as string[] | undefined) ?? []
|
|
1005
|
-
}
|
|
1006
|
-
/>
|
|
1007
|
-
)}
|
|
1008
|
-
{(layout.sidebar ?? []).map((name) => renderItem(name))}
|
|
1009
|
-
</div>
|
|
1010
|
-
</div>
|
|
1011
528
|
{showUnsavedModal && <UnsavedChangesModal onClose={() => setShowUnsavedModal(false)} />}
|
|
1012
529
|
{!mutationsBlocked && !discarding && pendingSystemFieldsSubmit != null && (
|
|
1013
530
|
<SystemFieldsConfirmModal
|
|
1014
531
|
contentDirty={pendingSystemFieldsSubmit.contentDirty}
|
|
1015
532
|
pathDirty={pendingSystemFieldsSubmit.pathDirty}
|
|
1016
533
|
availableLocalesDirty={pendingSystemFieldsSubmit.availableLocalesDirty}
|
|
1017
|
-
onCancel={
|
|
534
|
+
onCancel={submission.cancelSystemFields}
|
|
1018
535
|
onConfirm={() => {
|
|
1019
|
-
|
|
1020
|
-
setPendingSystemFieldsSubmit(null)
|
|
1021
|
-
void submitPayload(payload)
|
|
536
|
+
void submission.confirmSystemFields()
|
|
1022
537
|
}}
|
|
1023
538
|
/>
|
|
1024
539
|
)}
|