@byline/admin 4.18.0 → 5.0.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.
@@ -21,7 +21,7 @@ import type {
21
21
  import { getAdminConfig } from '@byline/core'
22
22
  import type { DocumentPatch } from '@byline/core/patches'
23
23
  import { useTranslation } from '@byline/i18n/react'
24
- import { Alert, Button, ComboButton } from '@byline/ui/react'
24
+ import { Alert, Button, ComboButton, LoaderEllipsis } from '@byline/ui/react'
25
25
  import cx from 'clsx'
26
26
 
27
27
  import { sliceFieldAdmin } from '../fields/field-admin'
@@ -80,6 +80,15 @@ export interface SystemFieldsSubmitPayload {
80
80
 
81
81
  /** Props shared by both the public FormRenderer and its internal FormContent component. */
82
82
  export interface FormRendererProps {
83
+ mutationIssue?: 'stale' | 'reload' | 'lock' | 'unavailable' | 'committed' | null
84
+ mutationsBlocked?: boolean
85
+ observedRevision?: number
86
+ onMutationError?: (error: unknown) => 'blocked' | 'committed' | null | void
87
+ onTreeMutationCommitted?: (receipt: import('@byline/core').StructuralMutationReceipt) => void
88
+ scheduledPublicationsNeedReconfirmation?: boolean
89
+ scheduledPublicationsHref?: string
90
+ /** Explicit discard action; defaults to a complete server reload. */
91
+ onReloadDocument?: () => void | Promise<void>
83
92
  mode: 'create' | 'edit'
84
93
  fields: Field[]
85
94
  onSubmit: (data: any) => void | Promise<void>
@@ -196,6 +205,14 @@ export interface FormRendererProps {
196
205
 
197
206
  const FormContent = ({
198
207
  mode,
208
+ mutationIssue,
209
+ mutationsBlocked = false,
210
+ observedRevision,
211
+ onMutationError,
212
+ onTreeMutationCommitted,
213
+ scheduledPublicationsNeedReconfirmation = false,
214
+ scheduledPublicationsHref,
215
+ onReloadDocument,
199
216
  fields,
200
217
  onSubmit,
201
218
  onCancel,
@@ -263,6 +280,10 @@ const FormContent = ({
263
280
  const [isUploading, setIsUploading] = useState(false)
264
281
  const submittingRef = useRef(false)
265
282
  const [isSubmitting, setIsSubmitting] = useState(false)
283
+ const isBusy = isUploading || isSubmitting
284
+ const formRef = useRef<HTMLFormElement>(null)
285
+ const focusBeforeBusyRef = useRef<HTMLElement | null>(null)
286
+ const restoreFocusAfterBusyRef = useRef(false)
266
287
  // Block-only "save first" guard. Set true when the editor triggers a
267
288
  // guarded action (status change, duplicate, copy-to-locale) while the form
268
289
  // is dirty — those actions operate on the saved version, so unsaved edits
@@ -279,6 +300,7 @@ const FormContent = ({
279
300
  // escalated notice, and the schedule modal — so its state lives here and the
280
301
  // surfaces are rendered where each belongs.
281
302
  const scheduling = useScheduledPublication({
303
+ disabled: mutationsBlocked,
282
304
  schedule: scheduledPublication ?? null,
283
305
  onSchedule: onSchedulePublication,
284
306
  onConfirm: onConfirmScheduledPublication,
@@ -369,7 +391,25 @@ const FormContent = ({
369
391
  // The guard hook is injected by the consuming framework (prop > context > no-op fallback).
370
392
  const guardFromContext = useNavigationGuardAdapter()
371
393
  const useGuard = useNavigationGuardProp ?? guardFromContext
372
- const guard = useGuard(hasChanges)
394
+ const [discarding, setDiscarding] = useState(false)
395
+ const [reloadFailed, setReloadFailed] = useState(false)
396
+ const warningRef = useRef<HTMLDivElement>(null)
397
+ const mutationBlockedRef = useRef(mutationsBlocked)
398
+ mutationBlockedRef.current = mutationsBlocked || discarding
399
+ const guard = useGuard(hasChanges && !discarding)
400
+ useEffect(() => {
401
+ if (mutationIssue) warningRef.current?.focus()
402
+ }, [mutationIssue])
403
+ useEffect(() => {
404
+ if (!discarding) return
405
+ // Let the guard's beforeunload listener detach before the explicit discard.
406
+ Promise.resolve()
407
+ .then(() => (onReloadDocument ? onReloadDocument() : window.location.reload()))
408
+ .catch(() => {
409
+ setDiscarding(false)
410
+ setReloadFailed(true)
411
+ })
412
+ }, [discarding, onReloadDocument])
373
413
 
374
414
  // Compute available status transitions
375
415
  const currentStatus = initialData?.status
@@ -392,6 +432,39 @@ const FormContent = ({
392
432
  return subscribeMeta(() => setFormData(getFieldValues()))
393
433
  }, [subscribeMeta, getFieldValues])
394
434
 
435
+ // `inert` removes the active control from the tab order while a save is in
436
+ // flight. Restore the editor's position after React has removed `inert`;
437
+ // when the original control became disabled, fall back to the first usable
438
+ // form control instead of leaving focus on <body>.
439
+ useEffect(() => {
440
+ if (isBusy || !restoreFocusAfterBusyRef.current) return
441
+ restoreFocusAfterBusyRef.current = false
442
+
443
+ if (mutationIssue) {
444
+ warningRef.current?.focus()
445
+ focusBeforeBusyRef.current = null
446
+ return
447
+ }
448
+ const original = focusBeforeBusyRef.current
449
+ focusBeforeBusyRef.current = null
450
+ const originalCanReceiveFocus =
451
+ original?.isConnected === true && !original.matches(':disabled, [aria-disabled="true"]')
452
+ const target = originalCanReceiveFocus
453
+ ? original
454
+ : formRef.current?.querySelector<HTMLElement>(
455
+ 'input:not(:disabled), textarea:not(:disabled), select:not(:disabled), button:not(:disabled), [tabindex]:not([tabindex="-1"])'
456
+ )
457
+ target?.focus({ preventScroll: true })
458
+ }, [isBusy, mutationIssue])
459
+
460
+ const captureFocusBeforeBusy = useCallback(() => {
461
+ if (focusBeforeBusyRef.current != null) return
462
+ const activeElement = document.activeElement
463
+ if (!(activeElement instanceof HTMLElement) || !formRef.current?.contains(activeElement)) return
464
+ focusBeforeBusyRef.current = activeElement
465
+ restoreFocusAfterBusyRef.current = true
466
+ }, [])
467
+
395
468
  const handleCancel = () => {
396
469
  if (onCancel && typeof onCancel === 'function') {
397
470
  onCancel()
@@ -406,9 +479,10 @@ const FormContent = ({
406
479
  // mid-flight; the mirrored state drives the Save button's disabled prop.
407
480
  const submitPayload = useCallback(
408
481
  async (payload: SystemFieldsSubmitPayload) => {
409
- if (typeof onSubmit !== 'function') return
482
+ if (mutationBlockedRef.current || typeof onSubmit !== 'function') return
410
483
  if (submittingRef.current) return
411
484
  submittingRef.current = true
485
+ captureFocusBeforeBusy()
412
486
  setIsSubmitting(true)
413
487
  try {
414
488
  await onSubmit(payload)
@@ -421,10 +495,14 @@ const FormContent = ({
421
495
  setIsSubmitting(false)
422
496
  }
423
497
  },
424
- [onSubmit, resetHasChanges]
498
+ [captureFocusBeforeBusy, onSubmit, resetHasChanges]
425
499
  )
426
500
 
427
501
  const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
502
+ if (mutationBlockedRef.current) {
503
+ e.preventDefault()
504
+ return
505
+ }
428
506
  e.preventDefault()
429
507
 
430
508
  // Run field-level beforeValidate hooks (submit-time), then validate
@@ -438,9 +516,12 @@ const FormContent = ({
438
516
  return
439
517
  }
440
518
 
519
+ if (mutationBlockedRef.current) return
520
+
441
521
  // Execute any pending uploads before submitting
442
522
  const pendingUploads = getPendingUploads()
443
523
  if (pendingUploads.size > 0) {
524
+ captureFocusBeforeBusy()
444
525
  setIsUploading(true)
445
526
  try {
446
527
  const uploadResult = await executeUploadsWithProgress(
@@ -605,217 +686,329 @@ const FormContent = ({
605
686
  )
606
687
  }
607
688
 
689
+ const busyAnnouncement = isUploading
690
+ ? t('forms.actions.uploading')
691
+ : isSubmitting
692
+ ? t('forms.actions.saving')
693
+ : ''
694
+
608
695
  return (
609
- <form
610
- method="post"
611
- noValidate
612
- onSubmit={handleSubmit}
613
- className={cx('byline-form', styles.form)}
614
- inert={isUploading ? true : undefined}
615
- aria-busy={isUploading}
616
- >
617
- <div className={cx('byline-form-heading-row', styles['heading-row'])}>
618
- <h1 className={cx('byline-form-heading', styles.heading)}>{computedHeading}</h1>
619
- {/* Source-locale anchor indicator removed pending heading-layout work.
696
+ <>
697
+ <span
698
+ className={cx('byline-form-busy-status', styles['busy-status'])}
699
+ role="status"
700
+ aria-live="polite"
701
+ >
702
+ {busyAnnouncement}
703
+ </span>
704
+ <div aria-busy={isBusy}>
705
+ <form
706
+ ref={formRef}
707
+ method="post"
708
+ noValidate
709
+ onSubmit={handleSubmit}
710
+ className={cx('byline-form', styles.form)}
711
+ inert={isBusy ? true : undefined}
712
+ >
713
+ <div className={cx('byline-form-heading-row', styles['heading-row'])}>
714
+ <h1 className={cx('byline-form-heading', styles.heading)}>{computedHeading}</h1>
715
+ {/* Source-locale anchor indicator removed pending heading-layout work.
620
716
  To re-enable: render `<SourceLocaleBadge locale={sourceLocale} />`
621
717
  here from `initialData.sourceLocale` (mismatch-only is the intended
622
718
  end state). See docs/08-internationalization/index.md. */}
623
- {headerSlot}
624
- </div>
625
- <div className={cx('byline-form-status-bar', styles['status-bar'])}>
626
- <div className={cx('byline-form-status-details', styles['status-details'])}>
627
- <FormStatusDisplay
628
- initialData={initialData}
629
- workflowStatuses={workflowStatuses}
630
- publishedVersion={publishedVersion}
631
- onUnpublish={onUnpublish}
632
- afterStatusCells={
633
- <ScheduledPublicationCell state={scheduling.state} timeZone={scheduling.timeZone} />
634
- }
635
- />
636
- </div>
637
- <div className={cx('byline-form-actions', styles.actions)}>
638
- <Button
639
- className={cx('byline-form-actions-button', styles['actions-button'])}
640
- size="sm"
641
- intent="noeffect"
642
- type="button"
643
- onClick={handleCancel}
644
- >
645
- {hasChanges === false ? t('common.actions.close') : t('common.actions.cancel')}
646
- </Button>
647
- <Button
648
- className={cx('byline-form-actions-button', styles['actions-button'])}
649
- size="sm"
650
- type="submit"
651
- disabled={hasChanges === false || isUploading || isSubmitting}
652
- >
653
- {isUploading ? t('forms.actions.uploading') : t('common.actions.save')}
654
- </Button>
655
- {primaryStatus && onStatusChange && (
656
- <div className={cx('byline-form-actions-status-wrap', styles['actions-status-wrap'])}>
657
- <ComboButton
658
- buttonClassName={cx(
659
- 'byline-form-actions-combo-button',
660
- styles['actions-combo-button']
661
- )}
662
- triggerClassName={cx(
663
- 'byline-form-actions-combo-trigger',
664
- styles['actions-combo-trigger']
665
- )}
666
- options={secondaryStatuses.map((s) => ({
667
- label: isTerminal
668
- ? t('forms.actions.revertTo', { label: s.label ?? s.name })
669
- : (s.verb ?? s.label ?? s.name),
670
- value: s.name,
671
- }))}
672
- sideOffset={5}
719
+ {headerSlot}
720
+ </div>
721
+ <div className={cx('byline-form-status-bar', styles['status-bar'])}>
722
+ <div className={cx('byline-form-status-details', styles['status-details'])}>
723
+ <FormStatusDisplay
724
+ disabled={mutationsBlocked || discarding}
725
+ initialData={initialData}
726
+ workflowStatuses={workflowStatuses}
727
+ publishedVersion={publishedVersion}
728
+ onUnpublish={onUnpublish}
729
+ afterStatusCells={
730
+ <ScheduledPublicationCell
731
+ state={scheduling.state}
732
+ timeZone={scheduling.timeZone}
733
+ />
734
+ }
735
+ />
736
+ </div>
737
+ <div className={cx('byline-form-actions', styles.actions)}>
738
+ <Button
739
+ className={cx('byline-form-actions-button', styles['actions-button'])}
673
740
  size="sm"
741
+ intent="noeffect"
674
742
  type="button"
675
- intent={isTerminal ? 'info' : 'success'}
676
- disabled={statusBusy}
677
- onOptionSelect={async (value: string) => {
678
- if (hasChanges) {
679
- setShowUnsavedModal(true)
680
- return
681
- }
682
- setStatusBusy(true)
683
- try {
684
- await onStatusChange(value)
685
- } finally {
686
- setStatusBusy(false)
687
- }
688
- }}
689
- onButtonClick={
690
- isTerminal
691
- ? undefined
692
- : async () => {
693
- if (hasChanges) {
694
- setShowUnsavedModal(true)
695
- return
696
- }
697
- setStatusBusy(true)
698
- try {
699
- await onStatusChange(primaryStatus.name)
700
- } finally {
701
- setStatusBusy(false)
702
- }
703
- }
743
+ onClick={handleCancel}
744
+ >
745
+ {hasChanges === false ? t('common.actions.close') : t('common.actions.cancel')}
746
+ </Button>
747
+ <Button
748
+ className={cx('byline-form-actions-button', styles['actions-button'])}
749
+ size="sm"
750
+ type="submit"
751
+ disabled={
752
+ mutationsBlocked ||
753
+ discarding ||
754
+ hasChanges === false ||
755
+ isUploading ||
756
+ isSubmitting
704
757
  }
758
+ aria-label={isSubmitting ? t('common.actions.save') : undefined}
705
759
  >
706
- {statusBusy
707
- ? '...'
708
- : isTerminal
709
- ? (primaryStatus.label ?? primaryStatus.name)
710
- : (primaryStatus.verb ?? primaryStatus.label ?? primaryStatus.name)}
711
- </ComboButton>
760
+ {isUploading ? (
761
+ t('forms.actions.uploading')
762
+ ) : (
763
+ <span className={cx('byline-form-save-content', styles['save-content'])}>
764
+ <span
765
+ className={cx(
766
+ 'byline-form-save-label',
767
+ styles['save-label'],
768
+ isSubmitting && styles['save-label-hidden']
769
+ )}
770
+ >
771
+ {t('common.actions.save')}
772
+ </span>
773
+ {isSubmitting ? (
774
+ <span className={cx('byline-form-save-loader', styles['save-loader'])}>
775
+ <LoaderEllipsis size={28} aria-hidden="true" />
776
+ </span>
777
+ ) : null}
778
+ </span>
779
+ )}
780
+ </Button>
781
+ {primaryStatus && onStatusChange && (
782
+ <div
783
+ className={cx('byline-form-actions-status-wrap', styles['actions-status-wrap'])}
784
+ >
785
+ <ComboButton
786
+ buttonClassName={cx(
787
+ 'byline-form-actions-combo-button',
788
+ styles['actions-combo-button']
789
+ )}
790
+ triggerClassName={cx(
791
+ 'byline-form-actions-combo-trigger',
792
+ styles['actions-combo-trigger']
793
+ )}
794
+ options={secondaryStatuses.map((s) => ({
795
+ label: isTerminal
796
+ ? t('forms.actions.revertTo', { label: s.label ?? s.name })
797
+ : (s.verb ?? s.label ?? s.name),
798
+ value: s.name,
799
+ }))}
800
+ sideOffset={5}
801
+ size="sm"
802
+ type="button"
803
+ intent={isTerminal ? 'info' : 'success'}
804
+ disabled={mutationsBlocked || discarding || statusBusy}
805
+ onOptionSelect={async (value: string) => {
806
+ if (mutationBlockedRef.current) return
807
+ if (hasChanges) {
808
+ setShowUnsavedModal(true)
809
+ return
810
+ }
811
+ setStatusBusy(true)
812
+ try {
813
+ await onStatusChange(value)
814
+ } catch (error) {
815
+ onMutationError?.(error)
816
+ } finally {
817
+ setStatusBusy(false)
818
+ }
819
+ }}
820
+ onButtonClick={
821
+ isTerminal
822
+ ? undefined
823
+ : async () => {
824
+ if (mutationBlockedRef.current) return
825
+ if (hasChanges) {
826
+ setShowUnsavedModal(true)
827
+ return
828
+ }
829
+ setStatusBusy(true)
830
+ try {
831
+ await onStatusChange(primaryStatus.name)
832
+ } catch (error) {
833
+ onMutationError?.(error)
834
+ } finally {
835
+ setStatusBusy(false)
836
+ }
837
+ }
838
+ }
839
+ >
840
+ {statusBusy
841
+ ? '...'
842
+ : isTerminal
843
+ ? (primaryStatus.label ?? primaryStatus.name)
844
+ : (primaryStatus.verb ?? primaryStatus.label ?? primaryStatus.name)}
845
+ </ComboButton>
846
+ </div>
847
+ )}
848
+ <DocumentActions
849
+ disabled={mutationsBlocked || discarding}
850
+ publishedVersion={publishedVersion}
851
+ onUnpublish={onUnpublish}
852
+ onDelete={onDelete}
853
+ onDuplicate={onDuplicate}
854
+ sourceTitle={
855
+ useAsTitle != null && initialData != null
856
+ ? ((initialData as Record<string, unknown>)[useAsTitle] as
857
+ | string
858
+ | null
859
+ | undefined)
860
+ : null
861
+ }
862
+ onCopyToLocale={onCopyToLocale}
863
+ sourceLocale={contentLocale}
864
+ contentLocales={contentLocales}
865
+ hasUnsavedChanges={hasChanges}
866
+ onUnsavedChanges={() => setShowUnsavedModal(true)}
867
+ onDeleteLocale={onDeleteLocale}
868
+ defaultLocale={defaultLocale}
869
+ availableLocales={initialData?._availableVersionLocales as string[] | undefined}
870
+ scheduledPublicationState={scheduling.state}
871
+ onSchedulePublication={scheduling.openSchedule}
872
+ onConfirmScheduledPublication={scheduling.confirm}
873
+ onCancelScheduledPublication={scheduling.cancel}
874
+ />
875
+ </div>
876
+ </div>
877
+ {(mutationIssue || scheduledPublicationsNeedReconfirmation) && (
878
+ <div
879
+ ref={warningRef}
880
+ tabIndex={-1}
881
+ role="alert"
882
+ aria-live="assertive"
883
+ className={cx('byline-document-concurrency', styles.concurrency)}
884
+ >
885
+ {mutationIssue && (
886
+ <Alert
887
+ intent="warning"
888
+ icon
889
+ close={false}
890
+ title={t(`documentConcurrency.${mutationIssue}Title`)}
891
+ >
892
+ <p>{t(`documentConcurrency.${mutationIssue}`)}</p>
893
+ {mutationIssue !== 'committed' && (
894
+ <Button
895
+ type="button"
896
+ disabled={discarding}
897
+ onClick={() => {
898
+ setReloadFailed(false)
899
+ setDiscarding(true)
900
+ }}
901
+ >
902
+ {t('documentConcurrency.reloadAction')}
903
+ </Button>
904
+ )}
905
+ {reloadFailed && <p>{t('documentConcurrency.reloadFailed')}</p>}
906
+ </Alert>
907
+ )}
908
+ {scheduledPublicationsNeedReconfirmation && (
909
+ <Alert
910
+ intent="warning"
911
+ icon
912
+ close={false}
913
+ title={t('documentConcurrency.schedulesTitle')}
914
+ >
915
+ <p>{t('documentConcurrency.schedules')}</p>
916
+ {scheduledPublicationsHref && (
917
+ <a href={scheduledPublicationsHref}>
918
+ {t('documentConcurrency.reviewSchedules')}
919
+ </a>
920
+ )}
921
+ </Alert>
922
+ )}
712
923
  </div>
713
924
  )}
714
- <DocumentActions
715
- publishedVersion={publishedVersion}
716
- onUnpublish={onUnpublish}
717
- onDelete={onDelete}
718
- onDuplicate={onDuplicate}
719
- sourceTitle={
720
- useAsTitle != null && initialData != null
721
- ? ((initialData as Record<string, unknown>)[useAsTitle] as
722
- | string
723
- | null
724
- | undefined)
725
- : null
726
- }
727
- onCopyToLocale={onCopyToLocale}
728
- sourceLocale={contentLocale}
729
- contentLocales={contentLocales}
730
- hasUnsavedChanges={hasChanges}
731
- onUnsavedChanges={() => setShowUnsavedModal(true)}
732
- onDeleteLocale={onDeleteLocale}
733
- defaultLocale={defaultLocale}
734
- availableLocales={initialData?._availableVersionLocales as string[] | undefined}
735
- scheduledPublicationState={scheduling.state}
736
- onSchedulePublication={scheduling.openSchedule}
737
- onConfirmScheduledPublication={scheduling.confirm}
738
- onCancelScheduledPublication={scheduling.cancel}
925
+ <ScheduledPublicationNotice
926
+ state={scheduling.state}
927
+ timeZone={scheduling.timeZone}
928
+ busy={mutationsBlocked || discarding || scheduling.busy}
929
+ onConfirm={scheduling.confirm}
930
+ onReschedule={scheduling.openSchedule}
931
+ onCancel={scheduling.cancel}
739
932
  />
740
- </div>
741
- </div>
742
- <ScheduledPublicationNotice
743
- state={scheduling.state}
744
- timeZone={scheduling.timeZone}
745
- busy={scheduling.busy}
746
- onConfirm={scheduling.confirm}
747
- onReschedule={scheduling.openSchedule}
748
- onCancel={scheduling.cancel}
749
- />
750
- {scheduling.modal}
751
- {restoreWarnings && restoreWarnings.length > 0 && (
752
- <Alert
753
- className="m-0 mt-4"
754
- intent="warning"
755
- icon={true}
756
- close={false}
757
- title={t('forms.restoreWarnings.title')}
758
- >
759
- <p>{t('forms.restoreWarnings.body', { count: restoreWarnings.length })}</p>
760
- <ul>
761
- {restoreWarnings.map((w) => (
762
- <li key={w}>{w}</li>
763
- ))}
764
- </ul>
765
- </Alert>
766
- )}
767
- <div className={cx('byline-form-layout', styles.layout)}>
768
- <div className={cx('byline-form-content', styles.content)}>
769
- {layout.main.map((name) => renderItem(name))}
770
- </div>
771
- <div className={cx('byline-form-sidebar', styles.sidebar)}>
772
- {showPath &&
773
- (useAsPath ||
774
- (typeof initialData?.path === 'string' && initialData.path.length > 0)) && (
775
- <PathWidget
776
- useAsPath={useAsPath}
777
- collectionPath={collectionPath ?? ''}
778
- defaultLocale={defaultLocale}
779
- activeLocale={contentLocale}
780
- mode={mode}
781
- slugifier={pathSlugifier}
782
- sourceLocked={pathSourceLocked}
783
- />
784
- )}
785
- {tree && mode === 'edit' && typeof initialData?.id === 'string' && (
786
- <TreePlacementWidget
787
- collectionPath={collectionPath ?? ''}
788
- documentId={initialData.id as string}
789
- useAsTitle={useAsTitle}
790
- />
933
+ {scheduling.modal}
934
+ {restoreWarnings && restoreWarnings.length > 0 && (
935
+ <Alert
936
+ className="m-0 mt-4"
937
+ intent="warning"
938
+ icon={true}
939
+ close={false}
940
+ title={t('forms.restoreWarnings.title')}
941
+ >
942
+ <p>{t('forms.restoreWarnings.body', { count: restoreWarnings.length })}</p>
943
+ <ul>
944
+ {restoreWarnings.map((w) => (
945
+ <li key={w}>{w}</li>
946
+ ))}
947
+ </ul>
948
+ </Alert>
791
949
  )}
792
- {advertiseLocales && (
793
- <AvailableLocalesWidget
794
- contentLocales={contentLocales ?? []}
795
- availableVersionLocales={
796
- (initialData?._availableVersionLocales as string[] | undefined) ?? []
797
- }
950
+ <div className={cx('byline-form-layout', styles.layout)}>
951
+ <div className={cx('byline-form-content', styles.content)}>
952
+ {layout.main.map((name) => renderItem(name))}
953
+ </div>
954
+ <div className={cx('byline-form-sidebar', styles.sidebar)}>
955
+ {showPath &&
956
+ (useAsPath ||
957
+ (typeof initialData?.path === 'string' && initialData.path.length > 0)) && (
958
+ <PathWidget
959
+ disabled={mutationsBlocked || discarding}
960
+ useAsPath={useAsPath}
961
+ collectionPath={collectionPath ?? ''}
962
+ defaultLocale={defaultLocale}
963
+ activeLocale={contentLocale}
964
+ mode={mode}
965
+ slugifier={pathSlugifier}
966
+ sourceLocked={pathSourceLocked}
967
+ />
968
+ )}
969
+ {tree && mode === 'edit' && typeof initialData?.id === 'string' && (
970
+ <TreePlacementWidget
971
+ disabled={mutationsBlocked || discarding}
972
+ onMutationError={onMutationError}
973
+ onCommitted={onTreeMutationCommitted}
974
+ expectedRevision={observedRevision ?? initialData.revision}
975
+ collectionPath={collectionPath ?? ''}
976
+ documentId={initialData.id as string}
977
+ useAsTitle={useAsTitle}
978
+ />
979
+ )}
980
+ {advertiseLocales && (
981
+ <AvailableLocalesWidget
982
+ disabled={mutationsBlocked || discarding}
983
+ contentLocales={contentLocales ?? []}
984
+ availableVersionLocales={
985
+ (initialData?._availableVersionLocales as string[] | undefined) ?? []
986
+ }
987
+ />
988
+ )}
989
+ {(layout.sidebar ?? []).map((name) => renderItem(name))}
990
+ </div>
991
+ </div>
992
+ {showUnsavedModal && <UnsavedChangesModal onClose={() => setShowUnsavedModal(false)} />}
993
+ {!mutationsBlocked && !discarding && pendingSystemFieldsSubmit != null && (
994
+ <SystemFieldsConfirmModal
995
+ contentDirty={pendingSystemFieldsSubmit.contentDirty}
996
+ pathDirty={pendingSystemFieldsSubmit.pathDirty}
997
+ availableLocalesDirty={pendingSystemFieldsSubmit.availableLocalesDirty}
998
+ onCancel={() => setPendingSystemFieldsSubmit(null)}
999
+ onConfirm={() => {
1000
+ const payload = pendingSystemFieldsSubmit
1001
+ setPendingSystemFieldsSubmit(null)
1002
+ void submitPayload(payload)
1003
+ }}
798
1004
  />
799
1005
  )}
800
- {(layout.sidebar ?? []).map((name) => renderItem(name))}
801
- </div>
1006
+ {guard.isBlocked && (
1007
+ <NavigationGuardModal onStay={guard.stay} onProceed={guard.proceed} />
1008
+ )}
1009
+ </form>
802
1010
  </div>
803
- {showUnsavedModal && <UnsavedChangesModal onClose={() => setShowUnsavedModal(false)} />}
804
- {pendingSystemFieldsSubmit != null && (
805
- <SystemFieldsConfirmModal
806
- contentDirty={pendingSystemFieldsSubmit.contentDirty}
807
- pathDirty={pendingSystemFieldsSubmit.pathDirty}
808
- availableLocalesDirty={pendingSystemFieldsSubmit.availableLocalesDirty}
809
- onCancel={() => setPendingSystemFieldsSubmit(null)}
810
- onConfirm={() => {
811
- const payload = pendingSystemFieldsSubmit
812
- setPendingSystemFieldsSubmit(null)
813
- void submitPayload(payload)
814
- }}
815
- />
816
- )}
817
- {guard.isBlocked && <NavigationGuardModal onStay={guard.stay} onProceed={guard.proceed} />}
818
- </form>
1011
+ </>
819
1012
  )
820
1013
  }
821
1014