Files
summercms/admin/src/components/form/formState.ts
Jakub Zych a714bb4629 fix(admin): name records by translatable (mltext) fields
The record heading, breadcrumb crumb and document title took the first
plain text field only, so records named by an mltext field fell back to
the generic update message. A shared recordDisplayName helper now takes
the first text or ML field and reads its default locale, then the first
non-empty one.
2026-10-07 19:05:47 +02:00

282 lines
9.8 KiB
TypeScript

// 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 { isMLFieldType, localeRecord } from './mlLocale'
import { isRegistered } from './registry'
export { ML_LOCALE_CHANGE, broadcastMLLocale, isMLFieldType, 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 (isMLFieldType(field.type)) {
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
/**
* 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 = isMLFieldType(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 (isMLFieldType(field.type)) {
const seed: Record<string, string> = {}
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<string, string> {
const seeded: Record<string, string> = {}
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) }
}
/** The record's display name: the value of the first text-like field (plain
* `text` or an ML type) in schema order. An ML value reads the default (first
* enabled) locale, then the first non-empty locale; '' when nothing is set. */
export function recordDisplayName(
fields: readonly FormField[] | undefined,
values: AdminRecord,
enabledLocales: readonly string[],
): string {
const first = fields?.find((field) => field.type === 'text' || isMLFieldType(field.type))
const value = first ? values[first.name] : undefined
if (typeof value === 'string' || typeof value === 'number') {
return String(value).trim()
}
if (!first || !isMLFieldType(first.type)) {
return ''
}
const texts = localeRecord(value)
const preferred = enabledLocales[0] !== undefined ? (texts[enabledLocales[0]] ?? '').trim() : ''
if (preferred !== '') {
return preferred
}
for (const text of Object.values(texts)) {
if (text.trim() !== '') {
return text.trim()
}
}
return ''
}
/** D-10 422 details (field -> messages) as a string-list map. */
export function fieldErrors(details: ErrorBody['details'] | undefined): Record<string, string[]> {
const out: Record<string, string[]> = {}
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
}