'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 { useCallback, useMemo } from 'react' import type { SlugifierFn } from '@byline/core' import { slugify } from '@byline/core' import { useTranslation } from '@byline/i18n/react' import { Input, Label } from '@byline/ui/react' import cx from 'clsx' import { useFieldValue, useFormContext, useSystemPath } from './form-context' import styles from './path-widget.module.css' /** * Coerce an arbitrary source-field value (string, Date, or other) into * a string suitable for slugification. Mirrors the lifecycle's coercion * so the live preview matches what the server will store. */ function coerceToString(value: unknown): string { if (value == null) return '' if (value instanceof Date) return value.toISOString() return String(value) } export interface PathWidgetProps { disabled?: boolean /** The collection's `useAsPath` source field name, when configured. */ useAsPath: string | undefined /** Collection path, forwarded to the slugifier as context. */ collectionPath: string /** Default content locale, forwarded to the slugifier as context. */ defaultLocale: string /** * The locale currently being edited in the form. When this differs * from `defaultLocale` (i.e. the editor is editing a translation), * the widget renders read-only — phase 1 paths are default-locale * territory, and the lifecycle drops translation-locale path changes * with a warn. Locking the input prevents the warn path being hit * through the admin form and gives editors a clear cue. */ activeLocale: string /** `'create'` shows the live derived preview as placeholder text. */ mode: 'create' | 'edit' /** * Installation slugifier used to derive the live preview. Must match the * server-side `ServerConfig.slugifier` so the preview agrees with what is * persisted. Defaults to the built-in `slugify` when omitted — callers * that keep the default slugifier need not pass this. */ slugifier?: SlugifierFn /** * When `true`, the `useAsPath` source field's value is not editable through * this form (e.g. it is an allocator-assigned `counter`, or an otherwise * read-only field). Its value is either server-assigned — and so not * reproducible client-side — or simply cannot change, which means the * source-derived live preview and the "Regenerate" affordance are * meaningless. When set, the widget suppresses both and just shows the * persisted path. Defaults to `false`. */ sourceLocked?: boolean /** * When `true`, the collection declares its paths managed * (`CollectionAdminConfig.lockPath`): the input renders read-only in both * modes and the preview placeholder and "Regenerate" action are * suppressed. Independent of `sourceLocked`, which answers whether the * form can derive a preview at all. Defaults to `false`. */ lockPath?: boolean } /** * System-managed `path` widget. * * Edits the path stored in `byline_document_paths` for the current * (document, locale) row. Displays the current persisted/overridden * value as an editable input. * In create mode, when the user hasn't supplied an override, the input * shows the live-derived preview (slugified `useAsPath` source field) as * a placeholder so the user sees what will be saved. The "Regenerate" * action explicitly writes the current live preview into the override * slot so the user can re-anchor a path against the source field after * editing the title. * * Stable override handles: `.byline-form-path`, `.byline-form-path-header`, * `.byline-form-path-regenerate`. */ export const PathWidget = ({ useAsPath, collectionPath, defaultLocale, activeLocale, mode, slugifier, sourceLocked = false, lockPath = false, disabled = false, }: PathWidgetProps) => { const { setSystemPath } = useFormContext() const { t } = useTranslation('byline-admin') const systemPath = useSystemPath() const sourceValue = useFieldValue(useAsPath ?? '') // The installation slugifier, or the built-in default. Server-side path // derivation uses `ServerConfig.slugifier`; this must be the same function // (registered via `AdminConfig.slugifier`) or the preview will disagree // with what the server persists. const runSlugify = slugifier ?? slugify // Phase 1: paths are written/edited only under the default content locale, // so editing a translation locks the widget down. const translationLocked = activeLocale !== defaultLocale // A collection may also declare its paths managed. Both conditions produce // the same read-only surface, so the placeholder, the Regenerate // affordance, the `readOnly` attribute and the change guard all key off the // combined flag. const isReadOnly = lockPath || translationLocked // Live preview — what the server would derive from the current source // field value if no override were set. Used as placeholder in create // mode and as the target of the "Regenerate" action. const livePreview = useMemo(() => { // A locked source (server-assigned counter / read-only field) can't be // previewed or regenerated from the form — suppress the derivation. if (!useAsPath || sourceLocked) return '' const asString = coerceToString(sourceValue) if (asString.length === 0) return '' return runSlugify(asString, { locale: defaultLocale, collectionPath }) }, [useAsPath, sourceLocked, sourceValue, defaultLocale, collectionPath, runSlugify]) const inputValue = systemPath ?? '' const handleChange = useCallback( (next: string) => { // `readOnly` stops real typing; a programmatic change event still // reaches this handler. Refuse the write so the lock holds in the form. if (disabled || isReadOnly) return // Empty string clears the override — server falls back to derive // (create) or sticky (update). setSystemPath(next.length === 0 ? null : next) }, [disabled, isReadOnly, setSystemPath] ) const handleRegenerate = useCallback(() => { if (!disabled && livePreview.length > 0) { setSystemPath(livePreview) } }, [disabled, livePreview, setSystemPath]) // Validate live: if the typed value differs from its slugified form, // surface an inline hint without blocking input (mirrors the previous // field-hook advisory behaviour). const formatted = useMemo(() => { if (inputValue.length === 0) return '' return runSlugify(inputValue, { locale: defaultLocale, collectionPath }) }, [inputValue, defaultLocale, collectionPath, runSlugify]) const validationHint = inputValue.length > 0 && formatted !== inputValue ? t('pathWidget.suggestedHint', { formatted }) : undefined // Hint precedence: locked, then translation, then live validation. const lockedHint = lockPath ? mode === 'create' ? t('pathWidget.lockedCreateHint') : t('pathWidget.lockedHint') : undefined const translationHint = translationLocked ? t('pathWidget.readOnlyHint', { locale: defaultLocale }) : undefined const hint = lockedHint ?? translationHint ?? validationHint const placeholder = !isReadOnly && mode === 'create' && livePreview.length > 0 ? t('pathWidget.willBeSavedAs', { preview: livePreview }) : undefined // Screen-reader description. The input's base purpose ("System-managed // URL path") plus whichever of the visible hints (placeholder preview // in create mode, "Suggested" validation hint, or read-only explainer) // currently applies. The visible helpText/placeholder cover sighted // users; this element makes the same information addressable via // aria-describedby for AT. const srDescription = [t('pathWidget.srDescription'), placeholder, hint].filter(Boolean).join(' ') const showRegenerate = !isReadOnly && useAsPath && livePreview.length > 0 && livePreview !== systemPath return (
handleChange(e.target.value)} helpText={hint} readOnly={isReadOnly} aria-describedby="system-path-description" /> {srDescription}
) }