- 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
257 lines
9.0 KiB
TypeScript
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
|
|
}
|