Files
summercms/admin/src/components/form/formState.ts
Jakub Zych b5d850662c feat(admin): follow presets on mltext fields per locale
- formState: changedLocales and presetUpdates hold the preset-follow rules
  for text and mltext pairs
- FormView applies presetUpdates on create, tracks per-locale hand edits of
  ML fields and follows the active ML locale
- rebuilt modules/boardwalk/dist
2026-10-06 21:18:09 +02:00

257 lines
9.0 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 { 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<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) }
}
/** 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
}