@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.
Files changed (75) hide show
  1. package/dist/fields/checkbox/checkbox-field.js +3 -1
  2. package/dist/fields/code/code-field.js +2 -1
  3. package/dist/fields/datetime/datetime-field.js +4 -2
  4. package/dist/fields/field-renderer.js +2 -1
  5. package/dist/fields/field-services-types.d.ts +23 -0
  6. package/dist/fields/file/file-field.js +3 -2
  7. package/dist/fields/group/group-field.js +3 -1
  8. package/dist/fields/image/image-field.js +3 -2
  9. package/dist/fields/numerical/numerical-field.js +2 -1
  10. package/dist/fields/relation/relation-field.js +3 -2
  11. package/dist/fields/relation/relation-many-field.js +3 -2
  12. package/dist/fields/relation/relation-picker.js +38 -7
  13. package/dist/fields/relation/relation-picker.module.js +3 -0
  14. package/dist/fields/relation/relation-picker_module.css +20 -0
  15. package/dist/fields/select/select-field.js +3 -2
  16. package/dist/fields/text/text-field.js +2 -1
  17. package/dist/fields/text-area/text-area-field.js +2 -1
  18. package/dist/forms/form-context.js +4 -1
  19. package/dist/forms/form-dom-scope.d.ts +34 -0
  20. package/dist/forms/form-dom-scope.js +20 -0
  21. package/dist/forms/form-layout-styles.test.node.d.ts +8 -0
  22. package/dist/forms/form-layout.d.ts +39 -0
  23. package/dist/forms/form-layout.js +102 -0
  24. package/dist/forms/form-page-chrome.d.ts +110 -0
  25. package/dist/forms/form-page-chrome.js +253 -0
  26. package/dist/forms/form-renderer.d.ts +2 -18
  27. package/dist/forms/form-renderer.js +105 -423
  28. package/dist/forms/form-renderer.module.js +2 -0
  29. package/dist/forms/form-renderer_module.css +13 -0
  30. package/dist/forms/use-form-submission.d.ts +74 -0
  31. package/dist/forms/use-form-submission.js +166 -0
  32. package/dist/forms/use-form-tabs.d.ts +29 -0
  33. package/dist/forms/use-form-tabs.js +45 -0
  34. package/dist/presentation/tabs.js +2 -1
  35. package/dist/react.d.ts +1 -1
  36. package/package.json +7 -7
  37. package/src/fields/checkbox/checkbox-field.tsx +3 -1
  38. package/src/fields/code/code-field.tsx +2 -1
  39. package/src/fields/datetime/datetime-field.tsx +4 -2
  40. package/src/fields/direct-widget-dom-ids.test.tsx +142 -0
  41. package/src/fields/field-dom-ids.test.tsx +293 -0
  42. package/src/fields/field-renderer.tsx +2 -1
  43. package/src/fields/field-services-types.ts +25 -0
  44. package/src/fields/file/file-field.tsx +3 -2
  45. package/src/fields/group/group-field.tsx +3 -1
  46. package/src/fields/image/image-field.tsx +3 -2
  47. package/src/fields/numerical/numerical-field.tsx +2 -1
  48. package/src/fields/relation/relation-field.tsx +3 -2
  49. package/src/fields/relation/relation-many-field.tsx +3 -2
  50. package/src/fields/relation/relation-picker-create.test.tsx +203 -0
  51. package/src/fields/relation/relation-picker-refresh.test.tsx +325 -0
  52. package/src/fields/relation/relation-picker.module.css +32 -0
  53. package/src/fields/relation/relation-picker.tsx +59 -1
  54. package/src/fields/select/select-field.tsx +3 -2
  55. package/src/fields/text/text-field.tsx +2 -1
  56. package/src/fields/text-area/text-area-field.tsx +2 -1
  57. package/src/forms/form-context.tsx +2 -1
  58. package/src/forms/form-dom-scope.test.tsx +213 -0
  59. package/src/forms/form-dom-scope.tsx +70 -0
  60. package/src/forms/form-layout-styles.test.node.ts +32 -0
  61. package/src/forms/form-layout.test.tsx +276 -0
  62. package/src/forms/form-layout.tsx +204 -0
  63. package/src/forms/form-page-chrome.tsx +460 -0
  64. package/src/forms/form-renderer-submit.test.tsx +40 -0
  65. package/src/forms/form-renderer-tab-persistence.test.tsx +145 -0
  66. package/src/forms/form-renderer.module.css +18 -0
  67. package/src/forms/form-renderer.tsx +126 -600
  68. package/src/forms/form-submit-isolation.test.tsx +247 -0
  69. package/src/forms/use-form-submission.test.tsx +564 -0
  70. package/src/forms/use-form-submission.ts +293 -0
  71. package/src/forms/use-form-tabs.ts +89 -0
  72. package/src/presentation/tab-dom-ids.test.tsx +153 -0
  73. package/src/presentation/tabs.test.tsx +49 -2
  74. package/src/presentation/tabs.tsx +11 -1
  75. package/src/react.ts +2 -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 { TreePlacementWidget } from './tree-placement-widget'
50
- import { executeUploadsWithProgress } from './upload-executor'
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
- const {
263
- getFieldValues,
264
- runFieldHooks,
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
- // Keep formData in sync for evaluating TabDefinition.condition functions
437
- useEffect(() => {
438
- return subscribeMeta(() => setFormData(getFieldValues()))
439
- }, [subscribeMeta, getFieldValues])
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,27 @@ 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
- }
425
+ // A form rendered into a portal is not a DOM descendant of this one, so no
426
+ // invalid nested-form markup arises — but React propagates synthetic events
427
+ // along the *React* tree, so its submit still reaches this handler. Nothing
428
+ // about `currentTarget` reveals that: it is always this form while the
429
+ // handler runs, bubbled event or not. The event's origin is what
430
+ // distinguishes them.
431
+ //
432
+ // Deliberately no `preventDefault()` on the way out: the submit belongs to
433
+ // the inner form, and suppressing its default here would take that decision
434
+ // away from the handler that owns it.
435
+ if (e.target !== e.currentTarget) return
512
436
  e.preventDefault()
513
-
514
- // Run field-level beforeValidate hooks (submit-time), then validate
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
- )
437
+ if (mutationBlockedRef.current) return
438
+ void submission.submit()
700
439
  }
701
440
 
702
441
  const busyAnnouncement = isUploading
@@ -723,302 +462,89 @@ const FormContent = ({
723
462
  className={cx('byline-form', styles.form)}
724
463
  inert={isBusy ? true : undefined}
725
464
  >
726
- <div className={cx('byline-form-heading-row', styles['heading-row'])}>
727
- <h1 className={cx('byline-form-heading', styles.heading)}>{computedHeading}</h1>
728
- {/* Source-locale anchor indicator removed pending heading-layout work.
729
- To re-enable: render `<SourceLocaleBadge locale={sourceLocale} />`
730
- here from `initialData.sourceLocale` (mismatch-only is the intended
731
- end state). See docs/08-internationalization/index.md. */}
732
- {headerSlot}
733
- </div>
734
- <div className={cx('byline-form-status-bar', styles['status-bar'])}>
735
- <div className={cx('byline-form-status-details', styles['status-details'])}>
736
- <FormStatusDisplay
465
+ <FormHeadingRow heading={computedHeading} headerSlot={headerSlot} />
466
+ <FormStatusBar
467
+ disabled={mutationsBlocked || discarding}
468
+ initialData={initialData}
469
+ workflowStatuses={workflowStatuses}
470
+ publishedVersion={publishedVersion}
471
+ scheduling={scheduling}
472
+ hasChanges={hasChanges}
473
+ isUploading={isUploading}
474
+ isSubmitting={isSubmitting}
475
+ onCancel={handleCancel}
476
+ transitions={{ primaryStatus, secondaryStatuses, isTerminal }}
477
+ statusBusy={statusBusy}
478
+ onStatusBusyChange={setStatusBusy}
479
+ onStatusChange={onStatusChange}
480
+ onMutationError={onMutationError}
481
+ isBlocked={() => mutationBlockedRef.current}
482
+ onUnsavedChanges={() => setShowUnsavedModal(true)}
483
+ onUnpublish={onUnpublish}
484
+ onDelete={onDelete}
485
+ onDuplicate={onDuplicate}
486
+ onCopyToLocale={onCopyToLocale}
487
+ onDeleteLocale={onDeleteLocale}
488
+ useAsTitle={useAsTitle}
489
+ contentLocale={contentLocale}
490
+ contentLocales={contentLocales}
491
+ defaultLocale={defaultLocale}
492
+ />
493
+ <FormConcurrencyNotices
494
+ disabled={mutationsBlocked || discarding}
495
+ mutationIssue={mutationIssue}
496
+ scheduledPublicationsNeedReconfirmation={scheduledPublicationsNeedReconfirmation}
497
+ scheduledPublicationsHref={scheduledPublicationsHref}
498
+ warningRef={warningRef}
499
+ discarding={discarding}
500
+ reloadFailed={reloadFailed}
501
+ onDiscardRequested={() => {
502
+ setReloadFailed(false)
503
+ setDiscarding(true)
504
+ }}
505
+ scheduling={scheduling}
506
+ restoreWarnings={restoreWarnings}
507
+ />
508
+ <FormLayout
509
+ fields={fields}
510
+ adminConfig={adminConfig}
511
+ initialData={initialData}
512
+ activeLocale={contentLocale}
513
+ collectionPath={collectionPath ?? undefined}
514
+ activeTabBySet={activeTabBySet}
515
+ onTabChange={handleTabChange}
516
+ sidebarSlot={
517
+ <FormSidebarWidgets
737
518
  disabled={mutationsBlocked || discarding}
519
+ mode={mode}
738
520
  initialData={initialData}
739
- workflowStatuses={workflowStatuses}
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}
521
+ collectionPath={collectionPath ?? ''}
881
522
  defaultLocale={defaultLocale}
882
- availableLocales={initialData?._availableVersionLocales as string[] | undefined}
883
- scheduledPublicationState={scheduling.state}
884
- onSchedulePublication={scheduling.openSchedule}
885
- onConfirmScheduledPublication={scheduling.confirm}
886
- onCancelScheduledPublication={scheduling.cancel}
523
+ contentLocale={contentLocale}
524
+ contentLocales={contentLocales}
525
+ showPath={showPath}
526
+ useAsPath={useAsPath}
527
+ lockPath={adminConfig?.lockPath}
528
+ pathSlugifier={pathSlugifier}
529
+ pathSourceLocked={pathSourceLocked}
530
+ tree={tree}
531
+ useAsTitle={useAsTitle}
532
+ advertiseLocales={advertiseLocales}
533
+ observedRevision={observedRevision}
534
+ onMutationError={onMutationError}
535
+ onTreeMutationCommitted={onTreeMutationCommitted}
887
536
  />
888
- </div>
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}
537
+ }
945
538
  />
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
539
  {showUnsavedModal && <UnsavedChangesModal onClose={() => setShowUnsavedModal(false)} />}
1012
540
  {!mutationsBlocked && !discarding && pendingSystemFieldsSubmit != null && (
1013
541
  <SystemFieldsConfirmModal
1014
542
  contentDirty={pendingSystemFieldsSubmit.contentDirty}
1015
543
  pathDirty={pendingSystemFieldsSubmit.pathDirty}
1016
544
  availableLocalesDirty={pendingSystemFieldsSubmit.availableLocalesDirty}
1017
- onCancel={() => setPendingSystemFieldsSubmit(null)}
545
+ onCancel={submission.cancelSystemFields}
1018
546
  onConfirm={() => {
1019
- const payload = pendingSystemFieldsSubmit
1020
- setPendingSystemFieldsSubmit(null)
1021
- void submitPayload(payload)
547
+ void submission.confirmSystemFields()
1022
548
  }}
1023
549
  />
1024
550
  )}