'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 */ import { useState } from 'react' import { useTranslation } from '@byline/i18n/react' import { Button, Checkbox, CloseIcon, DeleteIcon, Dropdown as DropdownComponent, EllipsisIcon, IconButton, Modal, Select, } from '@byline/ui/react' import cx from 'clsx' import styles from './document-actions.module.css' import type { PublishedVersionInfo } from './form-renderer' import type { ScheduledPublicationState } from './scheduled-publication-state.js' const DUPLICATE_TITLE_SUFFIX = ' (copy)' /** * Shape of a content-locale option as consumed by the Copy-to-Locale * modal. Matches the host adapter's `ContentLocaleOption`; declared * locally so this package does not take a dependency on host code. */ export interface DocumentActionsLocaleOption { code: string label: string } export function DocumentActions({ disabled = false, publishedVersion, onUnpublish, onDelete, onDuplicate, sourceTitle, onCopyToLocale, sourceLocale, contentLocales, hasUnsavedChanges, onUnsavedChanges, onDeleteLocale, defaultLocale, availableLocales, scheduledPublicationState, onSchedulePublication, onConfirmScheduledPublication, onCancelScheduledPublication, }: { disabled?: boolean publishedVersion?: PublishedVersionInfo | null onUnpublish?: () => Promise onDelete?: () => Promise /** * Called when the editor confirms the duplicate modal. The parent runs * the server fn, surfaces a toast, and navigates to the new document. */ onDuplicate?: () => Promise /** * The current (saved) value of the source document's `useAsTitle` * field, used to render the suffix preview inside the duplicate modal. * Sourced from the form's `initialData`, not live form state, so the * preview reflects what will actually be duplicated. */ sourceTitle?: string | null /** * Called when the editor confirms the Copy-to-Locale modal. The * parent runs the server fn, surfaces a toast, and navigates to the * target locale view. Menu item is hidden when omitted, or when fewer * than two content locales are configured. */ onCopyToLocale?: (args: { targetLocale: string; overwrite: boolean }) => Promise /** * The locale the form is currently displaying. Used as the read-only * "From" label in the Copy-to-Locale modal and excluded from the * target Select. */ sourceLocale?: string /** * All configured content locales (code + display label). The * Copy-to-Locale Select lists every locale except `sourceLocale`. */ contentLocales?: ReadonlyArray /** * Whether the form currently has unsaved changes. Duplicate and * Copy-to-Locale operate on the *saved* version, so when this is true * the action is blocked and `onUnsavedChanges` fires instead of opening * the action's modal. Delete is intentionally not gated. */ hasUnsavedChanges?: boolean /** * Called when a save-gated action (duplicate / copy-to-locale / * delete-locale) is triggered while `hasUnsavedChanges` is true. The parent * surfaces a "save first" prompt. */ onUnsavedChanges?: () => void /** * Called when the editor confirms the Delete-Locale modal. The parent runs * the server fn, surfaces a toast, and navigates to a surviving locale. * Menu item is hidden when omitted, or when the document has no non-default * locale to delete. */ onDeleteLocale?: (args: { targetLocale: string }) => Promise /** * The default content locale (the document's anchor). Excluded from the * Delete-Locale list — it can never be removed. */ defaultLocale?: string /** * The locales the document currently has content in (the derived * `_availableVersionLocales` ledger). The Delete-Locale list is this set * minus the default locale and the `'all'` sentinel. */ availableLocales?: string[] /** * Derived presentation state for the document's pending publication * schedule. Decides which of the scheduling menu items appear; omit it (or * pass a `none` state with no capabilities) and the group is hidden * entirely — which is what an ineligible document, a single-status * workflow, or an actor missing either ability all reduce to. */ scheduledPublicationState?: ScheduledPublicationState /** Opens the schedule / reschedule modal. Save-gated by the caller. */ onSchedulePublication?: () => void /** Re-confirms a suspended schedule against the current version. Save-gated by the caller. */ onConfirmScheduledPublication?: () => void | Promise /** Withdraws the pending schedule. Deliberately not save-gated. */ onCancelScheduledPublication?: () => void | Promise }) { const { t } = useTranslation('byline-admin') const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showDuplicateConfirm, setShowDuplicateConfirm] = useState(false) const [duplicateBusy, setDuplicateBusy] = useState(false) // Copy-to-Locale modal state. The menu item is hidden entirely unless // the host has supplied a handler AND there is at least one *other* // locale to copy into. const availableTargetLocales = (contentLocales ?? []).filter((loc) => loc.code !== sourceLocale) const copyToLocaleAvailable = onCopyToLocale != null && availableTargetLocales.length > 0 const [showCopyToLocaleConfirm, setShowCopyToLocaleConfirm] = useState(false) const [copyToLocaleBusy, setCopyToLocaleBusy] = useState(false) const [copyTargetLocale, setCopyTargetLocale] = useState( availableTargetLocales[0]?.code ?? '' ) const [copyOverwrite, setCopyOverwrite] = useState(false) // Delete-Locale modal state. The menu item is hidden unless the host // supplied a handler AND the document has at least one non-default locale // with content. The default locale is the document's anchor and is never // listed (it cannot be removed). const deletableLocales = (availableLocales ?? []) .filter((code) => code !== defaultLocale && code !== 'all') .map((code) => ({ code, label: contentLocales?.find((loc) => loc.code === code)?.label ?? code, })) const deleteLocaleAvailable = onDeleteLocale != null && deletableLocales.length > 0 const [showDeleteLocaleConfirm, setShowDeleteLocaleConfirm] = useState(false) const [deleteLocaleBusy, setDeleteLocaleBusy] = useState(false) const [deleteTargetLocale, setDeleteTargetLocale] = useState('') // Scheduled-publication menu items. The derived state already accounts for // eligibility and for the abilities the actor holds, so the only thing left // here is to map the permitted operations onto entries. The group renders // above the locale and duplicate items because scheduling acts on the // document's lifecycle rather than on its copies. const scheduling = scheduledPublicationState const schedulingActions: Array<{ key: string; label: string; onSelect: () => void }> = [] if (scheduling != null) { if (scheduling.actions.schedule && onSchedulePublication != null) { schedulingActions.push({ key: 'schedule', // Short form: the menu supplies the "this document" context that the // modal's title and submit button have to spell out for themselves. label: t('scheduledPublication.actions.scheduleMenuItem'), onSelect: onSchedulePublication, }) } if (scheduling.actions.confirm && onConfirmScheduledPublication != null) { schedulingActions.push({ key: 'confirm', label: t('scheduledPublication.actions.confirm'), onSelect: () => void onConfirmScheduledPublication(), }) } if (scheduling.actions.reschedule && onSchedulePublication != null) { schedulingActions.push({ key: 'reschedule', label: t('scheduledPublication.actions.reschedule'), onSelect: onSchedulePublication, }) } if (scheduling.actions.cancel && onCancelScheduledPublication != null) { schedulingActions.push({ key: 'cancel', label: t('scheduledPublication.actions.cancel'), onSelect: () => void onCancelScheduledPublication(), }) } } // Whether the ellipsis menu has anything to show. Every entry below is // conditional, so with no available action the trigger would open an empty // menu. const hasAnyAction = schedulingActions.length > 0 || copyToLocaleAvailable || deleteLocaleAvailable || onDuplicate != null || onDelete != null const handleOnDelete = () => { if (disabled) return setShowDeleteConfirm(false) if (onDelete) { void onDelete().catch(() => {}) } } const handleOnDuplicate = async () => { if (disabled) return if (!onDuplicate) return setDuplicateBusy(true) try { await onDuplicate() setShowDuplicateConfirm(false) } catch { // The host reports the failure and retains the editor observation. } finally { setDuplicateBusy(false) } } const handleOpenDuplicate = () => { if (disabled) return // Duplicate copies the saved version — block when the form is dirty so // unsaved edits are not silently dropped from the copy. if (hasUnsavedChanges) { onUnsavedChanges?.() return } setShowDuplicateConfirm(true) } const handleOpenCopyToLocale = () => { if (disabled) return // Copy-to-Locale reads the saved version — block when the form is dirty. if (hasUnsavedChanges) { onUnsavedChanges?.() return } // Reset on open: pick the first available target and clear the // overwrite checkbox so a previous-session "overwrite=true" choice // is not silently sticky. setCopyTargetLocale(availableTargetLocales[0]?.code ?? '') setCopyOverwrite(false) setShowCopyToLocaleConfirm(true) } const handleOnCopyToLocale = async () => { if (disabled) return if (!onCopyToLocale || !copyTargetLocale) return setCopyToLocaleBusy(true) try { await onCopyToLocale({ targetLocale: copyTargetLocale, overwrite: copyOverwrite }) setShowCopyToLocaleConfirm(false) } catch { // The host reports the failure and retains the editor observation. } finally { setCopyToLocaleBusy(false) } } const handleOpenDeleteLocale = () => { if (disabled) return // Delete-Locale removes the saved version's locale content — block when // the form is dirty so the editor saves (or discards) first. if (hasUnsavedChanges) { onUnsavedChanges?.() return } // Default to the currently-viewed locale when it is deletable, otherwise // the first available target. const preferred = deletableLocales.find((loc) => loc.code === sourceLocale)?.code setDeleteTargetLocale(preferred ?? deletableLocales[0]?.code ?? '') setShowDeleteLocaleConfirm(true) } const handleOnDeleteLocale = async () => { if (disabled) return if (!onDeleteLocale || !deleteTargetLocale) return setDeleteLocaleBusy(true) try { await onDeleteLocale({ targetLocale: deleteTargetLocale }) setShowDeleteLocaleConfirm(false) } catch { // The host reports the failure and retains the editor observation. } finally { setDeleteLocaleBusy(false) } } // Preview text shown inside the modal. Falls back to the literal suffix // when no source title is supplied (collections without `useAsTitle`). const duplicatePreviewBefore = sourceTitle ?? '' const duplicatePreviewAfter = (sourceTitle ?? '') + DUPLICATE_TITLE_SUFFIX const sourceLocaleLabel = contentLocales?.find((loc) => loc.code === sourceLocale)?.label ?? sourceLocale ?? '' return ( <> {hasAnyAction && ( } > {/*{publishedVersion && ( <>
Unpublish
)}*/} {schedulingActions.length > 0 && ( <> {schedulingActions.map((action) => (
))} )} {copyToLocaleAvailable && (
)} {deleteLocaleAvailable && (
)} {onDuplicate && (
)} {onDelete && ( <> { setShowDeleteConfirm(true) }} >
)}
)} { setShowDeleteConfirm(false) }} >

{t('documentActions.delete.title')}

{ setShowDeleteConfirm(false) }} >

{t('documentActions.delete.warning')}

{ if (!duplicateBusy) setShowDuplicateConfirm(false) }} >

{t('documentActions.duplicate.title')}

{ if (!duplicateBusy) setShowDuplicateConfirm(false) }} >

{t('documentActions.duplicate.intro')}

  • {t('documentActions.duplicate.bulletTitle')}{' '} {DUPLICATE_TITLE_SUFFIX.trim()}.
  • {t('documentActions.duplicate.bulletPath')}
{sourceTitle != null && sourceTitle.length > 0 && (
{t('documentActions.duplicate.previewLabel')}
{duplicatePreviewBefore} {duplicatePreviewAfter}
)}
{ if (!copyToLocaleBusy) setShowCopyToLocaleConfirm(false) }} >

{t('documentActions.copyToLocale.title')}

{ if (!copyToLocaleBusy) setShowCopyToLocaleConfirm(false) }} >

{t('documentActions.copyToLocale.intro')}

{t('documentActions.copyToLocale.fromLabel')}  {sourceLocaleLabel}
{t('documentActions.copyToLocale.toLabel')} size="sm" ariaLabel={t('documentActions.copyToLocale.targetAriaLabel')} value={copyTargetLocale} items={availableTargetLocales.map((loc) => ({ value: loc.code, label: loc.label, }))} onValueChange={(value) => { if (value != null) setCopyTargetLocale(value) }} disabled={disabled || copyToLocaleBusy} />
{ setCopyOverwrite(value === true) }} />
{ if (!deleteLocaleBusy) setShowDeleteLocaleConfirm(false) }} >

{t('documentActions.deleteLocale.title')}

{ if (!deleteLocaleBusy) setShowDeleteLocaleConfirm(false) }} >

{t('documentActions.deleteLocale.intro')}

{t('documentActions.deleteLocale.localeLabel')} size="sm" ariaLabel={t('documentActions.deleteLocale.targetAriaLabel')} value={deleteTargetLocale} items={deletableLocales.map((loc) => ({ value: loc.code, label: loc.label, }))} onValueChange={(value) => { if (value != null) setDeleteTargetLocale(value) }} disabled={disabled || deleteLocaleBusy} />

{t('documentActions.deleteLocale.warning')}

) }