feat(10-03): open, edit and save a record with toast and 422 feedback

- The SPA loads the backend::lang bundle before /auth/me, sets the
  document language from meta.locale and renders plural messages with
  Intl.PluralRules; interpolate mirrors phrasebook for :name/:Name/:NAME
- Create and record routes; mapWinterUrl maps recordUrl and redirects
  onto the controller's list, create and record routes only
- List rows open their record; FormView loads the form schema and the
  record, shows context-allowed fields in the span grid, saves values
  keyed by field name and toasts the resolved saved message
- A 422 puts each message under its field (aria-invalid,
  aria-describedby), shows the plural banner, focuses the first invalid
  field in schema order and clears a field's error on change
- The D-05 registry maps text, textarea, number and dropdown; any other
  type renders the unsupported-field box with the type in DM Mono
- The admin OpenAPI document declares the write request bodies
  (AdminRecord, AdminIDsRequest) and the list filter query as a
  deepObject, so the typed client can send them
- New backend::lang form.load_failed key; boardwalk/dist rebuilt
This commit is contained in:
Jakub Zych
2026-09-27 16:43:47 +02:00
parent 453b8ae786
commit 126ca5b8ed
41 changed files with 1930 additions and 37 deletions

View File

@@ -1,18 +1,59 @@
// UI strings (D-20). The resolved backend::lang bundle is loaded from the
// server in a later plan; until a key is loaded, t() returns the key itself,
// mirroring phrasebook's missing-key fallback. Placeholders use phrasebook's
// :name syntax with :Name and :NAME casing variants (D-24).
import { ref } from 'vue'
// UI strings (D-20, D-24). The resolved backend::lang bundle is loaded from
// the public GET /lang before anything else at startup. A key that is not in
// the bundle renders as the key itself, mirroring phrasebook's missing-key
// fallback. Every message is a CLDR form map; plural messages pick their form
// with Intl.PluralRules for the bundle's locale and fall back to "other".
// Placeholders use phrasebook's :name syntax with :Name and :NAME variants,
// and interpolate() replaces them exactly like phrasebook.interpolate.
import { readonly, ref } from 'vue'
import { api } from '../api/client'
import type { LangBundle, MessageForms } from '../api/types'
type Forms = Record<string, string>
export type MessageParams = Record<string, string | number>
const bundle = ref<Record<string, Forms>>({})
const bundle = ref<LangBundle>({})
const locale = ref(typeof document === 'undefined' ? 'pl' : document.documentElement.lang || 'pl')
export function setBundle(next: Record<string, Forms>): void {
/** The locale the loaded bundle resolved to (meta.locale of GET /lang). */
export const currentLocale = readonly(locale)
export function setBundle(next: LangBundle, nextLocale?: string): void {
bundle.value = next
if (nextLocale) {
locale.value = nextLocale
if (typeof document !== 'undefined') {
document.documentElement.lang = nextLocale
}
}
}
export function interpolate(text: string, params: Record<string, string | number> = {}): string {
/** Loads the backend::lang bundle for the admin's locale. Public endpoint. */
export async function loadStrings(): Promise<void> {
const { data, response } = await api.GET('/lang')
if (!response.ok || !data) {
return
}
setBundle(data.data, data.meta.locale)
}
/** Upper-cases the first code point, like phrasebook's ucfirst. */
function ucfirst(value: string): string {
const first = value.codePointAt(0)
if (first === undefined) {
return value
}
const head = String.fromCodePoint(first)
return head.toUpperCase() + value.slice(head.length)
}
/**
* Replaces :name, :Name and :NAME placeholders. Longer placeholders are
* replaced first (stable for equal lengths), so :names never loses to :name.
*/
export function interpolate(text: string, params: MessageParams = {}): string {
if (text === '') {
return text
}
const replacements: Array<[string, string]> = []
for (const [rawName, raw] of Object.entries(params)) {
const name = rawName.replace(/^:/, '')
@@ -20,7 +61,7 @@ export function interpolate(text: string, params: Record<string, string | number
continue
}
const value = String(raw)
replacements.push([`:${name.charAt(0).toUpperCase()}${name.slice(1)}`, value.charAt(0).toUpperCase() + value.slice(1)])
replacements.push([`:${ucfirst(name)}`, ucfirst(value)])
replacements.push([`:${name.toUpperCase()}`, value.toUpperCase()])
replacements.push([`:${name}`, value])
}
@@ -32,11 +73,51 @@ export function interpolate(text: string, params: Record<string, string | number
return out
}
export function t(key: string, params: Record<string, string | number> = {}): string {
const forms = bundle.value[key]
const text = forms?.other
const pluralRules = new Map<string, Intl.PluralRules | null>()
/** CLDR plural category of count in the bundle locale; "other" if unknown. */
export function pluralCategory(count: number, forLocale: string = locale.value): string {
let rules = pluralRules.get(forLocale)
if (rules === undefined) {
try {
rules = new Intl.PluralRules(forLocale)
} catch {
rules = null
}
pluralRules.set(forLocale, rules)
}
return rules ? rules.select(count) : 'other'
}
/**
* Renders one message (CLDR forms). With a count the plural form is chosen
* and :count is available to the text; params may override it.
*/
export function message(forms: MessageForms | null | undefined, count?: number, params: MessageParams = {}): string {
if (!forms) {
return ''
}
if (count === undefined) {
return interpolate(forms.other ?? '', params)
}
const text = forms[pluralCategory(count)] ?? forms.other ?? ''
return interpolate(text, { count, ...params })
}
/** A bundle string by key; the key itself when it is not loaded. */
export function t(key: string, params: MessageParams = {}): string {
const text = bundle.value[key]?.other
if (text === undefined) {
return key
}
return interpolate(text, params)
}
/** A plural bundle string by key; the key itself when it is not loaded. */
export function tc(key: string, count: number, params: MessageParams = {}): string {
const forms = bundle.value[key]
if (!forms) {
return key
}
return message(forms, count, params)
}

View File

@@ -4,6 +4,7 @@ import { currentUser } from '../state/useAuth'
import { homePath } from '../state/useNavigation'
import LoginView from '../views/LoginView.vue'
import ListView from '../views/ListView.vue'
import FormView from '../views/FormView.vue'
import NotFoundView from '../views/NotFoundView.vue'
declare module 'vue-router' {
@@ -43,6 +44,8 @@ export function createAdminRouter(history: RouterHistory = createWebHistory(runt
beforeEnter: () => homePath() ?? true,
},
{ path: '/:vendor/:plugin/:controller', name: 'list', component: ListView, meta: { shell: true } },
{ path: '/:vendor/:plugin/:controller/create', name: 'create', component: FormView, meta: { shell: true } },
{ path: '/:vendor/:plugin/:controller/:id(\\d+)', name: 'record', component: FormView, meta: { shell: true } },
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView, meta: { shell: true } },
],
})

View File

@@ -0,0 +1,39 @@
// Winter-shaped URLs (config_list recordUrl, config_form redirects) mapped
// onto the SPA's D-10 routes. The strings come from plugin YAML, so they are
// never used verbatim: only the current controller's list, create and record
// routes can come out (research Gap 8, T-10-20).
//
// <vendor>/<plugin>/<controller> -> list
// <vendor>/<plugin>/<controller>/create -> create
// <vendor>/<plugin>/<controller>/update/:id -> record (:id substituted)
// anything else, or another controller -> list
import { controllerPath, parseControllerId } from './controllerRoutes'
const DIGITS = /^\d+$/
export function mapWinterUrl(url: string | null | undefined, controllerId: string, id?: string | number | null): string {
const params = parseControllerId(controllerId)
const base = controllerPath(controllerId)
if (!params || !base) {
return '/'
}
const raw = (url ?? '').trim().split(/[?#]/, 1)[0] ?? ''
const segments = raw.split('/').filter((segment) => segment !== '')
const owner = [params.vendor, params.plugin, params.controller]
const ownsUrl = owner.every((segment, index) => segments[index]?.toLowerCase() === segment.toLowerCase())
if (!ownsUrl) {
return base
}
const rest = segments.slice(owner.length)
if (rest.length === 0) {
return base
}
if (rest.length === 1 && rest[0] === 'create') {
return `${base}/create`
}
if (rest.length === 2 && rest[0] === 'update') {
const target = rest[1] === ':id' ? String(id ?? '') : (rest[1] ?? '')
return DIGITS.test(target) ? `${base}/${target}` : base
}
return base
}