'use client' /** * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * * Copyright (c) Infonomic Company Limited */ /** * Page-level chrome for the full-page document editor: the parts that frame a * form rather than render it. The embedded creation view renders none of this. * * Each component takes only the props it needs — deliberately not a * `FormRendererProps` pass-through, so what a region actually depends on stays * visible at its call site. */ import type { ReactNode, RefObject } from 'react' import type { SlugifierFn, StructuralMutationReceipt, WorkflowStatus } from '@byline/core' import { useTranslation } from '@byline/i18n/react' import { Alert, Button, ComboButton, LoaderEllipsis } from '@byline/ui/react' import cx from 'clsx' import { AvailableLocalesWidget } from './available-locales-widget' import { DocumentActions, type DocumentActionsLocaleOption } from './document-actions' import styles from './form-renderer.module.css' import { FormStatusDisplay } from './form-status-display' import { PathWidget } from './path-widget' import { ScheduledPublicationCell, ScheduledPublicationNotice, type UseScheduledPublicationReturn, } from './scheduled-publication-control' import { TreePlacementWidget } from './tree-placement-widget' import type { PublishedVersionInfo } from './form-renderer' import type { StatusTransitions } from './status-transitions' export interface FormHeadingRowProps { heading: ReactNode /** Host-supplied controls rendered beside the heading (locale switcher, …). */ headerSlot?: ReactNode } export const FormHeadingRow = ({ heading, headerSlot }: FormHeadingRowProps): ReactNode => (

{heading}

{/* Source-locale anchor indicator removed pending heading-layout work. To re-enable: render `` here from `initialData.sourceLocale` (mismatch-only is the intended end state). See docs/08-internationalization/index.md. */} {headerSlot}
) export interface FormStatusBarProps { /** Mutations blocked or a discard in flight. */ disabled: boolean // biome-ignore lint/suspicious/noExplicitAny: document shape is collection-specific initialData?: any workflowStatuses?: WorkflowStatus[] publishedVersion?: PublishedVersionInfo | null scheduling: UseScheduledPublicationReturn hasChanges: boolean isUploading: boolean isSubmitting: boolean onCancel: () => void /** Status transitions, already computed for the current workflow position. */ transitions: StatusTransitions statusBusy: boolean onStatusBusyChange: (busy: boolean) => void onStatusChange?: (nextStatus: string) => Promise onMutationError?: (error: unknown) => 'blocked' | 'committed' | null | void /** Read synchronously at click time, not at render time. */ isBlocked: () => boolean /** A guarded action was attempted while the form is dirty. */ onUnsavedChanges: () => void onUnpublish?: () => Promise onDelete?: () => Promise onDuplicate?: () => Promise onCopyToLocale?: (args: { targetLocale: string; overwrite: boolean }) => Promise onDeleteLocale?: (args: { targetLocale: string }) => Promise useAsTitle?: string contentLocale: string contentLocales?: ReadonlyArray defaultLocale: string } export const FormStatusBar = ({ disabled, initialData, workflowStatuses, publishedVersion, scheduling, hasChanges, isUploading, isSubmitting, onCancel, transitions, statusBusy, onStatusBusyChange, onStatusChange, onMutationError, isBlocked, onUnsavedChanges, onUnpublish, onDelete, onDuplicate, onCopyToLocale, onDeleteLocale, useAsTitle, contentLocale, contentLocales, defaultLocale, }: FormStatusBarProps): ReactNode => { const { t } = useTranslation('byline-admin') const { primaryStatus, secondaryStatuses, isTerminal } = transitions const runTransition = async (status: string) => { if (isBlocked() || !onStatusChange) return if (hasChanges) { onUnsavedChanges() return } onStatusBusyChange(true) try { await onStatusChange(status) } catch (error) { onMutationError?.(error) } finally { onStatusBusyChange(false) } } return (
} />
{primaryStatus && onStatusChange && (
({ label: isTerminal ? t('forms.actions.revertTo', { label: s.label ?? s.name }) : (s.verb ?? s.label ?? s.name), value: s.name, }))} sideOffset={5} size="sm" type="button" intent={isTerminal ? 'info' : 'success'} disabled={disabled || statusBusy} onOptionSelect={runTransition} onButtonClick={isTerminal ? undefined : () => runTransition(primaryStatus.name)} > {statusBusy ? '...' : isTerminal ? (primaryStatus.label ?? primaryStatus.name) : (primaryStatus.verb ?? primaryStatus.label ?? primaryStatus.name)}
)} )[useAsTitle] as string | null | undefined) : null } onCopyToLocale={onCopyToLocale} sourceLocale={contentLocale} contentLocales={contentLocales} hasUnsavedChanges={hasChanges} onUnsavedChanges={() => onUnsavedChanges()} onDeleteLocale={onDeleteLocale} defaultLocale={defaultLocale} availableLocales={initialData?._availableVersionLocales as string[] | undefined} scheduledPublicationState={scheduling.state} onSchedulePublication={scheduling.openSchedule} onConfirmScheduledPublication={scheduling.confirm} onCancelScheduledPublication={scheduling.cancel} />
) } export interface FormConcurrencyNoticesProps { /** Mutations blocked or a discard in flight. */ disabled: boolean mutationIssue?: 'stale' | 'reload' | 'lock' | 'unavailable' | 'committed' | null scheduledPublicationsNeedReconfirmation?: boolean scheduledPublicationsHref?: string /** * Focus target when a mutation issue appears. The ref belongs to the form * component, which restores focus after a save; this region only attaches it. */ warningRef: RefObject discarding: boolean reloadFailed: boolean onDiscardRequested: () => void scheduling: UseScheduledPublicationReturn restoreWarnings?: string[] } export const FormConcurrencyNotices = ({ disabled, mutationIssue, scheduledPublicationsNeedReconfirmation, scheduledPublicationsHref, warningRef, discarding, reloadFailed, onDiscardRequested, scheduling, restoreWarnings, }: FormConcurrencyNoticesProps): ReactNode => { const { t } = useTranslation('byline-admin') return ( <> {(mutationIssue || scheduledPublicationsNeedReconfirmation) && (
{mutationIssue && (

{t(`documentConcurrency.${mutationIssue}`)}

{mutationIssue !== 'committed' && ( )} {reloadFailed &&

{t('documentConcurrency.reloadFailed')}

}
)} {scheduledPublicationsNeedReconfirmation && (

{t('documentConcurrency.schedules')}

{scheduledPublicationsHref && ( {t('documentConcurrency.reviewSchedules')} )}
)}
)} {scheduling.modal} {restoreWarnings && restoreWarnings.length > 0 && (

{t('forms.restoreWarnings.body', { count: restoreWarnings.length })}

    {restoreWarnings.map((w) => (
  • {w}
  • ))}
)} ) } export interface FormSidebarWidgetsProps { /** Mutations blocked or a discard in flight. */ disabled: boolean mode: 'create' | 'edit' // biome-ignore lint/suspicious/noExplicitAny: document shape is collection-specific initialData?: any collectionPath?: string defaultLocale: string contentLocale: string contentLocales?: ReadonlyArray showPath: boolean useAsPath?: string lockPath?: boolean pathSlugifier?: SlugifierFn pathSourceLocked?: boolean tree?: boolean useAsTitle?: string advertiseLocales?: boolean observedRevision?: number onMutationError?: (error: unknown) => 'blocked' | 'committed' | null | void onTreeMutationCommitted?: (receipt: StructuralMutationReceipt) => void } /** * The page-level widgets in the editor's sidebar: path, tree placement and * advertised locales. They are page concerns rather than schema fields, so they * reach `FormLayout` through its `sidebarSlot` and the embedded creation view * passes none of them. */ export const FormSidebarWidgets = ({ disabled, mode, initialData, collectionPath, defaultLocale, contentLocale, contentLocales, showPath, useAsPath, lockPath, pathSlugifier, pathSourceLocked, tree, useAsTitle, advertiseLocales, observedRevision, onMutationError, onTreeMutationCommitted, }: FormSidebarWidgetsProps): ReactNode => ( <> {/* A locked collection's widget renders even with no `useAsPath` and nothing stored yet: its path is managed, and the editor needs to see that. `showPath: false` still wins — it marks a path that must never be presented at all. */} {showPath && (useAsPath || lockPath === true || (typeof initialData?.path === 'string' && initialData.path.length > 0)) && ( )} {tree && mode === 'edit' && typeof initialData?.id === 'string' && ( )} {advertiseLocales && ( )} )