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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user