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
This commit is contained in:
Jakub Zych
2026-10-06 21:18:09 +02:00
parent 7afad732c7
commit b5d850662c
7 changed files with 301 additions and 22 deletions

View File

@@ -92,6 +92,91 @@ export function presetValue(type: string, source: unknown): string {
.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 {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, provide, readonly, ref, watchEffect } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, provide, readonly, ref, watch, watchEffect } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Trash2 } from '@lucide/vue'
import { api } from '../api/client'
@@ -17,6 +17,8 @@ import { FORM_ASSETS, FORM_ENABLED_LOCALES, FORM_LOCALE, FORM_PATCH, FORM_SESSIO
import { RELATION_MANAGER, needsRecord } from '../components/form/registry'
import {
DEFAULT_TAB,
ML_LOCALE_CHANGE,
changedLocales,
contextAllows,
editablePayload,
fieldErrors,
@@ -24,7 +26,7 @@ import {
initialValues,
mergeMLValue,
panelDomId,
presetValue,
presetUpdates,
schemaEnabledLocales,
snapshot,
tabDomId,
@@ -205,24 +207,60 @@ async function load(): Promise<void> {
values.value = initialValues(fields.value, schemaEnabledLocales(schema.value.meta))
}
adopt(recordResult?.data)
mlLocale.value = firstMLLocale()
activeTab.value = tabs.value[0]?.key ?? DEFAULT_TAB
loading.value = false
}
// Preset fields (fields.yaml `preset`, UI-SPEC S7): on create, a text field
// follows its source field until the administrator edits it by hand; that
// stops it for as long as the form is open. On update nothing follows.
// Preset fields (fields.yaml `preset`, UI-SPEC S7): on create, a text or
// mltext field follows its text or mltext source until the administrator
// edits it by hand; that stops it for as long as the form is open. An mltext
// pair follows per locale and a hand edit of one locale stops only that
// locale; a mixed pair follows the active ML locale (presetUpdates). On
// update nothing follows.
const edited = new Set<string>()
// The locales of each ML field the administrator edited by hand.
const editedLocales = new Map<string, Set<string>>()
// The locale every ML control shows (MLTextField broadcasts the choice).
const mlLocale = ref('en')
function firstMLLocale(): string {
return schemaEnabledLocales(schema.value?.meta)[0] ?? 'en'
}
function onMLLocale(event: Event): void {
const locale = (event as CustomEvent<unknown>).detail
if (typeof locale === 'string' && locale !== '') {
mlLocale.value = locale
}
}
// FormGrid remounts the new panel's ML controls on the first enabled locale.
watch(activeTab, () => {
mlLocale.value = firstMLLocale()
})
function isEdited(target: string, locale?: string): boolean {
return locale === undefined ? edited.has(target) : (editedLocales.get(target)?.has(locale) ?? false)
}
function update(name: string, value: unknown): void {
const previous = values.value[name]
const next = { ...values.value, [name]: value }
edited.add(name)
if (mode === 'create') {
for (const field of fields.value) {
if (field.preset?.field === name && field.type === 'text' && !edited.has(field.name)) {
next[field.name] = presetValue(field.preset.type, value)
}
const type = fields.value.find((field) => field.name === name)?.type
if (type === 'mltext' || type === 'mlmarkdown') {
let locales = editedLocales.get(name)
if (!locales) {
locales = new Set<string>()
editedLocales.set(name, locales)
}
for (const locale of changedLocales(previous, value)) {
locales.add(locale)
}
}
if (mode === 'create') {
Object.assign(next, presetUpdates(fields.value, name, previous, value, values.value, mlLocale.value, isEdited))
}
values.value = next
if (errors.value[name]) {
@@ -411,8 +449,14 @@ function onBeforeUnload(event: BeforeUnloadEvent): void {
}
}
onMounted(() => window.addEventListener('beforeunload', onBeforeUnload))
onBeforeUnmount(() => window.removeEventListener('beforeunload', onBeforeUnload))
onMounted(() => {
window.addEventListener('beforeunload', onBeforeUnload)
window.addEventListener(ML_LOCALE_CHANGE, onMLLocale)
})
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', onBeforeUnload)
window.removeEventListener(ML_LOCALE_CHANGE, onMLLocale)
})
// The header's last breadcrumb is this record's title (design: Top header).
watchEffect(() => setRecordCrumb(loading.value ? '' : title.value))