feat(10-03): open, edit and save a record with toast and 422 feedback
- The SPA loads the backend::lang bundle before /auth/me, sets the document language from meta.locale and renders plural messages with Intl.PluralRules; interpolate mirrors phrasebook for :name/:Name/:NAME - Create and record routes; mapWinterUrl maps recordUrl and redirects onto the controller's list, create and record routes only - List rows open their record; FormView loads the form schema and the record, shows context-allowed fields in the span grid, saves values keyed by field name and toasts the resolved saved message - A 422 puts each message under its field (aria-invalid, aria-describedby), shows the plural banner, focuses the first invalid field in schema order and clears a field's error on change - The D-05 registry maps text, textarea, number and dropdown; any other type renders the unsupported-field box with the type in DM Mono - The admin OpenAPI document declares the write request bodies (AdminRecord, AdminIDsRequest) and the list filter query as a deepObject, so the typed client can send them - New backend::lang form.load_failed key; boardwalk/dist rebuilt
This commit is contained in:
@@ -1,6 +1,20 @@
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"cabana.AdminIDsRequest": {
|
||||
"properties": {
|
||||
"ids": {
|
||||
"items": {
|
||||
"type": "integer"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ids"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.AdminLoginData": {
|
||||
"properties": {
|
||||
"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": {
|
||||
"200": {
|
||||
"content": {
|
||||
@@ -1863,6 +1888,19 @@
|
||||
"schema": {
|
||||
"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": {
|
||||
@@ -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": {
|
||||
"201": {
|
||||
"content": {
|
||||
@@ -2052,6 +2101,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.AdminIDsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Record ids",
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"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": {
|
||||
"200": {
|
||||
"content": {
|
||||
@@ -3176,6 +3247,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.AdminIDsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Related record ids",
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
@@ -3288,6 +3370,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/cabana.AdminIDsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Related record ids",
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
49
admin/src/api/schema.d.ts
vendored
49
admin/src/api/schema.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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']>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
],
|
||||
})
|
||||
|
||||
39
admin/src/app/winterUrl.ts
Normal file
39
admin/src/app/winterUrl.ts
Normal 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
|
||||
}
|
||||
25
admin/src/components/form/FieldRenderer.vue
Normal file
25
admin/src/components/form/FieldRenderer.vue
Normal 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>
|
||||
35
admin/src/components/form/FormErrorBanner.vue
Normal file
35
admin/src/components/form/FormErrorBanner.vue
Normal 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>
|
||||
59
admin/src/components/form/FormField.vue
Normal file
59
admin/src/components/form/FormField.vue
Normal 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>
|
||||
48
admin/src/components/form/FormGrid.vue
Normal file
48
admin/src/components/form/FormGrid.vue
Normal 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>
|
||||
79
admin/src/components/form/fields/DropdownField.vue
Normal file
79
admin/src/components/form/fields/DropdownField.vue
Normal 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>
|
||||
39
admin/src/components/form/fields/NumberField.vue
Normal file
39
admin/src/components/form/fields/NumberField.vue
Normal 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>
|
||||
27
admin/src/components/form/fields/TextField.vue
Normal file
27
admin/src/components/form/fields/TextField.vue
Normal 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>
|
||||
31
admin/src/components/form/fields/TextareaField.vue
Normal file
31
admin/src/components/form/fields/TextareaField.vue
Normal 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>
|
||||
34
admin/src/components/form/fields/UnsupportedField.vue
Normal file
34
admin/src/components/form/fields/UnsupportedField.vue
Normal 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>
|
||||
83
admin/src/components/form/formState.ts
Normal file
83
admin/src/components/form/formState.ts
Normal 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
|
||||
}
|
||||
84
admin/src/components/form/registry.ts
Normal file
84
admin/src/components/form/registry.ts
Normal 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(' ')
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
42
admin/src/components/ui/Button.vue
Normal file
42
admin/src/components/ui/Button.vue
Normal 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>
|
||||
36
admin/src/components/ui/Toast.vue
Normal file
36
admin/src/components/ui/Toast.vue
Normal 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>
|
||||
@@ -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)
|
||||
|
||||
52
admin/src/state/useToasts.ts
Normal file
52
admin/src/state/useToasts.ts
Normal 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 }
|
||||
}
|
||||
209
admin/src/views/FormView.vue
Normal file
209
admin/src/views/FormView.vue
Normal 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>
|
||||
@@ -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 }}
|
||||
|
||||
304
admin/tests/fixtures/lang.json
vendored
Normal file
304
admin/tests/fixtures/lang.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
38
admin/tests/fixtures/widgets.form-schema.json
vendored
Normal file
38
admin/tests/fixtures/widgets.form-schema.json
vendored
Normal 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" }
|
||||
}
|
||||
35
admin/tests/fixtures/widgets.list-schema.json
vendored
35
admin/tests/fixtures/widgets.list-schema.json
vendored
@@ -8,15 +8,38 @@
|
||||
"showCheckboxes": true,
|
||||
"showSorting": true,
|
||||
"searchTerm": "",
|
||||
"toolbarButtons": ["create"],
|
||||
"toolbarButtons": [
|
||||
"create"
|
||||
],
|
||||
"columns": [
|
||||
{ "key": "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" }
|
||||
{
|
||||
"key": "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": [],
|
||||
"rowActions": [],
|
||||
"bulkActions": []
|
||||
"bulkActions": [],
|
||||
"recordUrl": "acme/demo/widgets/update/:id"
|
||||
},
|
||||
"meta": { "locale": "en" }
|
||||
"meta": {
|
||||
"locale": "en"
|
||||
}
|
||||
}
|
||||
|
||||
12
admin/tests/fixtures/widgets.record.json
vendored
Normal file
12
admin/tests/fixtures/widgets.record.json
vendored
Normal 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
87
admin/tests/helpers.ts
Normal 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 }
|
||||
}
|
||||
205
admin/tests/smoke/edit.smoke.test.ts
Normal file
205
admin/tests/smoke/edit.smoke.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
1
boardwalk/dist/assets/index-BB795npz.js
vendored
Normal file
1
boardwalk/dist/assets/index-BB795npz.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
boardwalk/dist/assets/index-BD2QIjGC.js
vendored
1
boardwalk/dist/assets/index-BD2QIjGC.js
vendored
File diff suppressed because one or more lines are too long
1
boardwalk/dist/assets/index-UTAit0wB.css
vendored
1
boardwalk/dist/assets/index-UTAit0wB.css
vendored
File diff suppressed because one or more lines are too long
1
boardwalk/dist/assets/index-VVVgyQoR.css
vendored
Normal file
1
boardwalk/dist/assets/index-VVVgyQoR.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
boardwalk/dist/index.html
vendored
4
boardwalk/dist/index.html
vendored
@@ -6,8 +6,8 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||
<title>SummerCMS</title>
|
||||
<script type="module" crossorigin src="./assets/index-BD2QIjGC.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-UTAit0wB.css">
|
||||
<script type="module" crossorigin src="./assets/index-BB795npz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-VVVgyQoR.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -66,6 +66,12 @@ type ListEnvelope[T any] struct {
|
||||
// list or form schema (D-16).
|
||||
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
|
||||
// identifies the backend user.
|
||||
type AdminLoginRequest struct {
|
||||
@@ -220,6 +226,7 @@ func AdminSettingsGet() {}
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Param code path string true "Settings code"
|
||||
// @Param body body AdminRecord true "Setting values keyed by field name"
|
||||
// @Success 200 {object} Envelope[SettingsResult]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
@@ -331,6 +338,7 @@ func AdminFilterOptions() {}
|
||||
// @Param dir query string false "Sort direction (asc or desc)"
|
||||
// @Param page query integer false "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]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
@@ -350,6 +358,7 @@ func AdminList() {}
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @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
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
@@ -368,6 +377,7 @@ func AdminCreate() {}
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param body body AdminIDsRequest true "Record ids"
|
||||
// @Success 200 {object} Envelope[BulkResult]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
@@ -405,6 +415,7 @@ func AdminShow() {}
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @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
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
@@ -481,6 +492,7 @@ func AdminRelationCandidates() {}
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id"
|
||||
// @Param name path string true "Relation name"
|
||||
// @Param body body AdminIDsRequest true "Related record ids"
|
||||
// @Success 200 {object} Envelope[RelationMutationResult]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
@@ -501,6 +513,7 @@ func AdminRelationLink() {}
|
||||
// @Param controller path string true "Controller"
|
||||
// @Param id path integer true "Owner id"
|
||||
// @Param name path string true "Relation name"
|
||||
// @Param body body AdminIDsRequest true "Related record ids"
|
||||
// @Success 200 {object} Envelope[RelationMutationResult]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
|
||||
@@ -230,6 +230,17 @@ func convertParameter(p map[string]any) map[string]any {
|
||||
}
|
||||
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 {
|
||||
out["schema"] = schema
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ form:
|
||||
error_fields:
|
||||
one: "Fix :count field 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.
|
||||
unsaved_confirm: You have unsaved changes. Discard them?
|
||||
tab_errors:
|
||||
|
||||
@@ -74,6 +74,7 @@ form:
|
||||
few: "Popraw :count pola oznaczone poniżej."
|
||||
many: "Popraw :count pól oznaczonych 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.
|
||||
unsaved_confirm: Masz niezapisane zmiany. Porzucić je?
|
||||
tab_errors:
|
||||
|
||||
Reference in New Issue
Block a user