'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 (
}
/>
{hasChanges === false ? t('common.actions.close') : t('common.actions.cancel')}
{isUploading ? (
t('forms.actions.uploading')
) : (
{t('common.actions.save')}
{isSubmitting ? (
) : null}
)}
{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' && (
{t('documentConcurrency.reloadAction')}
)}
{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 && (
)}
>
)