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,6 +1,20 @@
{ {
"components": { "components": {
"schemas": { "schemas": {
"cabana.AdminIDsRequest": {
"properties": {
"ids": {
"items": {
"type": "integer"
},
"type": "array"
}
},
"required": [
"ids"
],
"type": "object"
},
"cabana.AdminLoginData": { "cabana.AdminLoginData": {
"properties": { "properties": {
"access_token": { "access_token": {
@@ -1665,6 +1679,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.AdminRecord"
}
}
},
"description": "Setting values keyed by field name",
"required": true
},
"responses": { "responses": {
"200": { "200": {
"content": { "content": {
@@ -1863,6 +1888,19 @@
"schema": { "schema": {
"type": "integer" "type": "integer"
} }
},
{
"description": "Filter values keyed by filter name, sent as filter[\u003cname\u003e]=\u003cvalue\u003e",
"explode": true,
"in": "query",
"name": "filter",
"schema": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"style": "deepObject"
} }
], ],
"responses": { "responses": {
@@ -1958,6 +1996,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.AdminRecord"
}
}
},
"description": "Field values keyed by field name; relation fields carry ids",
"required": true
},
"responses": { "responses": {
"201": { "201": {
"content": { "content": {
@@ -2052,6 +2101,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.AdminIDsRequest"
}
}
},
"description": "Record ids",
"required": true
},
"responses": { "responses": {
"200": { "200": {
"content": { "content": {
@@ -2840,6 +2900,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.AdminRecord"
}
}
},
"description": "Field values keyed by field name; relation fields carry ids",
"required": true
},
"responses": { "responses": {
"200": { "200": {
"content": { "content": {
@@ -3176,6 +3247,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.AdminIDsRequest"
}
}
},
"description": "Related record ids",
"required": true
},
"responses": { "responses": {
"200": { "200": {
"content": { "content": {
@@ -3288,6 +3370,17 @@
} }
} }
], ],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.AdminIDsRequest"
}
}
},
"description": "Related record ids",
"required": true
},
"responses": { "responses": {
"200": { "200": {
"content": { "content": {

View File

@@ -8,8 +8,12 @@ const inShell = computed(() => route.meta.shell === true)
</script> </script>
<template> <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"> <AppShell v-if="inShell">
<RouterView /> <RouterView v-slot="{ Component, route: current }">
<component :is="Component" :key="current.path" />
</RouterView>
</AppShell> </AppShell>
<RouterView v-else /> <RouterView v-else />
</template> </template>

View File

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

View File

@@ -1,8 +1,10 @@
// Aliases onto the generated OpenAPI schema (D-15, D-16). No API shape is // Aliases onto the generated OpenAPI schema (D-15, D-16). No API shape is
// written by hand: every type here points at components['schemas']. // 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 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 AdminLoginData = Schemas['cabana.AdminLoginData']
export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest'] export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest']
@@ -36,3 +38,13 @@ export type LangBundle = Schemas['cabana.LangBundle']
export type ErrorEnvelope = Schemas['cabana.ErrorEnvelope'] export type ErrorEnvelope = Schemas['cabana.ErrorEnvelope']
/** One record: a string-keyed map read through its list or form schema. */ /** One record: a string-keyed map read through its list or form schema. */
export type AdminRecord = Schemas['cabana.AdminRecord'] 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 // UI strings (D-20, D-24). The resolved backend::lang bundle is loaded from
// server in a later plan; until a key is loaded, t() returns the key itself, // the public GET /lang before anything else at startup. A key that is not in
// mirroring phrasebook's missing-key fallback. Placeholders use phrasebook's // the bundle renders as the key itself, mirroring phrasebook's missing-key
// :name syntax with :Name and :NAME casing variants (D-24). // fallback. Every message is a CLDR form map; plural messages pick their form
import { ref } from 'vue' // 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 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]> = [] const replacements: Array<[string, string]> = []
for (const [rawName, raw] of Object.entries(params)) { for (const [rawName, raw] of Object.entries(params)) {
const name = rawName.replace(/^:/, '') const name = rawName.replace(/^:/, '')
@@ -20,7 +61,7 @@ export function interpolate(text: string, params: Record<string, string | number
continue continue
} }
const value = String(raw) 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.toUpperCase()}`, value.toUpperCase()])
replacements.push([`:${name}`, value]) replacements.push([`:${name}`, value])
} }
@@ -32,11 +73,51 @@ export function interpolate(text: string, params: Record<string, string | number
return out return out
} }
export function t(key: string, params: Record<string, string | number> = {}): string { const pluralRules = new Map<string, Intl.PluralRules | null>()
const forms = bundle.value[key]
const text = forms?.other /** 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) { if (text === undefined) {
return key return key
} }
return interpolate(text, params) 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 { homePath } from '../state/useNavigation'
import LoginView from '../views/LoginView.vue' import LoginView from '../views/LoginView.vue'
import ListView from '../views/ListView.vue' import ListView from '../views/ListView.vue'
import FormView from '../views/FormView.vue'
import NotFoundView from '../views/NotFoundView.vue' import NotFoundView from '../views/NotFoundView.vue'
declare module 'vue-router' { declare module 'vue-router' {
@@ -43,6 +44,8 @@ export function createAdminRouter(history: RouterHistory = createWebHistory(runt
beforeEnter: () => homePath() ?? true, beforeEnter: () => homePath() ?? true,
}, },
{ path: '/:vendor/:plugin/:controller', name: 'list', component: ListView, meta: { shell: 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 } }, { 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"> <script setup lang="ts">
import { RouterLink, useRouter } from 'vue-router'
import type { AdminRecord, ListColumn } from '../../api/types' import type { AdminRecord, ListColumn } from '../../api/types'
import { t } from '../../app/i18n' import { t } from '../../app/i18n'
// Read-only, schema-driven table: columns come from the list schema and each // Schema-driven table: columns come from the list schema and each record is
// record is read through its column keys (D-16). Selection, sorting and // read through its column keys (D-16). With rowLink a row opens its record
// row links arrive with the list screens. // route; the first cell carries the link for keyboard users.
defineProps<{ const props = defineProps<{
columns: ListColumn[] columns: ListColumn[]
rows: AdminRecord[] rows: AdminRecord[]
loading?: boolean loading?: boolean
emptyText?: string 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 { function cellText(row: AdminRecord, column: ListColumn): string {
return formatValue(row[column.key]) return formatValue(row[column.key])
} }
@@ -68,14 +87,23 @@ function rowKey(row: AdminRecord, index: number): string {
</td> </td>
</tr> </tr>
<template v-else> <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 <td
v-for="(column, columnIndex) in columns" v-for="(column, columnIndex) in columns"
:key="column.key" :key="column.key"
:class="columnIndex === 0 ? 'font-semibold' : 'text-muted'" :class="columnIndex === 0 ? 'font-semibold' : 'text-muted'"
class="px-3.5 first:pl-5 last:pr-5" 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> </td>
</tr> </tr>
</template> </template>

View File

@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
import { ChevronRight } from '@lucide/vue' import { ChevronRight } from '@lucide/vue'
import PluginRail from './PluginRail.vue' import PluginRail from './PluginRail.vue'
import SectionPanel from './SectionPanel.vue' import SectionPanel from './SectionPanel.vue'
import Toast from '../ui/Toast.vue'
import { t } from '../../app/i18n' import { t } from '../../app/i18n'
import { controllerIdFromPath } from '../../app/controllerRoutes' import { controllerIdFromPath } from '../../app/controllerRoutes'
import { currentUser } from '../../state/useAuth' import { currentUser } from '../../state/useAuth'
@@ -68,5 +69,6 @@ const initials = computed(() =>
<slot /> <slot />
</main> </main>
</div> </div>
<Toast />
</div> </div>
</template> </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 App from './App.vue'
import { createAdminRouter } from './app/router' import { createAdminRouter } from './app/router'
import { onUnauthorized } from './api/client' import { onUnauthorized } from './api/client'
import { loadStrings } from './app/i18n'
import { clearUser, me } from './state/useAuth' import { clearUser, me } from './state/useAuth'
import { loadNavigation } from './state/useNavigation' import { loadNavigation } from './state/useNavigation'
import './styles/main.css' import './styles/main.css'
async function boot(): Promise<void> { 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 // A 401 here only means "not signed in"; the router guard sends the
// visitor to the login route with the requested path as redirect. // visitor to the login route with the requested path as redirect.
const user = await me().catch(() => null) 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 type { AdminRecord, ListMeta, ListSchema } from '../api/types'
import { controllerIdFromParams } from '../app/controllerRoutes' import { controllerIdFromParams } from '../app/controllerRoutes'
import { t } from '../app/i18n' import { t } from '../app/i18n'
import { mapWinterUrl } from '../app/winterUrl'
import DataTable from '../components/list/DataTable.vue' import DataTable from '../components/list/DataTable.vue'
import { activeEntry } from '../state/useNavigation' import { activeEntry } from '../state/useNavigation'
@@ -41,6 +42,16 @@ const range = computed(() => {
return t('backend::lang.list.pagination_range', { from, to, total: m.total }) 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 let generation = 0
async function load(): Promise<void> { async function load(): Promise<void> {
@@ -80,6 +91,7 @@ watch(path, load, { immediate: true, deep: true })
:rows="rows" :rows="rows"
:loading="loading" :loading="loading"
:empty-text="schema?.noRecordsMessage" :empty-text="schema?.noRecordsMessage"
:row-link="rowLink"
/> />
<footer class="border-t border-border px-5 py-3.5 text-[13px] text-muted"> <footer class="border-t border-border px-5 py-3.5 text-[13px] text-muted">
{{ loading ? t('backend::lang.list.loading') : range }} {{ loading ? t('backend::lang.list.loading') : range }}

304
admin/tests/fixtures/lang.json vendored Normal file
View File

@@ -0,0 +1,304 @@
{
"data": {
"backend::lang.auth.invalid": {
"other": "Nieprawidłowe dane logowania"
},
"backend::lang.auth.login": {
"other": "Login lub e-mail"
},
"backend::lang.auth.logout": {
"other": "Wyloguj"
},
"backend::lang.auth.password": {
"other": "Hasło"
},
"backend::lang.auth.session_expired": {
"other": "Sesja wygasła. Zaloguj się ponownie."
},
"backend::lang.auth.submit": {
"other": "Zaloguj"
},
"backend::lang.auth.title": {
"other": "Witaj ponownie"
},
"backend::lang.form.add": {
"other": "Dodaj"
},
"backend::lang.form.add_item": {
"other": "Dodaj…"
},
"backend::lang.form.cancel": {
"other": "Anuluj"
},
"backend::lang.form.close": {
"other": "Zamknij"
},
"backend::lang.form.confirm": {
"other": "Potwierdź"
},
"backend::lang.form.create": {
"other": "Utwórz"
},
"backend::lang.form.delete": {
"other": "Usuń"
},
"backend::lang.form.deleting": {
"other": "Usuwanie…"
},
"backend::lang.form.error_fields": {
"one": "Popraw :count pole oznaczone poniżej.",
"few": "Popraw :count pola oznaczone poniżej.",
"many": "Popraw :count pól oznaczonych poniżej.",
"other": "Popraw :count pola oznaczonego poniżej."
},
"backend::lang.form.error_generic": {
"other": "Nie udało się zapisać zmian. Spróbuj ponownie."
},
"backend::lang.form.error_title": {
"other": "Nie udało się zapisać."
},
"backend::lang.form.load_failed": {
"other": "Nie udało się wczytać formularza."
},
"backend::lang.form.loading_options": {
"other": "Wczytywanie…"
},
"backend::lang.form.no_options": {
"other": "Brak wyników"
},
"backend::lang.form.none": {
"other": "— brak —"
},
"backend::lang.form.remove_item": {
"other": "Usuń: :name"
},
"backend::lang.form.return_to_list": {
"other": "Wróć do listy"
},
"backend::lang.form.save": {
"other": "Zapisz"
},
"backend::lang.form.save_and_close": {
"other": "Zapisz i zamknij"
},
"backend::lang.form.saving": {
"other": "Zapisywanie…"
},
"backend::lang.form.select_placeholder": {
"other": "Wybierz…"
},
"backend::lang.form.tab_errors": {
"one": ":count błąd",
"few": ":count błędy",
"many": ":count błędów",
"other": ":count błędu"
},
"backend::lang.form.unsaved_confirm": {
"other": "Masz niezapisane zmiany. Porzucić je?"
},
"backend::lang.form.unsupported_field": {
"other": "Nieobsługiwany typ pola: :type"
},
"backend::lang.form.update": {
"other": "Edytuj"
},
"backend::lang.list.clear_search": {
"other": "Wyczyść wyszukiwanie"
},
"backend::lang.list.column_created": {
"other": "Utworzono"
},
"backend::lang.list.column_switch_false": {
"other": "Nie"
},
"backend::lang.list.column_switch_true": {
"other": "Tak"
},
"backend::lang.list.column_updated": {
"other": "Zaktualizowano"
},
"backend::lang.list.delete_selected": {
"other": "Usuń zaznaczone"
},
"backend::lang.list.empty_value": {
"other": "—"
},
"backend::lang.list.load_failed": {
"other": "Nie udało się wczytać listy."
},
"backend::lang.list.loading": {
"other": "Wczytywanie…"
},
"backend::lang.list.next_page": {
"other": "Następna"
},
"backend::lang.list.no_records": {
"other": "Brak rekordów w tym widoku."
},
"backend::lang.list.no_results": {
"other": "0 wyników"
},
"backend::lang.list.pagination_range": {
"other": ":from–:to z :total"
},
"backend::lang.list.prev_page": {
"other": "Poprzednia"
},
"backend::lang.list.records_per_page": {
"other": "Na stronę"
},
"backend::lang.list.results": {
"one": ":count wynik",
"few": ":count wyniki",
"many": ":count wyników",
"other": ":count wyniku"
},
"backend::lang.list.search_prompt": {
"other": "Szukaj…"
},
"backend::lang.list.select_all": {
"other": "Zaznacz wszystkie na stronie"
},
"backend::lang.list.select_row": {
"other": "Zaznacz wiersz"
},
"backend::lang.list.sort_ascending": {
"other": "Sortuj rosnąco"
},
"backend::lang.list.sort_descending": {
"other": "Sortuj malejąco"
},
"backend::lang.messages.form.create": {
"other": "Nowy rekord"
},
"backend::lang.messages.form.delete_confirm": {
"other": "Usunąć ten rekord?"
},
"backend::lang.messages.form.deleted": {
"other": "Usunięto rekord"
},
"backend::lang.messages.form.saved": {
"other": "Zapisano"
},
"backend::lang.messages.form.update": {
"other": "Edycja rekordu"
},
"backend::lang.messages.list.create": {
"other": "Nowy rekord"
},
"backend::lang.messages.list.delete_confirm": {
"other": "Usunąć zaznaczone (:count)?"
},
"backend::lang.messages.list.deleted": {
"one": "Usunięto :count rekord",
"few": "Usunięto :count rekordy",
"many": "Usunięto :count rekordów",
"other": "Usunięto :count rekordu"
},
"backend::lang.messages.list.empty_search": {
"other": "Nic nie znaleziono"
},
"backend::lang.messages.list.empty_search_hint": {
"other": "Żaden rekord nie pasuje do frazy „:term”. Spróbuj innej frazy."
},
"backend::lang.messages.list.record_count": {
"one": ":count rekord",
"few": ":count rekordy",
"many": ":count rekordów",
"other": ":count rekordu"
},
"backend::lang.messages.list.selected": {
"other": "Zaznaczono :count"
},
"backend::lang.messages.relation.candidate_search": {
"other": "Szukaj…"
},
"backend::lang.messages.relation.empty": {
"other": "Brak powiązanych rekordów."
},
"backend::lang.messages.relation.link": {
"other": "Dodaj"
},
"backend::lang.messages.relation.link_hint": {
"other": "Wybierz rekordy, które chcesz dołączyć."
},
"backend::lang.messages.relation.linked": {
"one": "Dołączono :count rekord",
"few": "Dołączono :count rekordy",
"many": "Dołączono :count rekordów",
"other": "Dołączono :count rekordu"
},
"backend::lang.messages.relation.unlink_confirm": {
"other": "Odłączyć zaznaczone (:count)?"
},
"backend::lang.messages.relation.unlink_selected": {
"other": "Odłącz zaznaczone"
},
"backend::lang.messages.relation.unlinked": {
"one": "Odłączono :count rekord",
"few": "Odłączono :count rekordy",
"many": "Odłączono :count rekordów",
"other": "Odłączono :count rekordu"
},
"backend::lang.nav.breadcrumbs": {
"other": "Ścieżka nawigacji"
},
"backend::lang.nav.collapse": {
"other": "Zwiń menu"
},
"backend::lang.nav.empty": {
"other": "Brak dostępnych sekcji."
},
"backend::lang.nav.expand": {
"other": "Rozwiń menu"
},
"backend::lang.nav.plugins": {
"other": "Wtyczki"
},
"backend::lang.nav.sections": {
"other": "Sekcje"
},
"backend::lang.nav.settings": {
"other": "Ustawienia"
},
"backend::lang.nav.user_menu": {
"other": "Menu użytkownika"
},
"backend::lang.page.not_found": {
"other": "Nie znaleziono strony."
},
"backend::lang.relation.add": {
"other": "Dodaj"
},
"backend::lang.relation.add_count": {
"other": "Dodaj (:count)"
},
"backend::lang.relation.candidates_range": {
"other": ":from–:to z :total"
},
"backend::lang.relation.link": {
"other": "Dołącz"
},
"backend::lang.relation.remove": {
"other": "Usuń"
},
"backend::lang.relation.search": {
"other": "Szukaj…"
},
"backend::lang.relation.unlink": {
"other": "Odłącz"
},
"backend::lang.settings.empty": {
"other": "Brak dostępnych ustawień."
},
"backend::lang.settings.saved": {
"other": "Zapisano ustawienia"
},
"backend::lang.settings.title": {
"other": "Ustawienia"
}
},
"meta": {
"locale": "pl"
}
}

View File

@@ -0,0 +1,38 @@
{
"data": {
"name": "Widget",
"fields": [
{ "name": "name", "label": "Name", "type": "text", "span": "left", "required": true },
{ "name": "code", "label": "Code", "type": "text", "span": "right", "comment": "Unique widget code" },
{
"name": "size",
"label": "Size",
"type": "dropdown",
"span": "left",
"emptyOption": "— none —",
"options": [
{ "value": "s", "label": "Small" },
{ "value": "m", "label": "Medium" },
{ "value": "l", "label": "Large" }
]
},
{ "name": "weight", "label": "Weight", "type": "number", "span": "right" },
{ "name": "notes", "label": "Notes", "type": "textarea", "span": "full", "size": "small" },
{ "name": "color", "label": "Colour", "type": "colorpicker", "span": "left" }
],
"messages": {
"create": { "other": "New widget" },
"update": { "other": "Editing widget" },
"saved": { "other": "Widget „:name” saved" },
"deleteConfirm": { "other": "Delete this widget?" },
"deleted": { "other": "Widget deleted" }
},
"meta": { "locale": "en" },
"redirects": {
"create": { "redirect": "acme/demo/widgets/update/:id", "redirectClose": "acme/demo/widgets" },
"update": { "redirect": "acme/demo/widgets", "redirectClose": "acme/demo/widgets" },
"default": "acme/demo/widgets"
}
},
"meta": { "locale": "en" }
}

View File

@@ -8,15 +8,38 @@
"showCheckboxes": true, "showCheckboxes": true,
"showSorting": true, "showSorting": true,
"searchTerm": "", "searchTerm": "",
"toolbarButtons": ["create"], "toolbarButtons": [
"create"
],
"columns": [ "columns": [
{ "key": "name", "label": "Name", "searchable": true, "sortable": true }, {
{ "key": "code", "label": "Code", "searchable": true, "sortable": true }, "key": "name",
{ "key": "tags", "label": "Tags", "searchable": false, "sortable": false, "type": "relation", "relation": "tags", "select": "name" } "label": "Name",
"searchable": true,
"sortable": true
},
{
"key": "code",
"label": "Code",
"searchable": true,
"sortable": true
},
{
"key": "tags",
"label": "Tags",
"searchable": false,
"sortable": false,
"type": "relation",
"relation": "tags",
"select": "name"
}
], ],
"filters": [], "filters": [],
"rowActions": [], "rowActions": [],
"bulkActions": [] "bulkActions": [],
"recordUrl": "acme/demo/widgets/update/:id"
}, },
"meta": { "locale": "en" } "meta": {
"locale": "en"
}
} }

View File

@@ -0,0 +1,12 @@
{
"data": {
"id": 1,
"name": "Blue widget",
"code": "W-01",
"size": "m",
"weight": 12,
"notes": "Round",
"color": "#0000ff"
},
"meta": { "labels": {} }
}

87
admin/tests/helpers.ts Normal file
View File

@@ -0,0 +1,87 @@
// Shared smoke-test helpers: a fetch mock keyed by "METHOD /path" and an app
// mount at a route with a signed-in admin. Fixtures are neutral acme.demo.*
// data; no application names appear in framework tests.
import { vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory } from 'vue-router'
import App from '../src/App.vue'
import { createAdminRouter } from '../src/app/router'
import { me } from '../src/state/useAuth'
import { loadNavigation } from '../src/state/useNavigation'
import navigation from './fixtures/navigation.json'
export const API = '/admin-test/api/v1'
export const profile = {
data: {
id: 7,
login: 'dev',
email: 'dev@example.test',
first_name: 'Dana',
last_name: 'Dev',
is_superuser: false,
role: { id: 2, code: 'developer', name: 'Developer' },
},
meta: {},
}
export interface Reply {
status?: number
body: unknown
}
export type Route = Reply | ((request: Request) => Reply | Promise<Reply>)
export function json(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
}
export const notFound = { error: { code: 'not_found', message: 'Not found', details: {} } }
/** Replaces fetch; routes are keyed by "METHOD pathname" (query ignored). */
export function mockApi(routes: Record<string, Route>): Request[] {
const calls: Request[] = []
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => {
const request = input as Request
calls.push(request)
const route = routes[`${request.method} ${new URL(request.url).pathname}`]
if (!route) {
return json(404, notFound)
}
const reply = typeof route === 'function' ? await route(request.clone()) : route
return json(reply.status ?? 200, reply.body)
})
return calls
}
export function pathOf(request: Request): string {
return new URL(request.url).pathname
}
export function queryOf(request: Request): URLSearchParams {
return new URL(request.url).searchParams
}
/** Requests matching a method and pathname, in call order. */
export function requestsTo(calls: Request[], method: string, pathname: string): Request[] {
return calls.filter((request) => request.method === method && pathOf(request) === pathname)
}
/** Mounts the whole app at a path with the profile and navigation loaded. */
export async function mountApp(path: string, routes: Record<string, Route>, options: { attach?: boolean } = {}) {
const calls = mockApi({
[`GET ${API}/auth/me`]: { body: profile },
[`GET ${API}/navigation`]: { body: navigation },
...routes,
})
await me()
await loadNavigation()
const router = createAdminRouter(createMemoryHistory())
await router.push(path)
const wrapper = mount(App, {
global: { plugins: [router] },
attachTo: options.attach ? document.body : undefined,
})
await flushPromises()
return { wrapper, calls, router }
}

View File

@@ -0,0 +1,205 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { interpolate, loadStrings, message, setBundle, t, tc } from '../../src/app/i18n'
import { mapWinterUrl } from '../../src/app/winterUrl'
import { clearUser } from '../../src/state/useAuth'
import { setNavigation } from '../../src/state/useNavigation'
import { clearToasts } from '../../src/state/useToasts'
import lang from '../fixtures/lang.json'
import formSchema from '../fixtures/widgets.form-schema.json'
import record from '../fixtures/widgets.record.json'
import listSchema from '../fixtures/widgets.list-schema.json'
import listRows from '../fixtures/widgets.list.json'
import { API, mockApi, mountApp, pathOf, requestsTo, type Route } from '../helpers'
const RECORD = `${API}/acme/demo/widgets/1`
const formRoutes: Record<string, Route> = {
[`GET ${API}/acme/demo/widgets/schema/form`]: { body: formSchema },
[`GET ${RECORD}`]: { body: record },
}
beforeEach(() => {
clearUser()
setNavigation([])
clearToasts()
setBundle(lang.data, 'pl')
})
afterEach(() => {
document.body.innerHTML = ''
})
describe('strings', () => {
it('loads the backend::lang bundle and sets the document language', async () => {
setBundle({}, 'en')
const calls = mockApi({ [`GET ${API}/lang`]: { body: lang } })
await loadStrings()
expect(calls.map(pathOf)).toEqual([`${API}/lang`])
expect(document.documentElement.lang).toBe('pl')
expect(t('backend::lang.form.save')).toBe('Zapisz')
expect(t('backend::lang.form.missing_key')).toBe('backend::lang.form.missing_key')
})
it('interpolates :name, :Name and :NAME like phrasebook.interpolate', () => {
expect(interpolate('Hello :name, :Name, :NAME', { name: 'ala' })).toBe('Hello ala, Ala, ALA')
// Longer placeholders win, so :names is not eaten by :name.
expect(interpolate(':names and :name', { name: 'x', names: 'y' })).toBe('y and x')
expect(interpolate('Item :name', { ':name': 'żaba' })).toBe('Item żaba')
expect(interpolate(':Name', { name: 'żaba' })).toBe('Żaba')
expect(interpolate(':count items', { count: 3 })).toBe('3 items')
expect(interpolate('no placeholders', {})).toBe('no placeholders')
})
it('picks the CLDR plural form for the locale and falls back to other', () => {
expect(tc('backend::lang.form.error_fields', 1)).toBe('Popraw 1 pole oznaczone poniżej.')
expect(tc('backend::lang.form.error_fields', 2)).toBe('Popraw 2 pola oznaczone poniżej.')
expect(tc('backend::lang.form.error_fields', 5)).toBe('Popraw 5 pól oznaczonych poniżej.')
expect(message({ other: ':count things' }, 1)).toBe('1 things')
})
})
describe('winter URLs', () => {
it('maps recordUrl and redirects onto the controller routes only', () => {
const id = 'acme.demo.widgets'
expect(mapWinterUrl('acme/demo/widgets', id)).toBe('/acme/demo/widgets')
expect(mapWinterUrl('/acme/demo/widgets/create', id)).toBe('/acme/demo/widgets/create')
expect(mapWinterUrl('acme/demo/widgets/update/:id', id, 42)).toBe('/acme/demo/widgets/42')
expect(mapWinterUrl('acme/demo/widgets/update/:id', id, null)).toBe('/acme/demo/widgets')
expect(mapWinterUrl('acme/demo/widgets/preview/:id', id, 42)).toBe('/acme/demo/widgets')
expect(mapWinterUrl('https://evil.example/acme/demo/widgets', id, 1)).toBe('/acme/demo/widgets')
expect(mapWinterUrl('acme/other/things/update/:id', id, 1)).toBe('/acme/demo/widgets')
expect(mapWinterUrl('', id)).toBe('/acme/demo/widgets')
})
})
describe('edit a record', () => {
it('opens the record route from a list row', async () => {
const { wrapper, router } = await mountApp('/acme/demo/widgets', {
[`GET ${API}/acme/demo/widgets/schema/list`]: { body: listSchema },
[`GET ${API}/acme/demo/widgets`]: { body: listRows },
...formRoutes,
})
const firstRow = wrapper.findAll('tbody tr')[0]!
expect(firstRow.find('td a').attributes('href')).toBe('/acme/demo/widgets/1')
expect(firstRow.findAll('td').at(-2)!.classes()).not.toContain('font-semibold')
await firstRow.findAll('td').at(-1)!.trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/1')
expect(wrapper.find('h1').text()).toBe('Blue widget')
expect(wrapper.text()).toContain('Editing widget')
})
it('renders the schema fields with their values and marks required fields', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', formRoutes)
const name = wrapper.find('#field-name')
expect((name.element as HTMLInputElement).value).toBe('Blue widget')
expect(name.attributes('aria-required')).toBe('true')
expect(wrapper.find('label[for="field-name"] [aria-hidden="true"]').text()).toBe('*')
expect((wrapper.find('#field-weight').element as HTMLInputElement).value).toBe('12')
expect(wrapper.find('#field-notes').attributes('rows')).toBe('4')
const size = wrapper.find('#field-size')
expect(size.findAll('option').map((option) => option.text())).toEqual(['— none —', 'Small', 'Medium', 'Large'])
expect((size.element as HTMLSelectElement).value).toBe('1')
expect(wrapper.find('#field-code').attributes('aria-describedby')).toBe('field-code-comment')
expect(wrapper.find('#field-code-comment').text()).toBe('Unique widget code')
})
it('PUTs the typed body keyed by field name and shows the saved toast', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', {
...formRoutes,
[`PUT ${RECORD}`]: async (request) => ({
body: { data: { ...record.data, ...((await request.json()) as object) }, meta: { labels: {} } },
}),
})
await wrapper.find('#field-name').setValue('Red widget')
await wrapper.find('#field-size').setValue('2')
await wrapper.find('#field-weight').setValue('')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
const [put] = requestsTo(calls, 'PUT', RECORD)
expect(put).toBeDefined()
expect(put!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
expect(await put!.json()).toEqual({ name: 'Red widget', code: 'W-01', size: 'l', weight: null, notes: 'Round' })
const toast = wrapper.find('[role="status"]')
expect(toast.text()).toContain('Widget „Red widget” saved')
expect(toast.find('button').attributes('aria-label')).toBe('Zamknij')
})
it('maps a 422 onto fields, focuses the first invalid one and clears on change', async () => {
const { wrapper } = await mountApp(
'/acme/demo/widgets/1',
{
...formRoutes,
[`PUT ${RECORD}`]: {
status: 422,
body: {
error: {
code: 'validation_failed',
message: 'The given data was invalid.',
details: { weight: ['The weight must be positive.'], name: ['The name field is required.'] },
},
},
},
},
{ attach: true },
)
await wrapper.find('#field-name').setValue('')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
const banner = wrapper.find('[data-error-banner]')
expect(banner.attributes('role')).toBe('alert')
expect(banner.text()).toContain('Nie udało się zapisać.')
expect(banner.text()).toContain('Popraw 2 pola oznaczone poniżej.')
const name = wrapper.find('#field-name')
expect(name.attributes('aria-invalid')).toBe('true')
expect(name.attributes('aria-describedby')).toBe('field-name-error')
expect(wrapper.find('#field-name-error').text()).toBe('The name field is required.')
expect(wrapper.find('#field-weight').attributes('aria-invalid')).toBe('true')
// Schema order decides which invalid field is first, not the details order.
expect(document.activeElement?.id).toBe('field-name')
await name.setValue('Fixed')
expect(wrapper.find('#field-name').attributes('aria-invalid')).toBeUndefined()
expect(wrapper.find('#field-name-error').exists()).toBe(false)
expect(wrapper.find('#field-weight').attributes('aria-invalid')).toBe('true')
expect(wrapper.find('[data-error-banner]').text()).toContain('Popraw 1 pole oznaczone poniżej.')
})
it('shows a danger toast with the envelope message for other errors', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', {
...formRoutes,
[`PUT ${RECORD}`]: { status: 403, body: { error: { code: 'forbidden', message: 'Forbidden.', details: {} } } },
})
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
const toast = wrapper.find('[data-tone="danger"]')
expect(toast.attributes('role')).toBe('alert')
expect(toast.text()).toContain('Forbidden.')
expect(wrapper.find('[data-error-banner]').exists()).toBe(false)
})
it('renders an unknown field type as the unsupported box instead of breaking the form', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', formRoutes)
const box = wrapper.find('[data-unsupported-field]')
expect(box.exists()).toBe(true)
expect(box.text()).toBe('Nieobsługiwany typ pola: colorpicker')
expect(box.find('code').text()).toBe('colorpicker')
expect(box.find('code').classes()).toContain('font-mono')
expect(box.find('svg').exists()).toBe(true)
expect(wrapper.find('label[for="field-color"]').text()).toBe('Colour')
})
})

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,8 @@
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" /> <meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<title>SummerCMS</title> <title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-BD2QIjGC.js"></script> <script type="module" crossorigin src="./assets/index-BB795npz.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-UTAit0wB.css"> <link rel="stylesheet" crossorigin href="./assets/index-VVVgyQoR.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -66,6 +66,12 @@ type ListEnvelope[T any] struct {
// list or form schema (D-16). // list or form schema (D-16).
type AdminRecord map[string]any type AdminRecord map[string]any
// AdminIDsRequest is the body of the id-list writes: bulk delete, relation
// link and relation unlink.
type AdminIDsRequest struct {
IDs []uint64 `json:"ids"`
}
// AdminLoginRequest is the admin login body. Either login or email // AdminLoginRequest is the admin login body. Either login or email
// identifies the backend user. // identifies the backend user.
type AdminLoginRequest struct { type AdminLoginRequest struct {
@@ -220,6 +226,7 @@ func AdminSettingsGet() {}
// @Produce json // @Produce json
// @Security BackendBearer // @Security BackendBearer
// @Param code path string true "Settings code" // @Param code path string true "Settings code"
// @Param body body AdminRecord true "Setting values keyed by field name"
// @Success 200 {object} Envelope[SettingsResult] // @Success 200 {object} Envelope[SettingsResult]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
@@ -331,6 +338,7 @@ func AdminFilterOptions() {}
// @Param dir query string false "Sort direction (asc or desc)" // @Param dir query string false "Sort direction (asc or desc)"
// @Param page query integer false "Page" // @Param page query integer false "Page"
// @Param per_page query integer false "Records per page" // @Param per_page query integer false "Records per page"
// @Param filter query object false "Filter values keyed by filter name, sent as filter[<name>]=<value>"
// @Success 200 {object} ListEnvelope[[]AdminRecord] // @Success 200 {object} ListEnvelope[[]AdminRecord]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
@@ -350,6 +358,7 @@ func AdminList() {}
// @Param vendor path string true "Vendor" // @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin" // @Param plugin path string true "Plugin"
// @Param controller path string true "Controller" // @Param controller path string true "Controller"
// @Param body body AdminRecord true "Field values keyed by field name; relation fields carry ids"
// @Success 201 {object} RecordEnvelope // @Success 201 {object} RecordEnvelope
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
@@ -368,6 +377,7 @@ func AdminCreate() {}
// @Param vendor path string true "Vendor" // @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin" // @Param plugin path string true "Plugin"
// @Param controller path string true "Controller" // @Param controller path string true "Controller"
// @Param body body AdminIDsRequest true "Record ids"
// @Success 200 {object} Envelope[BulkResult] // @Success 200 {object} Envelope[BulkResult]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
@@ -405,6 +415,7 @@ func AdminShow() {}
// @Param plugin path string true "Plugin" // @Param plugin path string true "Plugin"
// @Param controller path string true "Controller" // @Param controller path string true "Controller"
// @Param id path integer true "Record id" // @Param id path integer true "Record id"
// @Param body body AdminRecord true "Field values keyed by field name; relation fields carry ids"
// @Success 200 {object} RecordEnvelope // @Success 200 {object} RecordEnvelope
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
@@ -481,6 +492,7 @@ func AdminRelationCandidates() {}
// @Param controller path string true "Controller" // @Param controller path string true "Controller"
// @Param id path integer true "Owner id" // @Param id path integer true "Owner id"
// @Param name path string true "Relation name" // @Param name path string true "Relation name"
// @Param body body AdminIDsRequest true "Related record ids"
// @Success 200 {object} Envelope[RelationMutationResult] // @Success 200 {object} Envelope[RelationMutationResult]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
@@ -501,6 +513,7 @@ func AdminRelationLink() {}
// @Param controller path string true "Controller" // @Param controller path string true "Controller"
// @Param id path integer true "Owner id" // @Param id path integer true "Owner id"
// @Param name path string true "Relation name" // @Param name path string true "Relation name"
// @Param body body AdminIDsRequest true "Related record ids"
// @Success 200 {object} Envelope[RelationMutationResult] // @Success 200 {object} Envelope[RelationMutationResult]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope

View File

@@ -230,6 +230,17 @@ func convertParameter(p map[string]any) map[string]any {
} }
out[k] = v out[k] = v
} }
// Swagger 2.0 has no object query parameters; swag emits `type: object`
// for one. cabana reads such a parameter as bracketed keys
// (filter[<name>]=<value>), which is OpenAPI 3's deepObject style with
// string values.
if in, _ := out["in"].(string); in == "query" && schema["type"] == "object" {
if _, ok := schema["additionalProperties"]; !ok {
schema["additionalProperties"] = map[string]any{"type": "string"}
}
out["style"] = "deepObject"
out["explode"] = true
}
if len(schema) > 0 { if len(schema) > 0 {
out["schema"] = schema out["schema"] = schema
} }

View File

@@ -68,6 +68,7 @@ form:
error_fields: error_fields:
one: "Fix :count field marked below." one: "Fix :count field marked below."
other: "Fix :count fields marked below." other: "Fix :count fields marked below."
load_failed: The form could not be loaded.
error_generic: The changes could not be saved. Please try again. error_generic: The changes could not be saved. Please try again.
unsaved_confirm: You have unsaved changes. Discard them? unsaved_confirm: You have unsaved changes. Discard them?
tab_errors: tab_errors:

View File

@@ -74,6 +74,7 @@ form:
few: "Popraw :count pola oznaczone poniżej." few: "Popraw :count pola oznaczone poniżej."
many: "Popraw :count pól oznaczonych poniżej." many: "Popraw :count pól oznaczonych poniżej."
other: "Popraw :count pola oznaczonego poniżej." other: "Popraw :count pola oznaczonego poniżej."
load_failed: Nie udało się wczytać formularza.
error_generic: Nie udało się zapisać zmian. Spróbuj ponownie. error_generic: Nie udało się zapisać zmian. Spróbuj ponownie.
unsaved_confirm: Masz niezapisane zmiany. Porzucić je? unsaved_confirm: Masz niezapisane zmiany. Porzucić je?
tab_errors: tab_errors: