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

@@ -8,8 +8,12 @@ const inShell = computed(() => route.meta.shell === true)
</script>
<template>
<!-- Keyed by path, not full path: a new controller or record remounts its
view, while list query changes (search, sort, page) keep it. -->
<AppShell v-if="inShell">
<RouterView />
<RouterView v-slot="{ Component, route: current }">
<component :is="Component" :key="current.path" />
</RouterView>
</AppShell>
<RouterView v-else />
</template>

View File

@@ -474,7 +474,12 @@ export interface paths {
};
cookie?: never;
};
requestBody?: never;
/** @description Setting values keyed by field name */
requestBody: {
content: {
"application/json": components["schemas"]["cabana.AdminRecord"];
};
};
responses: {
/** @description OK */
200: {
@@ -617,6 +622,10 @@ export interface paths {
page?: number;
/** @description Records per page */
per_page?: number;
/** @description Filter values keyed by filter name, sent as filter[<name>]=<value> */
filter?: {
[key: string]: string;
};
};
header?: never;
path: {
@@ -697,7 +706,12 @@ export interface paths {
};
cookie?: never;
};
requestBody?: never;
/** @description Field values keyed by field name; relation fields carry ids */
requestBody: {
content: {
"application/json": components["schemas"]["cabana.AdminRecord"];
};
};
responses: {
/** @description Created */
201: {
@@ -776,7 +790,12 @@ export interface paths {
};
cookie?: never;
};
requestBody?: never;
/** @description Record ids */
requestBody: {
content: {
"application/json": components["schemas"]["cabana.AdminIDsRequest"];
};
};
responses: {
/** @description OK */
200: {
@@ -1299,7 +1318,12 @@ export interface paths {
};
cookie?: never;
};
requestBody?: never;
/** @description Field values keyed by field name; relation fields carry ids */
requestBody: {
content: {
"application/json": components["schemas"]["cabana.AdminRecord"];
};
};
responses: {
/** @description OK */
200: {
@@ -1614,7 +1638,12 @@ export interface paths {
};
cookie?: never;
};
requestBody?: never;
/** @description Related record ids */
requestBody: {
content: {
"application/json": components["schemas"]["cabana.AdminIDsRequest"];
};
};
responses: {
/** @description OK */
200: {
@@ -1697,7 +1726,12 @@ export interface paths {
};
cookie?: never;
};
requestBody?: never;
/** @description Related record ids */
requestBody: {
content: {
"application/json": components["schemas"]["cabana.AdminIDsRequest"];
};
};
responses: {
/** @description OK */
200: {
@@ -1756,6 +1790,9 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
"cabana.AdminIDsRequest": {
ids: number[];
};
"cabana.AdminLoginData": {
access_token?: string;
expires_in?: number;

View File

@@ -1,8 +1,10 @@
// Aliases onto the generated OpenAPI schema (D-15, D-16). No API shape is
// written by hand: every type here points at components['schemas'].
import type { components } from './schema'
import type { components, paths } from './schema'
type Schemas = components['schemas']
type ControllerPath = paths['/{vendor}/{plugin}/{controller}']
type FieldOptionsPath = paths['/{vendor}/{plugin}/{controller}/fields/{field}/options']
export type AdminLoginData = Schemas['cabana.AdminLoginData']
export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest']
@@ -36,3 +38,13 @@ export type LangBundle = Schemas['cabana.LangBundle']
export type ErrorEnvelope = Schemas['cabana.ErrorEnvelope']
/** One record: a string-keyed map read through its list or form schema. */
export type AdminRecord = Schemas['cabana.AdminRecord']
export type ErrorBody = Schemas['cabana.ErrorBody']
export type SuccessMeta = Schemas['cabana.SuccessMeta']
/** Body of bulk delete and relation link/unlink. */
export type IDsRequest = Schemas['cabana.AdminIDsRequest']
/** Path parameters naming one controller: vendor, plugin, controller (D-10). */
export type ControllerParams = ControllerPath['get']['parameters']['path']
/** The list query: search, sort, dir, page, per_page and filter[<name>]. */
export type ListQuery = NonNullable<ControllerPath['get']['parameters']['query']>
/** Relation field options query: search, page, per_page. */
export type FieldOptionsQuery = NonNullable<FieldOptionsPath['get']['parameters']['query']>

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
}

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { computed } from 'vue'
import { rendererFor, type FieldControlProps } from './registry'
// Picks the control for field.type from the registry (D-05) and forwards the
// shared control props and the value update.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>()
const control = computed(() => rendererFor(props.field.type))
</script>
<template>
<component
:is="control"
:field="field"
:model-value="modelValue"
:control-id="controlId"
:invalid="invalid"
:described-by="describedBy"
:labels="labels"
:source="source"
@update:model-value="(value: unknown) => emit('update:modelValue', value)"
/>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { computed } from 'vue'
import { CircleAlert } from '@lucide/vue'
import { t, tc } from '../../app/i18n'
// 422 banner (design screen 4): "Nie udało się zapisać. Popraw N pola…".
// Messages of keys that are not form fields are listed here, so no server
// message is lost.
const props = defineProps<{ errors: Record<string, string[]>; fieldNames: string[] }>()
const count = computed(() => Object.keys(props.errors).length)
const orphans = computed(() =>
Object.entries(props.errors)
.filter(([name]) => !props.fieldNames.includes(name))
.flatMap(([, messages]) => messages),
)
</script>
<template>
<div
v-if="count > 0"
role="alert"
data-error-banner
class="flex items-start gap-3 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger"
>
<CircleAlert :size="20" class="mt-px shrink-0" aria-hidden="true" />
<div class="flex flex-col gap-1">
<p>
<strong>{{ t('backend::lang.form.error_title') }}</strong>
{{ tc('backend::lang.form.error_fields', count) }}
</p>
<p v-for="(text, index) in orphans" :key="index" class="text-[13px]">{{ text }}</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed } from 'vue'
import { CircleAlert } from '@lucide/vue'
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
import FieldRenderer from './FieldRenderer.vue'
import { ownsLabel } from './registry'
// One form row: label (600, red aria-hidden asterisk when required), the
// control, the comment and the error line linked by aria-describedby.
const props = defineProps<{
field: FormField
modelValue: unknown
errors?: string[]
labels?: RelationOption[]
source?: ControllerParams | null
/** Prefix for element ids, so two forms on a page never collide. */
idPrefix?: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>()
const base = computed(() => `${props.idPrefix ?? 'field'}-${props.field.name}`)
const controlId = computed(() => base.value)
const commentId = computed(() => `${base.value}-comment`)
const errorId = computed(() => `${base.value}-error`)
const invalid = computed(() => (props.errors?.length ?? 0) > 0)
const selfLabelled = computed(() => ownsLabel(props.field.type))
const showComment = computed(() => !selfLabelled.value && !!props.field.comment)
const describedBy = computed(() =>
[showComment.value || (selfLabelled.value && props.field.comment) ? commentId.value : '', invalid.value ? errorId.value : '']
.filter(Boolean)
.join(' '),
)
</script>
<template>
<div class="flex min-w-0 flex-col gap-1.5" :data-field="field.name">
<label v-if="!selfLabelled" :for="controlId" class="font-semibold">
{{ field.label || field.name }}<span v-if="field.required" class="text-danger" aria-hidden="true"> *</span>
</label>
<FieldRenderer
:field="field"
:model-value="modelValue"
:control-id="controlId"
:invalid="invalid"
:described-by="describedBy"
:labels="labels"
:source="source"
@update:model-value="(value: unknown) => emit('update:modelValue', value)"
/>
<p v-if="showComment" :id="commentId" class="text-[13px] text-muted">{{ field.comment }}</p>
<div v-if="invalid" :id="errorId" class="flex flex-col gap-0.5 text-[13px] text-danger">
<p v-for="(text, index) in errors" :key="index" class="flex items-center gap-1.5">
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ text }}</span>
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import type { AdminRecord, ControllerParams, FormField as FormFieldSchema, RecordMeta } from '../../api/types'
import FormField from './FormField.vue'
// Two-column form grid (design screen 4): span left -> column 1, right ->
// column 2, full (Winter's default) -> the whole row, auto and row -> the next
// free slot. Below 600px everything is one column. Gaps are 22px x 24px.
defineProps<{
fields: FormFieldSchema[]
values: AdminRecord
errors: Record<string, string[]>
labels?: RecordMeta['labels']
source?: ControllerParams | null
idPrefix?: string
}>()
const emit = defineEmits<{ update: [name: string, value: unknown] }>()
function spanClass(span: string | undefined): string {
switch (span) {
case 'left':
return 'min-[600px]:col-start-1'
case 'right':
return 'min-[600px]:col-start-2'
case 'auto':
case 'row':
return ''
default:
return 'col-span-full'
}
}
</script>
<template>
<div class="grid grid-cols-1 gap-x-6 gap-y-[22px] min-[600px]:grid-cols-2">
<FormField
v-for="field in fields"
:key="field.name"
:class="spanClass(field.span)"
:field="field"
:model-value="values[field.name]"
:errors="errors[field.name]"
:labels="labels?.[field.name]"
:source="source"
:id-prefix="idPrefix"
@update:model-value="(value: unknown) => emit('update', field.name, value)"
/>
</div>
</template>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ChevronDown } from '@lucide/vue'
import type { FormOption } from '../../../api/types'
import { t } from '../../../app/i18n'
import { controlClass, type FieldControlProps } from '../registry'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: FormOption['value']] }>()
const EMPTY = 'empty'
const PLACEHOLDER = 'placeholder'
const options = computed(() => props.field.options ?? [])
function same(a: unknown, b: unknown): boolean {
if (a === null || a === undefined || b === null || b === undefined) {
return a === b || (a == null && b == null)
}
return String(a) === String(b)
}
// The <select> works on indexes so option values keep their JSON type
// (string, number or boolean) when they are sent back.
const selected = computed(() => {
const index = options.value.findIndex((option) => same(option.value, props.modelValue))
if (index >= 0) {
return String(index)
}
if (props.field.emptyOption !== undefined && (props.modelValue === null || props.modelValue === undefined || props.modelValue === '')) {
return EMPTY
}
return PLACEHOLDER
})
const muted = computed(() => selected.value === EMPTY || selected.value === PLACEHOLDER)
function onChange(event: Event): void {
const value = (event.target as HTMLSelectElement).value
if (value === EMPTY) {
emit('update:modelValue', null)
return
}
const option = options.value[Number(value)]
if (option) {
emit('update:modelValue', option.value)
}
}
</script>
<template>
<div class="relative">
<select
:id="controlId"
:name="field.name"
:value="selected"
:required="field.required || undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:class="[controlClass(invalid), muted ? 'text-muted' : '']"
class="h-input appearance-none pr-10"
@change="onChange"
>
<option v-if="field.emptyOption !== undefined" :value="EMPTY" class="text-muted">{{ field.emptyOption }}</option>
<option v-else-if="selected === PLACEHOLDER" :value="PLACEHOLDER" disabled>
{{ t('backend::lang.form.select_placeholder') }}
</option>
<option v-for="(option, index) in options" :key="index" :value="String(index)" class="text-text">
{{ option.label }}
</option>
</select>
<ChevronDown
:size="16"
class="pointer-events-none absolute top-1/2 right-3.5 -translate-y-1/2 text-muted"
aria-hidden="true"
/>
</div>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed } from 'vue'
import { controlAttributes, controlClass, type FieldControlProps } from '../registry'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: number | null] }>()
const text = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
const attrs = computed(() => controlAttributes(props.field))
// An empty input clears the value (null); anything else is sent as a number.
function onInput(event: Event): void {
const raw = (event.target as HTMLInputElement).value.trim()
if (raw === '') {
emit('update:modelValue', null)
return
}
const value = Number(raw)
emit('update:modelValue', Number.isFinite(value) ? value : null)
}
</script>
<template>
<input
:id="controlId"
v-bind="attrs"
:name="field.name"
type="number"
inputmode="decimal"
:value="text"
:required="field.required || undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:class="controlClass(invalid)"
class="h-input tabular-nums"
@input="onInput"
/>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed } from 'vue'
import { controlAttributes, controlClass, type FieldControlProps } from '../registry'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const text = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
const attrs = computed(() => controlAttributes(props.field))
</script>
<template>
<input
:id="controlId"
v-bind="attrs"
:name="field.name"
type="text"
:value="text"
:required="field.required || undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:class="controlClass(invalid)"
class="h-input"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue'
import { controlAttributes, controlClass, type FieldControlProps } from '../registry'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
// Winter textarea sizes mapped to rows; the design's Opis uses 4 (small).
const rowsBySize: Record<string, number> = { tiny: 2, small: 4, large: 8, huge: 12, giant: 18 }
const rows = computed(() => rowsBySize[props.field.size ?? ''] ?? 5)
const text = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
const attrs = computed(() => controlAttributes(props.field))
</script>
<template>
<textarea
:id="controlId"
v-bind="attrs"
:name="field.name"
:rows="rows"
:value="text"
:required="field.required || undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:class="controlClass(invalid)"
class="min-h-input resize-y py-2.5"
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
/>
</template>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Puzzle } from '@lucide/vue'
import { t } from '../../../app/i18n'
import type { FieldControlProps } from '../registry'
// D-05 fallback for any field type without a renderer. The translated
// sentence is split around the type so the type renders in DM Mono, as text.
const props = defineProps<FieldControlProps>()
const MARK = '\u0000'
const parts = computed(() => {
const [before = '', after = ''] = t('backend::lang.form.unsupported_field', { type: MARK }).split(MARK)
return { before, after }
})
const hasType = computed(() => t('backend::lang.form.unsupported_field', { type: MARK }).includes(MARK))
</script>
<template>
<div
:id="controlId"
data-unsupported-field
:aria-describedby="describedBy || undefined"
class="flex min-h-input items-center gap-2.5 rounded-control border-[1.5px] border-dashed border-border-strong bg-subtle px-3.5 text-[13px] text-muted"
>
<Puzzle :size="16" aria-hidden="true" />
<span v-if="hasType"
>{{ parts.before }}<code class="font-mono text-text">{{ props.field.type }}</code>{{ parts.after }}</span
>
<span v-else>{{ t('backend::lang.form.unsupported_field') }} <code class="font-mono text-text">{{ props.field.type }}</code></span>
</div>
</template>

View File

@@ -0,0 +1,83 @@
// 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 { isRegistered } from './registry'
export type FormMode = 'create' | 'update'
/** 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.
*/
export function editablePayload(fields: FormField[], values: AdminRecord): AdminRecord {
const out: AdminRecord = {}
for (const field of fields) {
if (field.readOnly || !isRegistered(field.type)) {
continue
}
const value = values[field.name]
if (value !== undefined) {
out[field.name] = value
}
}
return out
}
/** Initial values of a new record: schema defaults, toggles off, no ids. */
export function initialValues(fields: FormField[]): 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] = []
}
}
return out
}
/** 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
}

View File

@@ -0,0 +1,84 @@
// Field renderer registry (D-05). A form field's `type` selects the control
// component; any type without a renderer gets UnsupportedField, the design's
// dashed box, so an unknown type never breaks the form. Phase 10.1 turns this
// seam into the plugin extension point.
import type { Component } from 'vue'
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
import DropdownField from './fields/DropdownField.vue'
import NumberField from './fields/NumberField.vue'
import TextField from './fields/TextField.vue'
import TextareaField from './fields/TextareaField.vue'
import UnsupportedField from './fields/UnsupportedField.vue'
/** Props every field control receives from FieldRenderer. */
export interface FieldControlProps {
field: FormField
modelValue: unknown
/** id of the focusable control; the label points at it. */
controlId: string
invalid?: boolean
/** Space-separated ids of the comment and error lines. */
describedBy?: string
/** Display labels of the field's current relation values (meta.labels). */
labels?: RelationOption[]
/** Controller whose option endpoints serve this form; null for settings. */
source?: ControllerParams | null
}
const renderers = new Map<string, Component>([
['text', TextField],
['textarea', TextareaField],
['number', NumberField],
['dropdown', DropdownField],
])
/** Types whose control shows the label itself (toggle cards). */
const selfLabelled = new Set<string>()
export function rendererFor(type: string): Component {
return renderers.get(type) ?? UnsupportedField
}
/** Whether the SPA can edit values of this field type. */
export function isRegistered(type: string): boolean {
return renderers.has(type)
}
export function ownsLabel(type: string): boolean {
return selfLabelled.has(type)
}
// Winter `attributes:` are HTML attributes on the control. Only these are
// passed through; anything else (event handlers included) is dropped.
const allowedAttributes = new Set([
'readonly',
'placeholder',
'maxlength',
'minlength',
'min',
'max',
'step',
'autocomplete',
'spellcheck',
'pattern',
])
export function controlAttributes(field: FormField): Record<string, string | number | boolean> {
const out: Record<string, string | number | boolean> = {}
for (const [name, value] of Object.entries(field.attributes ?? {})) {
const key = name.toLowerCase()
if (!allowedAttributes.has(key) || value === null || value === false) {
continue
}
out[key] = value
}
return out
}
/** Shared control styling: 44px, radius 10, danger border when invalid. */
export function controlClass(invalid: boolean | undefined): string {
return [
'w-full rounded-control border bg-surface px-3.5 text-text transition-colors duration-150 ease-out',
invalid ? 'border-danger' : 'border-border-strong',
].join(' ')
}

View File

@@ -1,17 +1,36 @@
<script setup lang="ts">
import { RouterLink, useRouter } from 'vue-router'
import type { AdminRecord, ListColumn } from '../../api/types'
import { t } from '../../app/i18n'
// Read-only, schema-driven table: columns come from the list schema and each
// record is read through its column keys (D-16). Selection, sorting and
// row links arrive with the list screens.
defineProps<{
// Schema-driven table: columns come from the list schema and each record is
// read through its column keys (D-16). With rowLink a row opens its record
// route; the first cell carries the link for keyboard users.
const props = defineProps<{
columns: ListColumn[]
rows: AdminRecord[]
loading?: boolean
emptyText?: string
rowLink?: (row: AdminRecord) => string | null
}>()
const router = useRouter()
function linkOf(row: AdminRecord): string | null {
return props.rowLink ? props.rowLink(row) : null
}
function openRow(event: MouseEvent, row: AdminRecord): void {
const target = event.target as HTMLElement | null
if (target?.closest('a, button, input, label')) {
return
}
const to = linkOf(row)
if (to) {
void router.push(to)
}
}
function cellText(row: AdminRecord, column: ListColumn): string {
return formatValue(row[column.key])
}
@@ -68,14 +87,23 @@ function rowKey(row: AdminRecord, index: number): string {
</td>
</tr>
<template v-else>
<tr v-for="(row, index) in rows" :key="rowKey(row, index)" class="h-row border-b border-border">
<tr
v-for="(row, index) in rows"
:key="rowKey(row, index)"
:class="linkOf(row) ? 'cursor-pointer hover:bg-hover' : ''"
class="h-row border-b border-border"
@click="openRow($event, row)"
>
<td
v-for="(column, columnIndex) in columns"
:key="column.key"
:class="columnIndex === 0 ? 'font-semibold' : 'text-muted'"
class="px-3.5 first:pl-5 last:pr-5"
>
{{ cellText(row, column) }}
<RouterLink v-if="columnIndex === 0 && linkOf(row)" :to="linkOf(row)!" class="text-text no-underline">
{{ cellText(row, column) }}
</RouterLink>
<template v-else>{{ cellText(row, column) }}</template>
</td>
</tr>
</template>

View File

@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
import { ChevronRight } from '@lucide/vue'
import PluginRail from './PluginRail.vue'
import SectionPanel from './SectionPanel.vue'
import Toast from '../ui/Toast.vue'
import { t } from '../../app/i18n'
import { controllerIdFromPath } from '../../app/controllerRoutes'
import { currentUser } from '../../state/useAuth'
@@ -68,5 +69,6 @@ const initials = computed(() =>
<slot />
</main>
</div>
<Toast />
</div>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed, type Component } from 'vue'
import { RouterLink, type RouteLocationRaw } from 'vue-router'
// One button for the whole admin (design tokens): primary, outline, ghost and
// danger (outline). With `to` it renders a router link styled the same way.
const props = withDefaults(
defineProps<{
variant?: 'primary' | 'outline' | 'ghost' | 'danger'
size?: 'md' | 'sm'
type?: 'button' | 'submit'
disabled?: boolean
icon?: Component
to?: RouteLocationRaw
}>(),
{ variant: 'outline', size: 'md', type: 'button', disabled: false, icon: undefined, to: undefined },
)
const variants: Record<NonNullable<typeof props.variant>, string> = {
primary: 'bg-primary text-on-primary hover:opacity-90',
outline: 'border border-border-strong bg-surface text-text hover:bg-hover',
ghost: 'text-muted hover:bg-hover hover:text-text',
danger: 'border border-danger bg-surface text-danger hover:bg-danger-soft',
}
const classes = computed(() => [
'inline-flex shrink-0 items-center justify-center gap-2 rounded-control px-4 font-semibold whitespace-nowrap no-underline transition-colors duration-150 ease-out disabled:cursor-not-allowed disabled:opacity-60',
props.size === 'sm' ? 'h-[38px]' : 'h-button',
variants[props.variant],
])
</script>
<template>
<RouterLink v-if="to !== undefined" :to="to" :class="classes">
<component :is="icon" v-if="icon" :size="16" aria-hidden="true" />
<slot />
</RouterLink>
<button v-else :type="type" :disabled="disabled" :class="classes">
<component :is="icon" v-if="icon" :size="16" aria-hidden="true" />
<slot />
</button>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { Check, CircleAlert, X } from '@lucide/vue'
import { t } from '../../app/i18n'
import { dismissToast, toasts } from '../../state/useToasts'
</script>
<template>
<!-- Bottom centre, 88px up so it clears the sticky form footer. -->
<div class="pointer-events-none fixed inset-x-0 bottom-[88px] z-50 flex flex-col items-center gap-2 px-4">
<div
v-for="toast in toasts"
:key="toast.id"
:role="toast.tone === 'danger' ? 'alert' : 'status'"
:data-tone="toast.tone"
class="pointer-events-auto flex max-w-[560px] items-center gap-3 rounded-[14px] bg-[#1d2740] py-3 pr-2.5 pl-3.5 text-white shadow-toast"
>
<span
:class="toast.tone === 'danger' ? 'bg-[#f58a8a] text-[#1d2740]' : 'bg-accent text-on-accent'"
class="flex size-[26px] shrink-0 items-center justify-center rounded-full"
aria-hidden="true"
>
<CircleAlert v-if="toast.tone === 'danger'" :size="16" />
<Check v-else :size="16" />
</span>
<span class="font-medium">{{ toast.text }}</span>
<button
type="button"
:aria-label="t('backend::lang.form.close')"
class="flex size-8 items-center justify-center rounded-pager text-[#c3cbda] hover:bg-side-hover hover:text-white"
@click="dismissToast(toast.id)"
>
<X :size="16" aria-hidden="true" />
</button>
</div>
</div>
</template>

View File

@@ -2,11 +2,15 @@ import { createApp } from 'vue'
import App from './App.vue'
import { createAdminRouter } from './app/router'
import { onUnauthorized } from './api/client'
import { loadStrings } from './app/i18n'
import { clearUser, me } from './state/useAuth'
import { loadNavigation } from './state/useNavigation'
import './styles/main.css'
async function boot(): Promise<void> {
// Boot order (research Pattern 7): the public strings bundle first, so the
// login screen is translated, then the session, then navigation.
await loadStrings().catch(() => undefined)
// A 401 here only means "not signed in"; the router guard sends the
// visitor to the login route with the requested path as redirect.
const user = await me().catch(() => null)

View File

@@ -0,0 +1,52 @@
// Global toast queue (design: State Management). Toasts outlive route
// changes because AppShell mounts the renderer, so "Zapisz i zamknij" can
// show its toast on the list it returns to.
import { readonly, ref } from 'vue'
export type ToastTone = 'success' | 'danger'
export interface ToastItem {
id: number
text: string
tone: ToastTone
}
/** Auto-dismiss delay in milliseconds (design: about 4 s). */
export const TOAST_TIMEOUT = 4000
const items = ref<ToastItem[]>([])
const timers = new Map<number, ReturnType<typeof setTimeout>>()
let nextId = 1
export const toasts = readonly(items)
export function showToast(text: string, tone: ToastTone = 'success'): number {
const id = nextId++
items.value = [...items.value, { id, text, tone }]
timers.set(
id,
setTimeout(() => dismissToast(id), TOAST_TIMEOUT),
)
return id
}
export function dismissToast(id: number): void {
const timer = timers.get(id)
if (timer !== undefined) {
clearTimeout(timer)
timers.delete(id)
}
items.value = items.value.filter((item) => item.id !== id)
}
export function clearToasts(): void {
for (const timer of timers.values()) {
clearTimeout(timer)
}
timers.clear()
items.value = []
}
export function useToasts() {
return { toasts, showToast, dismissToast, clearToasts }
}

View File

@@ -0,0 +1,209 @@
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import type { AdminRecord, ErrorEnvelope, FormView, RecordEnvelope, RecordMeta } from '../api/types'
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
import { message, t } from '../app/i18n'
import { mapWinterUrl } from '../app/winterUrl'
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
import FormGrid from '../components/form/FormGrid.vue'
import {
contextAllows,
editablePayload,
fieldErrors,
focusField,
initialValues,
snapshot,
type FormMode,
} from '../components/form/formState'
import Button from '../components/ui/Button.vue'
import { showToast } from '../state/useToasts'
// Schema-driven create/update form (D-05, D-09, D-10, D-18). The view is
// keyed by route path, so one instance serves one record or one create.
const route = useRoute()
const router = useRouter()
const ID_PREFIX = 'field'
const path = {
vendor: String(route.params.vendor ?? ''),
plugin: String(route.params.plugin ?? ''),
controller: String(route.params.controller ?? ''),
}
const controllerId = controllerIdFromParams(path)
const listPath = controllerPath(controllerId) ?? '/'
const mode: FormMode = route.name === 'create' ? 'create' : 'update'
const recordId = mode === 'update' ? Number(route.params.id) : null
const schema = ref<FormView | null>(null)
const values = ref<AdminRecord>({})
const labels = ref<RecordMeta['labels']>({})
const errors = ref<Record<string, string[]>>({})
const loading = ref(true)
const failed = ref(false)
const saving = ref(false)
const saved = ref('')
const fields = computed(() => (schema.value?.fields ?? []).filter((field) => contextAllows(field, mode)))
const fieldNames = computed(() => fields.value.map((field) => field.name))
/** The record's display name: the first text field's value, in schema order. */
const recordName = computed(() => {
const first = schema.value?.fields.find((field) => field.type === 'text')
const value = first ? values.value[first.name] : undefined
return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : ''
})
const title = computed(() => {
if (mode === 'update' && recordName.value !== '') {
return recordName.value
}
const forms = mode === 'create' ? schema.value?.messages.create : schema.value?.messages.update
return message(forms, undefined, { name: recordName.value })
})
const subtitle = computed(() =>
mode === 'update' ? message(schema.value?.messages.update, undefined, { name: recordName.value }) : '',
)
const dirty = computed(() => snapshot(editablePayload(fields.value, values.value)) !== saved.value)
function adopt(record: RecordEnvelope | undefined): void {
if (record) {
values.value = { ...record.data }
labels.value = record.meta.labels ?? {}
}
saved.value = snapshot(editablePayload(fields.value, values.value))
}
async function load(): Promise<void> {
loading.value = true
failed.value = false
const params = { path }
const [schemaResult, recordResult] = await Promise.all([
api.GET('/{vendor}/{plugin}/{controller}/schema/form', { params }),
recordId !== null
? api.GET('/{vendor}/{plugin}/{controller}/{id}', { params: { path: { ...path, id: recordId } } })
: Promise.resolve(null),
])
schema.value = schemaResult.data?.data ?? null
failed.value = !schemaResult.data || (recordResult !== null && !recordResult.data)
if (schema.value && mode === 'create') {
values.value = initialValues(fields.value)
}
adopt(recordResult?.data)
loading.value = false
}
function update(name: string, value: unknown): void {
values.value = { ...values.value, [name]: value }
if (errors.value[name]) {
const next = { ...errors.value }
delete next[name]
errors.value = next
}
}
async function showErrors(error: ErrorEnvelope['error'] | undefined): Promise<void> {
errors.value = fieldErrors(error?.details)
await nextTick()
const first = fields.value.find((field) => errors.value[field.name])
if (first) {
focusField(ID_PREFIX, first.name)
}
}
function redirectTarget(kind: 'redirect' | 'redirectClose', id: unknown): string {
const redirects = schema.value?.redirects
const url = (redirects?.[mode]?.[kind] || redirects?.default) ?? ''
return mapWinterUrl(url, controllerId, typeof id === 'number' || typeof id === 'string' ? id : null)
}
/** Saves the record; returns the saved envelope, or null after an error. */
async function save(): Promise<RecordEnvelope | null> {
if (saving.value || !schema.value) {
return null
}
saving.value = true
try {
const body = editablePayload(fields.value, values.value)
const result =
recordId === null
? await api.POST('/{vendor}/{plugin}/{controller}', { params: { path }, body })
: await api.PUT('/{vendor}/{plugin}/{controller}/{id}', { params: { path: { ...path, id: recordId } }, body })
if (result.data) {
errors.value = {}
adopt(result.data)
showToast(message(schema.value.messages.saved, undefined, { name: recordName.value }))
return result.data
}
if (result.response.status === 422) {
await showErrors(result.error?.error)
} else {
showToast(result.error?.error.message || t('backend::lang.form.error_generic'), 'danger')
}
return null
} catch {
showToast(t('backend::lang.form.error_generic'), 'danger')
return null
} finally {
saving.value = false
}
}
async function onSave(): Promise<void> {
const record = await save()
if (record && mode === 'create') {
await router.push(redirectTarget('redirect', record.data.id))
}
}
defineExpose({ dirty })
void load()
</script>
<template>
<section class="mx-auto flex w-full max-w-[980px] flex-col gap-5 pb-24">
<header class="flex items-center gap-4">
<div class="flex min-w-0 flex-col">
<h1 class="truncate text-[24px] font-bold tracking-[-0.02em]">{{ loading ? '' : title }}</h1>
<p v-if="subtitle" class="text-muted">{{ subtitle }}</p>
</div>
</header>
<p v-if="failed && !loading" role="alert" class="rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger">
{{ t('backend::lang.form.load_failed') }}
</p>
<template v-else-if="schema">
<FormErrorBanner :errors="errors" :field-names="fieldNames" />
<form
class="rounded-card border border-border bg-surface p-7 shadow-card"
novalidate
@submit.prevent="onSave"
>
<FormGrid
:fields="fields"
:values="values"
:errors="errors"
:labels="labels"
:source="path"
:id-prefix="ID_PREFIX"
@update="update"
/>
</form>
</template>
<footer
class="fixed inset-x-0 bottom-0 z-30 flex items-center justify-end gap-2.5 border-t border-border bg-surface px-8 py-3.5"
>
<Button variant="ghost" :to="listPath">{{ t('backend::lang.form.cancel') }}</Button>
<Button variant="primary" :disabled="saving || loading || !schema" data-action="save" @click="onSave">
{{ saving ? t('backend::lang.form.saving') : t('backend::lang.form.save') }}
</Button>
</footer>
</section>
</template>

View File

@@ -5,6 +5,7 @@ import { api } from '../api/client'
import type { AdminRecord, ListMeta, ListSchema } from '../api/types'
import { controllerIdFromParams } from '../app/controllerRoutes'
import { t } from '../app/i18n'
import { mapWinterUrl } from '../app/winterUrl'
import DataTable from '../components/list/DataTable.vue'
import { activeEntry } from '../state/useNavigation'
@@ -41,6 +42,16 @@ const range = computed(() => {
return t('backend::lang.list.pagination_range', { from, to, total: m.total })
})
/** Record route of a row when the list declares a recordUrl (D-10). */
function rowLink(row: AdminRecord): string | null {
const recordUrl = schema.value?.recordUrl
const id = row.id
if (!recordUrl || (typeof id !== 'number' && typeof id !== 'string')) {
return null
}
return mapWinterUrl(recordUrl, controllerIdFromParams(path.value), id)
}
let generation = 0
async function load(): Promise<void> {
@@ -80,6 +91,7 @@ watch(path, load, { immediate: true, deep: true })
:rows="rows"
:loading="loading"
:empty-text="schema?.noRecordsMessage"
:row-link="rowLink"
/>
<footer class="border-t border-border px-5 py-3.5 text-[13px] text-muted">
{{ loading ? t('backend::lang.list.loading') : range }}