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": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
|
"cabana.AdminIDsRequest": {
|
||||||
|
"properties": {
|
||||||
|
"ids": {
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"ids"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"cabana.AdminLoginData": {
|
"cabana.AdminLoginData": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"access_token": {
|
"access_token": {
|
||||||
@@ -1665,6 +1679,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.AdminRecord"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Setting values keyed by field name",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"content": {
|
"content": {
|
||||||
@@ -1863,6 +1888,19 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Filter values keyed by filter name, sent as filter[\u003cname\u003e]=\u003cvalue\u003e",
|
||||||
|
"explode": true,
|
||||||
|
"in": "query",
|
||||||
|
"name": "filter",
|
||||||
|
"schema": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"style": "deepObject"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -1958,6 +1996,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.AdminRecord"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Field values keyed by field name; relation fields carry ids",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"201": {
|
"201": {
|
||||||
"content": {
|
"content": {
|
||||||
@@ -2052,6 +2101,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.AdminIDsRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Record ids",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"content": {
|
"content": {
|
||||||
@@ -2840,6 +2900,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.AdminRecord"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Field values keyed by field name; relation fields carry ids",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"content": {
|
"content": {
|
||||||
@@ -3176,6 +3247,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.AdminIDsRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Related record ids",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"content": {
|
"content": {
|
||||||
@@ -3288,6 +3370,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.AdminIDsRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Related record ids",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"content": {
|
"content": {
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ const inShell = computed(() => route.meta.shell === true)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- Keyed by path, not full path: a new controller or record remounts its
|
||||||
|
view, while list query changes (search, sort, page) keep it. -->
|
||||||
<AppShell v-if="inShell">
|
<AppShell v-if="inShell">
|
||||||
<RouterView />
|
<RouterView v-slot="{ Component, route: current }">
|
||||||
|
<component :is="Component" :key="current.path" />
|
||||||
|
</RouterView>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
<RouterView v-else />
|
<RouterView v-else />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
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;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody?: never;
|
/** @description Setting values keyed by field name */
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.AdminRecord"];
|
||||||
|
};
|
||||||
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description OK */
|
/** @description OK */
|
||||||
200: {
|
200: {
|
||||||
@@ -617,6 +622,10 @@ export interface paths {
|
|||||||
page?: number;
|
page?: number;
|
||||||
/** @description Records per page */
|
/** @description Records per page */
|
||||||
per_page?: number;
|
per_page?: number;
|
||||||
|
/** @description Filter values keyed by filter name, sent as filter[<name>]=<value> */
|
||||||
|
filter?: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path: {
|
path: {
|
||||||
@@ -697,7 +706,12 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody?: never;
|
/** @description Field values keyed by field name; relation fields carry ids */
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.AdminRecord"];
|
||||||
|
};
|
||||||
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Created */
|
/** @description Created */
|
||||||
201: {
|
201: {
|
||||||
@@ -776,7 +790,12 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody?: never;
|
/** @description Record ids */
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.AdminIDsRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description OK */
|
/** @description OK */
|
||||||
200: {
|
200: {
|
||||||
@@ -1299,7 +1318,12 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody?: never;
|
/** @description Field values keyed by field name; relation fields carry ids */
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.AdminRecord"];
|
||||||
|
};
|
||||||
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description OK */
|
/** @description OK */
|
||||||
200: {
|
200: {
|
||||||
@@ -1614,7 +1638,12 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody?: never;
|
/** @description Related record ids */
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.AdminIDsRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description OK */
|
/** @description OK */
|
||||||
200: {
|
200: {
|
||||||
@@ -1697,7 +1726,12 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody?: never;
|
/** @description Related record ids */
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.AdminIDsRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description OK */
|
/** @description OK */
|
||||||
200: {
|
200: {
|
||||||
@@ -1756,6 +1790,9 @@ export interface paths {
|
|||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
"cabana.AdminIDsRequest": {
|
||||||
|
ids: number[];
|
||||||
|
};
|
||||||
"cabana.AdminLoginData": {
|
"cabana.AdminLoginData": {
|
||||||
access_token?: string;
|
access_token?: string;
|
||||||
expires_in?: number;
|
expires_in?: number;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
// Aliases onto the generated OpenAPI schema (D-15, D-16). No API shape is
|
// Aliases onto the generated OpenAPI schema (D-15, D-16). No API shape is
|
||||||
// written by hand: every type here points at components['schemas'].
|
// written by hand: every type here points at components['schemas'].
|
||||||
import type { components } from './schema'
|
import type { components, paths } from './schema'
|
||||||
|
|
||||||
type Schemas = components['schemas']
|
type Schemas = components['schemas']
|
||||||
|
type ControllerPath = paths['/{vendor}/{plugin}/{controller}']
|
||||||
|
type FieldOptionsPath = paths['/{vendor}/{plugin}/{controller}/fields/{field}/options']
|
||||||
|
|
||||||
export type AdminLoginData = Schemas['cabana.AdminLoginData']
|
export type AdminLoginData = Schemas['cabana.AdminLoginData']
|
||||||
export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest']
|
export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest']
|
||||||
@@ -36,3 +38,13 @@ export type LangBundle = Schemas['cabana.LangBundle']
|
|||||||
export type ErrorEnvelope = Schemas['cabana.ErrorEnvelope']
|
export type ErrorEnvelope = Schemas['cabana.ErrorEnvelope']
|
||||||
/** One record: a string-keyed map read through its list or form schema. */
|
/** One record: a string-keyed map read through its list or form schema. */
|
||||||
export type AdminRecord = Schemas['cabana.AdminRecord']
|
export type AdminRecord = Schemas['cabana.AdminRecord']
|
||||||
|
export type ErrorBody = Schemas['cabana.ErrorBody']
|
||||||
|
export type SuccessMeta = Schemas['cabana.SuccessMeta']
|
||||||
|
/** Body of bulk delete and relation link/unlink. */
|
||||||
|
export type IDsRequest = Schemas['cabana.AdminIDsRequest']
|
||||||
|
/** Path parameters naming one controller: vendor, plugin, controller (D-10). */
|
||||||
|
export type ControllerParams = ControllerPath['get']['parameters']['path']
|
||||||
|
/** The list query: search, sort, dir, page, per_page and filter[<name>]. */
|
||||||
|
export type ListQuery = NonNullable<ControllerPath['get']['parameters']['query']>
|
||||||
|
/** Relation field options query: search, page, per_page. */
|
||||||
|
export type FieldOptionsQuery = NonNullable<FieldOptionsPath['get']['parameters']['query']>
|
||||||
|
|||||||
@@ -1,18 +1,59 @@
|
|||||||
// UI strings (D-20). The resolved backend::lang bundle is loaded from the
|
// UI strings (D-20, D-24). The resolved backend::lang bundle is loaded from
|
||||||
// server in a later plan; until a key is loaded, t() returns the key itself,
|
// the public GET /lang before anything else at startup. A key that is not in
|
||||||
// mirroring phrasebook's missing-key fallback. Placeholders use phrasebook's
|
// the bundle renders as the key itself, mirroring phrasebook's missing-key
|
||||||
// :name syntax with :Name and :NAME casing variants (D-24).
|
// fallback. Every message is a CLDR form map; plural messages pick their form
|
||||||
import { ref } from 'vue'
|
// with Intl.PluralRules for the bundle's locale and fall back to "other".
|
||||||
|
// Placeholders use phrasebook's :name syntax with :Name and :NAME variants,
|
||||||
|
// and interpolate() replaces them exactly like phrasebook.interpolate.
|
||||||
|
import { readonly, ref } from 'vue'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
import type { LangBundle, MessageForms } from '../api/types'
|
||||||
|
|
||||||
type Forms = Record<string, string>
|
export type MessageParams = Record<string, string | number>
|
||||||
|
|
||||||
const bundle = ref<Record<string, Forms>>({})
|
const bundle = ref<LangBundle>({})
|
||||||
|
const locale = ref(typeof document === 'undefined' ? 'pl' : document.documentElement.lang || 'pl')
|
||||||
|
|
||||||
export function setBundle(next: Record<string, Forms>): void {
|
/** The locale the loaded bundle resolved to (meta.locale of GET /lang). */
|
||||||
|
export const currentLocale = readonly(locale)
|
||||||
|
|
||||||
|
export function setBundle(next: LangBundle, nextLocale?: string): void {
|
||||||
bundle.value = next
|
bundle.value = next
|
||||||
|
if (nextLocale) {
|
||||||
|
locale.value = nextLocale
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement.lang = nextLocale
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function interpolate(text: string, params: Record<string, string | number> = {}): string {
|
/** Loads the backend::lang bundle for the admin's locale. Public endpoint. */
|
||||||
|
export async function loadStrings(): Promise<void> {
|
||||||
|
const { data, response } = await api.GET('/lang')
|
||||||
|
if (!response.ok || !data) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBundle(data.data, data.meta.locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upper-cases the first code point, like phrasebook's ucfirst. */
|
||||||
|
function ucfirst(value: string): string {
|
||||||
|
const first = value.codePointAt(0)
|
||||||
|
if (first === undefined) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const head = String.fromCodePoint(first)
|
||||||
|
return head.toUpperCase() + value.slice(head.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces :name, :Name and :NAME placeholders. Longer placeholders are
|
||||||
|
* replaced first (stable for equal lengths), so :names never loses to :name.
|
||||||
|
*/
|
||||||
|
export function interpolate(text: string, params: MessageParams = {}): string {
|
||||||
|
if (text === '') {
|
||||||
|
return text
|
||||||
|
}
|
||||||
const replacements: Array<[string, string]> = []
|
const replacements: Array<[string, string]> = []
|
||||||
for (const [rawName, raw] of Object.entries(params)) {
|
for (const [rawName, raw] of Object.entries(params)) {
|
||||||
const name = rawName.replace(/^:/, '')
|
const name = rawName.replace(/^:/, '')
|
||||||
@@ -20,7 +61,7 @@ export function interpolate(text: string, params: Record<string, string | number
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const value = String(raw)
|
const value = String(raw)
|
||||||
replacements.push([`:${name.charAt(0).toUpperCase()}${name.slice(1)}`, value.charAt(0).toUpperCase() + value.slice(1)])
|
replacements.push([`:${ucfirst(name)}`, ucfirst(value)])
|
||||||
replacements.push([`:${name.toUpperCase()}`, value.toUpperCase()])
|
replacements.push([`:${name.toUpperCase()}`, value.toUpperCase()])
|
||||||
replacements.push([`:${name}`, value])
|
replacements.push([`:${name}`, value])
|
||||||
}
|
}
|
||||||
@@ -32,11 +73,51 @@ export function interpolate(text: string, params: Record<string, string | number
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
export function t(key: string, params: Record<string, string | number> = {}): string {
|
const pluralRules = new Map<string, Intl.PluralRules | null>()
|
||||||
const forms = bundle.value[key]
|
|
||||||
const text = forms?.other
|
/** CLDR plural category of count in the bundle locale; "other" if unknown. */
|
||||||
|
export function pluralCategory(count: number, forLocale: string = locale.value): string {
|
||||||
|
let rules = pluralRules.get(forLocale)
|
||||||
|
if (rules === undefined) {
|
||||||
|
try {
|
||||||
|
rules = new Intl.PluralRules(forLocale)
|
||||||
|
} catch {
|
||||||
|
rules = null
|
||||||
|
}
|
||||||
|
pluralRules.set(forLocale, rules)
|
||||||
|
}
|
||||||
|
return rules ? rules.select(count) : 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders one message (CLDR forms). With a count the plural form is chosen
|
||||||
|
* and :count is available to the text; params may override it.
|
||||||
|
*/
|
||||||
|
export function message(forms: MessageForms | null | undefined, count?: number, params: MessageParams = {}): string {
|
||||||
|
if (!forms) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
if (count === undefined) {
|
||||||
|
return interpolate(forms.other ?? '', params)
|
||||||
|
}
|
||||||
|
const text = forms[pluralCategory(count)] ?? forms.other ?? ''
|
||||||
|
return interpolate(text, { count, ...params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bundle string by key; the key itself when it is not loaded. */
|
||||||
|
export function t(key: string, params: MessageParams = {}): string {
|
||||||
|
const text = bundle.value[key]?.other
|
||||||
if (text === undefined) {
|
if (text === undefined) {
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
return interpolate(text, params)
|
return interpolate(text, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A plural bundle string by key; the key itself when it is not loaded. */
|
||||||
|
export function tc(key: string, count: number, params: MessageParams = {}): string {
|
||||||
|
const forms = bundle.value[key]
|
||||||
|
if (!forms) {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return message(forms, count, params)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { currentUser } from '../state/useAuth'
|
|||||||
import { homePath } from '../state/useNavigation'
|
import { homePath } from '../state/useNavigation'
|
||||||
import LoginView from '../views/LoginView.vue'
|
import LoginView from '../views/LoginView.vue'
|
||||||
import ListView from '../views/ListView.vue'
|
import ListView from '../views/ListView.vue'
|
||||||
|
import FormView from '../views/FormView.vue'
|
||||||
import NotFoundView from '../views/NotFoundView.vue'
|
import NotFoundView from '../views/NotFoundView.vue'
|
||||||
|
|
||||||
declare module 'vue-router' {
|
declare module 'vue-router' {
|
||||||
@@ -43,6 +44,8 @@ export function createAdminRouter(history: RouterHistory = createWebHistory(runt
|
|||||||
beforeEnter: () => homePath() ?? true,
|
beforeEnter: () => homePath() ?? true,
|
||||||
},
|
},
|
||||||
{ path: '/:vendor/:plugin/:controller', name: 'list', component: ListView, meta: { shell: true } },
|
{ path: '/:vendor/:plugin/:controller', name: 'list', component: ListView, meta: { shell: true } },
|
||||||
|
{ path: '/:vendor/:plugin/:controller/create', name: 'create', component: FormView, meta: { shell: true } },
|
||||||
|
{ path: '/:vendor/:plugin/:controller/:id(\\d+)', name: 'record', component: FormView, meta: { shell: true } },
|
||||||
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView, meta: { shell: true } },
|
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView, meta: { shell: true } },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
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">
|
<script setup lang="ts">
|
||||||
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
import type { AdminRecord, ListColumn } from '../../api/types'
|
import type { AdminRecord, ListColumn } from '../../api/types'
|
||||||
import { t } from '../../app/i18n'
|
import { t } from '../../app/i18n'
|
||||||
|
|
||||||
// Read-only, schema-driven table: columns come from the list schema and each
|
// Schema-driven table: columns come from the list schema and each record is
|
||||||
// record is read through its column keys (D-16). Selection, sorting and
|
// read through its column keys (D-16). With rowLink a row opens its record
|
||||||
// row links arrive with the list screens.
|
// route; the first cell carries the link for keyboard users.
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
columns: ListColumn[]
|
columns: ListColumn[]
|
||||||
rows: AdminRecord[]
|
rows: AdminRecord[]
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
emptyText?: string
|
emptyText?: string
|
||||||
|
rowLink?: (row: AdminRecord) => string | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
function linkOf(row: AdminRecord): string | null {
|
||||||
|
return props.rowLink ? props.rowLink(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRow(event: MouseEvent, row: AdminRecord): void {
|
||||||
|
const target = event.target as HTMLElement | null
|
||||||
|
if (target?.closest('a, button, input, label')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const to = linkOf(row)
|
||||||
|
if (to) {
|
||||||
|
void router.push(to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function cellText(row: AdminRecord, column: ListColumn): string {
|
function cellText(row: AdminRecord, column: ListColumn): string {
|
||||||
return formatValue(row[column.key])
|
return formatValue(row[column.key])
|
||||||
}
|
}
|
||||||
@@ -68,14 +87,23 @@ function rowKey(row: AdminRecord, index: number): string {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<tr v-for="(row, index) in rows" :key="rowKey(row, index)" class="h-row border-b border-border">
|
<tr
|
||||||
|
v-for="(row, index) in rows"
|
||||||
|
:key="rowKey(row, index)"
|
||||||
|
:class="linkOf(row) ? 'cursor-pointer hover:bg-hover' : ''"
|
||||||
|
class="h-row border-b border-border"
|
||||||
|
@click="openRow($event, row)"
|
||||||
|
>
|
||||||
<td
|
<td
|
||||||
v-for="(column, columnIndex) in columns"
|
v-for="(column, columnIndex) in columns"
|
||||||
:key="column.key"
|
:key="column.key"
|
||||||
:class="columnIndex === 0 ? 'font-semibold' : 'text-muted'"
|
:class="columnIndex === 0 ? 'font-semibold' : 'text-muted'"
|
||||||
class="px-3.5 first:pl-5 last:pr-5"
|
class="px-3.5 first:pl-5 last:pr-5"
|
||||||
>
|
>
|
||||||
{{ cellText(row, column) }}
|
<RouterLink v-if="columnIndex === 0 && linkOf(row)" :to="linkOf(row)!" class="text-text no-underline">
|
||||||
|
{{ cellText(row, column) }}
|
||||||
|
</RouterLink>
|
||||||
|
<template v-else>{{ cellText(row, column) }}</template>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
|
|||||||
import { ChevronRight } from '@lucide/vue'
|
import { ChevronRight } from '@lucide/vue'
|
||||||
import PluginRail from './PluginRail.vue'
|
import PluginRail from './PluginRail.vue'
|
||||||
import SectionPanel from './SectionPanel.vue'
|
import SectionPanel from './SectionPanel.vue'
|
||||||
|
import Toast from '../ui/Toast.vue'
|
||||||
import { t } from '../../app/i18n'
|
import { t } from '../../app/i18n'
|
||||||
import { controllerIdFromPath } from '../../app/controllerRoutes'
|
import { controllerIdFromPath } from '../../app/controllerRoutes'
|
||||||
import { currentUser } from '../../state/useAuth'
|
import { currentUser } from '../../state/useAuth'
|
||||||
@@ -68,5 +69,6 @@ const initials = computed(() =>
|
|||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<Toast />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
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 App from './App.vue'
|
||||||
import { createAdminRouter } from './app/router'
|
import { createAdminRouter } from './app/router'
|
||||||
import { onUnauthorized } from './api/client'
|
import { onUnauthorized } from './api/client'
|
||||||
|
import { loadStrings } from './app/i18n'
|
||||||
import { clearUser, me } from './state/useAuth'
|
import { clearUser, me } from './state/useAuth'
|
||||||
import { loadNavigation } from './state/useNavigation'
|
import { loadNavigation } from './state/useNavigation'
|
||||||
import './styles/main.css'
|
import './styles/main.css'
|
||||||
|
|
||||||
async function boot(): Promise<void> {
|
async function boot(): Promise<void> {
|
||||||
|
// Boot order (research Pattern 7): the public strings bundle first, so the
|
||||||
|
// login screen is translated, then the session, then navigation.
|
||||||
|
await loadStrings().catch(() => undefined)
|
||||||
// A 401 here only means "not signed in"; the router guard sends the
|
// A 401 here only means "not signed in"; the router guard sends the
|
||||||
// visitor to the login route with the requested path as redirect.
|
// visitor to the login route with the requested path as redirect.
|
||||||
const user = await me().catch(() => null)
|
const user = await me().catch(() => null)
|
||||||
|
|||||||
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 type { AdminRecord, ListMeta, ListSchema } from '../api/types'
|
||||||
import { controllerIdFromParams } from '../app/controllerRoutes'
|
import { controllerIdFromParams } from '../app/controllerRoutes'
|
||||||
import { t } from '../app/i18n'
|
import { t } from '../app/i18n'
|
||||||
|
import { mapWinterUrl } from '../app/winterUrl'
|
||||||
import DataTable from '../components/list/DataTable.vue'
|
import DataTable from '../components/list/DataTable.vue'
|
||||||
import { activeEntry } from '../state/useNavigation'
|
import { activeEntry } from '../state/useNavigation'
|
||||||
|
|
||||||
@@ -41,6 +42,16 @@ const range = computed(() => {
|
|||||||
return t('backend::lang.list.pagination_range', { from, to, total: m.total })
|
return t('backend::lang.list.pagination_range', { from, to, total: m.total })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Record route of a row when the list declares a recordUrl (D-10). */
|
||||||
|
function rowLink(row: AdminRecord): string | null {
|
||||||
|
const recordUrl = schema.value?.recordUrl
|
||||||
|
const id = row.id
|
||||||
|
if (!recordUrl || (typeof id !== 'number' && typeof id !== 'string')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return mapWinterUrl(recordUrl, controllerIdFromParams(path.value), id)
|
||||||
|
}
|
||||||
|
|
||||||
let generation = 0
|
let generation = 0
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
@@ -80,6 +91,7 @@ watch(path, load, { immediate: true, deep: true })
|
|||||||
:rows="rows"
|
:rows="rows"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:empty-text="schema?.noRecordsMessage"
|
:empty-text="schema?.noRecordsMessage"
|
||||||
|
:row-link="rowLink"
|
||||||
/>
|
/>
|
||||||
<footer class="border-t border-border px-5 py-3.5 text-[13px] text-muted">
|
<footer class="border-t border-border px-5 py-3.5 text-[13px] text-muted">
|
||||||
{{ loading ? t('backend::lang.list.loading') : range }}
|
{{ loading ? t('backend::lang.list.loading') : range }}
|
||||||
|
|||||||
304
admin/tests/fixtures/lang.json
vendored
Normal file
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,
|
"showCheckboxes": true,
|
||||||
"showSorting": true,
|
"showSorting": true,
|
||||||
"searchTerm": "",
|
"searchTerm": "",
|
||||||
"toolbarButtons": ["create"],
|
"toolbarButtons": [
|
||||||
|
"create"
|
||||||
|
],
|
||||||
"columns": [
|
"columns": [
|
||||||
{ "key": "name", "label": "Name", "searchable": true, "sortable": true },
|
{
|
||||||
{ "key": "code", "label": "Code", "searchable": true, "sortable": true },
|
"key": "name",
|
||||||
{ "key": "tags", "label": "Tags", "searchable": false, "sortable": false, "type": "relation", "relation": "tags", "select": "name" }
|
"label": "Name",
|
||||||
|
"searchable": true,
|
||||||
|
"sortable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "code",
|
||||||
|
"label": "Code",
|
||||||
|
"searchable": true,
|
||||||
|
"sortable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "tags",
|
||||||
|
"label": "Tags",
|
||||||
|
"searchable": false,
|
||||||
|
"sortable": false,
|
||||||
|
"type": "relation",
|
||||||
|
"relation": "tags",
|
||||||
|
"select": "name"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"filters": [],
|
"filters": [],
|
||||||
"rowActions": [],
|
"rowActions": [],
|
||||||
"bulkActions": []
|
"bulkActions": [],
|
||||||
|
"recordUrl": "acme/demo/widgets/update/:id"
|
||||||
},
|
},
|
||||||
"meta": { "locale": "en" }
|
"meta": {
|
||||||
|
"locale": "en"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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="robots" content="noindex, nofollow" />
|
||||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||||
<title>SummerCMS</title>
|
<title>SummerCMS</title>
|
||||||
<script type="module" crossorigin src="./assets/index-BD2QIjGC.js"></script>
|
<script type="module" crossorigin src="./assets/index-BB795npz.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-UTAit0wB.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-VVVgyQoR.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ type ListEnvelope[T any] struct {
|
|||||||
// list or form schema (D-16).
|
// list or form schema (D-16).
|
||||||
type AdminRecord map[string]any
|
type AdminRecord map[string]any
|
||||||
|
|
||||||
|
// AdminIDsRequest is the body of the id-list writes: bulk delete, relation
|
||||||
|
// link and relation unlink.
|
||||||
|
type AdminIDsRequest struct {
|
||||||
|
IDs []uint64 `json:"ids"`
|
||||||
|
}
|
||||||
|
|
||||||
// AdminLoginRequest is the admin login body. Either login or email
|
// AdminLoginRequest is the admin login body. Either login or email
|
||||||
// identifies the backend user.
|
// identifies the backend user.
|
||||||
type AdminLoginRequest struct {
|
type AdminLoginRequest struct {
|
||||||
@@ -220,6 +226,7 @@ func AdminSettingsGet() {}
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Security BackendBearer
|
// @Security BackendBearer
|
||||||
// @Param code path string true "Settings code"
|
// @Param code path string true "Settings code"
|
||||||
|
// @Param body body AdminRecord true "Setting values keyed by field name"
|
||||||
// @Success 200 {object} Envelope[SettingsResult]
|
// @Success 200 {object} Envelope[SettingsResult]
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
@@ -331,6 +338,7 @@ func AdminFilterOptions() {}
|
|||||||
// @Param dir query string false "Sort direction (asc or desc)"
|
// @Param dir query string false "Sort direction (asc or desc)"
|
||||||
// @Param page query integer false "Page"
|
// @Param page query integer false "Page"
|
||||||
// @Param per_page query integer false "Records per page"
|
// @Param per_page query integer false "Records per page"
|
||||||
|
// @Param filter query object false "Filter values keyed by filter name, sent as filter[<name>]=<value>"
|
||||||
// @Success 200 {object} ListEnvelope[[]AdminRecord]
|
// @Success 200 {object} ListEnvelope[[]AdminRecord]
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
@@ -350,6 +358,7 @@ func AdminList() {}
|
|||||||
// @Param vendor path string true "Vendor"
|
// @Param vendor path string true "Vendor"
|
||||||
// @Param plugin path string true "Plugin"
|
// @Param plugin path string true "Plugin"
|
||||||
// @Param controller path string true "Controller"
|
// @Param controller path string true "Controller"
|
||||||
|
// @Param body body AdminRecord true "Field values keyed by field name; relation fields carry ids"
|
||||||
// @Success 201 {object} RecordEnvelope
|
// @Success 201 {object} RecordEnvelope
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
@@ -368,6 +377,7 @@ func AdminCreate() {}
|
|||||||
// @Param vendor path string true "Vendor"
|
// @Param vendor path string true "Vendor"
|
||||||
// @Param plugin path string true "Plugin"
|
// @Param plugin path string true "Plugin"
|
||||||
// @Param controller path string true "Controller"
|
// @Param controller path string true "Controller"
|
||||||
|
// @Param body body AdminIDsRequest true "Record ids"
|
||||||
// @Success 200 {object} Envelope[BulkResult]
|
// @Success 200 {object} Envelope[BulkResult]
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
@@ -405,6 +415,7 @@ func AdminShow() {}
|
|||||||
// @Param plugin path string true "Plugin"
|
// @Param plugin path string true "Plugin"
|
||||||
// @Param controller path string true "Controller"
|
// @Param controller path string true "Controller"
|
||||||
// @Param id path integer true "Record id"
|
// @Param id path integer true "Record id"
|
||||||
|
// @Param body body AdminRecord true "Field values keyed by field name; relation fields carry ids"
|
||||||
// @Success 200 {object} RecordEnvelope
|
// @Success 200 {object} RecordEnvelope
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
@@ -481,6 +492,7 @@ func AdminRelationCandidates() {}
|
|||||||
// @Param controller path string true "Controller"
|
// @Param controller path string true "Controller"
|
||||||
// @Param id path integer true "Owner id"
|
// @Param id path integer true "Owner id"
|
||||||
// @Param name path string true "Relation name"
|
// @Param name path string true "Relation name"
|
||||||
|
// @Param body body AdminIDsRequest true "Related record ids"
|
||||||
// @Success 200 {object} Envelope[RelationMutationResult]
|
// @Success 200 {object} Envelope[RelationMutationResult]
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
@@ -501,6 +513,7 @@ func AdminRelationLink() {}
|
|||||||
// @Param controller path string true "Controller"
|
// @Param controller path string true "Controller"
|
||||||
// @Param id path integer true "Owner id"
|
// @Param id path integer true "Owner id"
|
||||||
// @Param name path string true "Relation name"
|
// @Param name path string true "Relation name"
|
||||||
|
// @Param body body AdminIDsRequest true "Related record ids"
|
||||||
// @Success 200 {object} Envelope[RelationMutationResult]
|
// @Success 200 {object} Envelope[RelationMutationResult]
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 403 {object} ErrorEnvelope
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
|||||||
@@ -230,6 +230,17 @@ func convertParameter(p map[string]any) map[string]any {
|
|||||||
}
|
}
|
||||||
out[k] = v
|
out[k] = v
|
||||||
}
|
}
|
||||||
|
// Swagger 2.0 has no object query parameters; swag emits `type: object`
|
||||||
|
// for one. cabana reads such a parameter as bracketed keys
|
||||||
|
// (filter[<name>]=<value>), which is OpenAPI 3's deepObject style with
|
||||||
|
// string values.
|
||||||
|
if in, _ := out["in"].(string); in == "query" && schema["type"] == "object" {
|
||||||
|
if _, ok := schema["additionalProperties"]; !ok {
|
||||||
|
schema["additionalProperties"] = map[string]any{"type": "string"}
|
||||||
|
}
|
||||||
|
out["style"] = "deepObject"
|
||||||
|
out["explode"] = true
|
||||||
|
}
|
||||||
if len(schema) > 0 {
|
if len(schema) > 0 {
|
||||||
out["schema"] = schema
|
out["schema"] = schema
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ form:
|
|||||||
error_fields:
|
error_fields:
|
||||||
one: "Fix :count field marked below."
|
one: "Fix :count field marked below."
|
||||||
other: "Fix :count fields marked below."
|
other: "Fix :count fields marked below."
|
||||||
|
load_failed: The form could not be loaded.
|
||||||
error_generic: The changes could not be saved. Please try again.
|
error_generic: The changes could not be saved. Please try again.
|
||||||
unsaved_confirm: You have unsaved changes. Discard them?
|
unsaved_confirm: You have unsaved changes. Discard them?
|
||||||
tab_errors:
|
tab_errors:
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ form:
|
|||||||
few: "Popraw :count pola oznaczone poniżej."
|
few: "Popraw :count pola oznaczone poniżej."
|
||||||
many: "Popraw :count pól oznaczonych poniżej."
|
many: "Popraw :count pól oznaczonych poniżej."
|
||||||
other: "Popraw :count pola oznaczonego poniżej."
|
other: "Popraw :count pola oznaczonego poniżej."
|
||||||
|
load_failed: Nie udało się wczytać formularza.
|
||||||
error_generic: Nie udało się zapisać zmian. Spróbuj ponownie.
|
error_generic: Nie udało się zapisać zmian. Spróbuj ponownie.
|
||||||
unsaved_confirm: Masz niezapisane zmiany. Porzucić je?
|
unsaved_confirm: Masz niezapisane zmiany. Porzucić je?
|
||||||
tab_errors:
|
tab_errors:
|
||||||
|
|||||||
Reference in New Issue
Block a user