// Helpers shared by the record form and the settings form: context // filtering, the save payload, 422 detail mapping and focus. import type { AdminRecord, ErrorBody, FormField } from '../../api/types' import { permissionValues } from './control' import { localeRecord } from './mlLocale' import { isRegistered } from './registry' export { ML_LOCALE_CHANGE, broadcastMLLocale, localeRecord } from './mlLocale' /** The screen a field is filtered for: the two form modes and the read-only preview (D-11). */ export type FormMode = 'create' | 'update' | 'preview' /** One form tab: the YAML tab label, or the default tab for untabbed fields. */ export interface TabItem { key: string label: string errors: number } /** Key of the tab that holds fields without a `tab`. */ export const DEFAULT_TAB = 'default' /** Tab key of a field: its tab label, or the default tab. */ export function tabOf(field: FormField): string { return field.tab ? `tab:${field.tab}` : DEFAULT_TAB } /** DOM ids of the tab button and its panel, by tab position. */ export function tabDomId(prefix: string, index: number): string { return `${prefix}-tab-${index}` } export function panelDomId(prefix: string, index: number): string { return `${prefix}-panel-${index}` } /** Winter `context:` semantics: no context shows the field everywhere. */ export function contextAllows(field: FormField, mode: FormMode): boolean { const context = field.context if (context === undefined || context === null) { return true } const values = Array.isArray(context) ? context : [context] return values.length === 0 || values.includes(mode) } /** * The save body: values keyed by field name for every editable field that * has a value. Read-only fields and types without a renderer are never sent. * An empty password on update means "unchanged" and is left out; on create it * is sent as entered and the server's rules decide (UI-SPEC S7). */ export function editablePayload(fields: FormField[], values: AdminRecord, mode: FormMode = 'create'): AdminRecord { const out: AdminRecord = {} for (const field of fields) { if (field.readOnly || !isRegistered(field.type)) { continue } const value = values[field.name] if (field.type === 'password' && mode === 'update' && (value === '' || value === null)) { continue } if (field.type === 'permissioneditor') { // Sent whenever it is shown, reduced to offered codes and values. out[field.name] = permissionValues(field, value) continue } if (field.type === 'mltext' || field.type === 'mlmarkdown') { out[field.name] = localeRecord(value) continue } if (value !== undefined) { out[field.name] = value } } return out } /** * The value a preset field takes from its source (fields.yaml `preset`). Type * slug: lower-case ASCII letters and digits, every run of other characters * one hyphen, no hyphen at either end. Type exact: the same text. */ export function presetValue(type: string, source: unknown): string { const text = typeof source === 'string' || typeof source === 'number' ? String(source) : '' if (type !== 'slug') { return text } return text .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') } /** The locales whose text differs between two locale maps: every locale on * either side, a missing key counting as ''. A value that is not a map counts * as an empty map. */ export function changedLocales(previous: unknown, next: unknown): string[] { const before = localeRecord(previous) const after = localeRecord(next) const out: string[] = [] for (const locale of new Set([...Object.keys(before), ...Object.keys(after)])) { if ((before[locale] ?? '') !== (after[locale] ?? '')) { out.push(locale) } } return out } /** Reports whether the administrator edited a preset target by hand: the * whole field when no locale is given, one locale of an ML target otherwise. */ export type PresetEdited = (target: string, locale?: string) => boolean function isMLType(type: string | undefined): boolean { return type === 'mltext' || type === 'mlmarkdown' } /** * The preset targets of `source` (fields.yaml `preset`) to rewrite after the * source changed from `previous` to `next`: target name -> new value. Targets * are text and mltext fields; a preset on any other type is ignored. * * - text -> text: the target takes presetValue of the source, until edited. * - ML -> text: the target takes presetValue of the source's active-locale text. * - text -> ML: only the target's active locale is written; its other locales stay. * - ML -> ML: per locale. Each locale whose source text changed rewrites the * same locale of the target, unless the administrator edited that locale by * hand; the target's other locales stay. * * A preset never writes a locale the administrator neither typed in (source) * nor sees (target), so untouched locales keep the server's fallback. */ export function presetUpdates( fields: FormField[], source: string, previous: unknown, next: unknown, values: AdminRecord, activeLocale: string, isEdited: PresetEdited, ): AdminRecord { const out: AdminRecord = {} const sourceML = isMLType(fields.find((field) => field.name === source)?.type) for (const field of fields) { const preset = field.preset if (!preset || preset.field !== source || (field.type !== 'text' && field.type !== 'mltext')) { continue } if (field.type === 'text') { if (isEdited(field.name)) { continue } out[field.name] = presetValue(preset.type, sourceML ? (localeRecord(next)[activeLocale] ?? '') : next) continue } if (!sourceML) { if (isEdited(field.name, activeLocale)) { continue } out[field.name] = { ...localeRecord(values[field.name]), [activeLocale]: presetValue(preset.type, next) } continue } const texts = localeRecord(next) const target = { ...localeRecord(values[field.name]) } let written = false for (const locale of changedLocales(previous, next)) { if (isEdited(field.name, locale)) { continue } target[locale] = presetValue(preset.type, texts[locale] ?? '') written = true } if (written) { out[field.name] = target } } return out } /** Initial values of a new record: schema defaults, toggles off, no ids. * ML fields seed an empty string for every enabled locale. */ export function initialValues(fields: FormField[], enabledLocales: readonly string[] = []): AdminRecord { const out: AdminRecord = {} for (const field of fields) { if (field.default !== undefined) { out[field.name] = field.default } else if (field.type === 'switch' || field.type === 'checkbox') { out[field.name] = false } else if (field.type === 'relation' && field.multiple) { out[field.name] = [] } else if (field.type === 'mltext' || field.type === 'mlmarkdown') { const seed: Record = {} for (const code of enabledLocales) { seed[code] = '' } out[field.name] = seed } } return out } /** Content locales from form schema meta; empty when the writer is unpublished. */ export function schemaEnabledLocales(meta: { enabledLocales?: readonly string[] } | null | undefined): readonly string[] { return meta?.enabledLocales ?? [] } /** Merge a GET/save ML value onto a seed map of every enabled locale. A host * string becomes the first enabled locale; it never replaces sibling keys. */ export function mergeMLValue(incoming: unknown, enabledLocales: readonly string[]): Record { const seeded: Record = {} for (const code of enabledLocales) { seeded[code] = '' } if (typeof incoming === 'string') { const fallback = enabledLocales[0] if (fallback) { seeded[fallback] = incoming } return seeded } return { ...seeded, ...localeRecord(incoming) } } /** D-10 422 details (field -> messages) as a string-list map. */ export function fieldErrors(details: ErrorBody['details'] | undefined): Record { const out: Record = {} for (const [name, value] of Object.entries(details ?? {})) { const messages = Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string' && item !== '') : typeof value === 'string' && value !== '' ? [value] : [] if (messages.length > 0) { out[name] = messages } } return out } /** Stable serialization for dirty checks. */ export function snapshot(record: AdminRecord): string { return JSON.stringify(Object.keys(record).sort().map((key) => [key, record[key]])) } /** Focuses a field's control (FormField ids are `${prefix}-${name}`). */ export function focusField(prefix: string, name: string): boolean { const element = document.getElementById(`${prefix}-${name}`) if (!element) { return false } if (!element.matches('input, select, textarea, button, [tabindex]')) { element.setAttribute('tabindex', '-1') } element.focus() return true }