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:
@@ -8,8 +8,12 @@ const inShell = computed(() => route.meta.shell === true)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Keyed by path, not full path: a new controller or record remounts its
|
||||
view, while list query changes (search, sort, page) keep it. -->
|
||||
<AppShell v-if="inShell">
|
||||
<RouterView />
|
||||
<RouterView v-slot="{ Component, route: current }">
|
||||
<component :is="Component" :key="current.path" />
|
||||
</RouterView>
|
||||
</AppShell>
|
||||
<RouterView v-else />
|
||||
</template>
|
||||
|
||||
49
admin/src/api/schema.d.ts
vendored
49
admin/src/api/schema.d.ts
vendored
@@ -474,7 +474,12 @@ export interface paths {
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
/** @description Setting values keyed by field name */
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.AdminRecord"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
@@ -617,6 +622,10 @@ export interface paths {
|
||||
page?: number;
|
||||
/** @description Records per page */
|
||||
per_page?: number;
|
||||
/** @description Filter values keyed by filter name, sent as filter[<name>]=<value> */
|
||||
filter?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
@@ -697,7 +706,12 @@ export interface paths {
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
/** @description Field values keyed by field name; relation fields carry ids */
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.AdminRecord"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Created */
|
||||
201: {
|
||||
@@ -776,7 +790,12 @@ export interface paths {
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
/** @description Record ids */
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.AdminIDsRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
@@ -1299,7 +1318,12 @@ export interface paths {
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
/** @description Field values keyed by field name; relation fields carry ids */
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.AdminRecord"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
@@ -1614,7 +1638,12 @@ export interface paths {
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
/** @description Related record ids */
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.AdminIDsRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
@@ -1697,7 +1726,12 @@ export interface paths {
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
/** @description Related record ids */
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["cabana.AdminIDsRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
@@ -1756,6 +1790,9 @@ export interface paths {
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
"cabana.AdminIDsRequest": {
|
||||
ids: number[];
|
||||
};
|
||||
"cabana.AdminLoginData": {
|
||||
access_token?: string;
|
||||
expires_in?: number;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// Aliases onto the generated OpenAPI schema (D-15, D-16). No API shape is
|
||||
// written by hand: every type here points at components['schemas'].
|
||||
import type { components } from './schema'
|
||||
import type { components, paths } from './schema'
|
||||
|
||||
type Schemas = components['schemas']
|
||||
type ControllerPath = paths['/{vendor}/{plugin}/{controller}']
|
||||
type FieldOptionsPath = paths['/{vendor}/{plugin}/{controller}/fields/{field}/options']
|
||||
|
||||
export type AdminLoginData = Schemas['cabana.AdminLoginData']
|
||||
export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest']
|
||||
@@ -36,3 +38,13 @@ export type LangBundle = Schemas['cabana.LangBundle']
|
||||
export type ErrorEnvelope = Schemas['cabana.ErrorEnvelope']
|
||||
/** One record: a string-keyed map read through its list or form schema. */
|
||||
export type AdminRecord = Schemas['cabana.AdminRecord']
|
||||
export type ErrorBody = Schemas['cabana.ErrorBody']
|
||||
export type SuccessMeta = Schemas['cabana.SuccessMeta']
|
||||
/** Body of bulk delete and relation link/unlink. */
|
||||
export type IDsRequest = Schemas['cabana.AdminIDsRequest']
|
||||
/** Path parameters naming one controller: vendor, plugin, controller (D-10). */
|
||||
export type ControllerParams = ControllerPath['get']['parameters']['path']
|
||||
/** The list query: search, sort, dir, page, per_page and filter[<name>]. */
|
||||
export type ListQuery = NonNullable<ControllerPath['get']['parameters']['query']>
|
||||
/** Relation field options query: search, page, per_page. */
|
||||
export type FieldOptionsQuery = NonNullable<FieldOptionsPath['get']['parameters']['query']>
|
||||
|
||||
@@ -1,18 +1,59 @@
|
||||
// UI strings (D-20). The resolved backend::lang bundle is loaded from the
|
||||
// server in a later plan; until a key is loaded, t() returns the key itself,
|
||||
// mirroring phrasebook's missing-key fallback. Placeholders use phrasebook's
|
||||
// :name syntax with :Name and :NAME casing variants (D-24).
|
||||
import { ref } from 'vue'
|
||||
// UI strings (D-20, D-24). The resolved backend::lang bundle is loaded from
|
||||
// the public GET /lang before anything else at startup. A key that is not in
|
||||
// the bundle renders as the key itself, mirroring phrasebook's missing-key
|
||||
// fallback. Every message is a CLDR form map; plural messages pick their form
|
||||
// with Intl.PluralRules for the bundle's locale and fall back to "other".
|
||||
// Placeholders use phrasebook's :name syntax with :Name and :NAME variants,
|
||||
// and interpolate() replaces them exactly like phrasebook.interpolate.
|
||||
import { readonly, ref } from 'vue'
|
||||
import { api } from '../api/client'
|
||||
import type { LangBundle, MessageForms } from '../api/types'
|
||||
|
||||
type Forms = Record<string, string>
|
||||
export type MessageParams = Record<string, string | number>
|
||||
|
||||
const bundle = ref<Record<string, Forms>>({})
|
||||
const bundle = ref<LangBundle>({})
|
||||
const locale = ref(typeof document === 'undefined' ? 'pl' : document.documentElement.lang || 'pl')
|
||||
|
||||
export function setBundle(next: Record<string, Forms>): void {
|
||||
/** The locale the loaded bundle resolved to (meta.locale of GET /lang). */
|
||||
export const currentLocale = readonly(locale)
|
||||
|
||||
export function setBundle(next: LangBundle, nextLocale?: string): void {
|
||||
bundle.value = next
|
||||
if (nextLocale) {
|
||||
locale.value = nextLocale
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = nextLocale
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function interpolate(text: string, params: Record<string, string | number> = {}): string {
|
||||
/** Loads the backend::lang bundle for the admin's locale. Public endpoint. */
|
||||
export async function loadStrings(): Promise<void> {
|
||||
const { data, response } = await api.GET('/lang')
|
||||
if (!response.ok || !data) {
|
||||
return
|
||||
}
|
||||
setBundle(data.data, data.meta.locale)
|
||||
}
|
||||
|
||||
/** Upper-cases the first code point, like phrasebook's ucfirst. */
|
||||
function ucfirst(value: string): string {
|
||||
const first = value.codePointAt(0)
|
||||
if (first === undefined) {
|
||||
return value
|
||||
}
|
||||
const head = String.fromCodePoint(first)
|
||||
return head.toUpperCase() + value.slice(head.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces :name, :Name and :NAME placeholders. Longer placeholders are
|
||||
* replaced first (stable for equal lengths), so :names never loses to :name.
|
||||
*/
|
||||
export function interpolate(text: string, params: MessageParams = {}): string {
|
||||
if (text === '') {
|
||||
return text
|
||||
}
|
||||
const replacements: Array<[string, string]> = []
|
||||
for (const [rawName, raw] of Object.entries(params)) {
|
||||
const name = rawName.replace(/^:/, '')
|
||||
@@ -20,7 +61,7 @@ export function interpolate(text: string, params: Record<string, string | number
|
||||
continue
|
||||
}
|
||||
const value = String(raw)
|
||||
replacements.push([`:${name.charAt(0).toUpperCase()}${name.slice(1)}`, value.charAt(0).toUpperCase() + value.slice(1)])
|
||||
replacements.push([`:${ucfirst(name)}`, ucfirst(value)])
|
||||
replacements.push([`:${name.toUpperCase()}`, value.toUpperCase()])
|
||||
replacements.push([`:${name}`, value])
|
||||
}
|
||||
@@ -32,11 +73,51 @@ export function interpolate(text: string, params: Record<string, string | number
|
||||
return out
|
||||
}
|
||||
|
||||
export function t(key: string, params: Record<string, string | number> = {}): string {
|
||||
const forms = bundle.value[key]
|
||||
const text = forms?.other
|
||||
const pluralRules = new Map<string, Intl.PluralRules | null>()
|
||||
|
||||
/** CLDR plural category of count in the bundle locale; "other" if unknown. */
|
||||
export function pluralCategory(count: number, forLocale: string = locale.value): string {
|
||||
let rules = pluralRules.get(forLocale)
|
||||
if (rules === undefined) {
|
||||
try {
|
||||
rules = new Intl.PluralRules(forLocale)
|
||||
} catch {
|
||||
rules = null
|
||||
}
|
||||
pluralRules.set(forLocale, rules)
|
||||
}
|
||||
return rules ? rules.select(count) : 'other'
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one message (CLDR forms). With a count the plural form is chosen
|
||||
* and :count is available to the text; params may override it.
|
||||
*/
|
||||
export function message(forms: MessageForms | null | undefined, count?: number, params: MessageParams = {}): string {
|
||||
if (!forms) {
|
||||
return ''
|
||||
}
|
||||
if (count === undefined) {
|
||||
return interpolate(forms.other ?? '', params)
|
||||
}
|
||||
const text = forms[pluralCategory(count)] ?? forms.other ?? ''
|
||||
return interpolate(text, { count, ...params })
|
||||
}
|
||||
|
||||
/** A bundle string by key; the key itself when it is not loaded. */
|
||||
export function t(key: string, params: MessageParams = {}): string {
|
||||
const text = bundle.value[key]?.other
|
||||
if (text === undefined) {
|
||||
return key
|
||||
}
|
||||
return interpolate(text, params)
|
||||
}
|
||||
|
||||
/** A plural bundle string by key; the key itself when it is not loaded. */
|
||||
export function tc(key: string, count: number, params: MessageParams = {}): string {
|
||||
const forms = bundle.value[key]
|
||||
if (!forms) {
|
||||
return key
|
||||
}
|
||||
return message(forms, count, params)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { currentUser } from '../state/useAuth'
|
||||
import { homePath } from '../state/useNavigation'
|
||||
import LoginView from '../views/LoginView.vue'
|
||||
import ListView from '../views/ListView.vue'
|
||||
import FormView from '../views/FormView.vue'
|
||||
import NotFoundView from '../views/NotFoundView.vue'
|
||||
|
||||
declare module 'vue-router' {
|
||||
@@ -43,6 +44,8 @@ export function createAdminRouter(history: RouterHistory = createWebHistory(runt
|
||||
beforeEnter: () => homePath() ?? true,
|
||||
},
|
||||
{ path: '/:vendor/:plugin/:controller', name: 'list', component: ListView, meta: { shell: true } },
|
||||
{ path: '/:vendor/:plugin/:controller/create', name: 'create', component: FormView, meta: { shell: true } },
|
||||
{ path: '/:vendor/:plugin/:controller/:id(\\d+)', name: 'record', component: FormView, meta: { shell: true } },
|
||||
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView, meta: { shell: true } },
|
||||
],
|
||||
})
|
||||
|
||||
39
admin/src/app/winterUrl.ts
Normal file
39
admin/src/app/winterUrl.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Winter-shaped URLs (config_list recordUrl, config_form redirects) mapped
|
||||
// onto the SPA's D-10 routes. The strings come from plugin YAML, so they are
|
||||
// never used verbatim: only the current controller's list, create and record
|
||||
// routes can come out (research Gap 8, T-10-20).
|
||||
//
|
||||
// <vendor>/<plugin>/<controller> -> list
|
||||
// <vendor>/<plugin>/<controller>/create -> create
|
||||
// <vendor>/<plugin>/<controller>/update/:id -> record (:id substituted)
|
||||
// anything else, or another controller -> list
|
||||
import { controllerPath, parseControllerId } from './controllerRoutes'
|
||||
|
||||
const DIGITS = /^\d+$/
|
||||
|
||||
export function mapWinterUrl(url: string | null | undefined, controllerId: string, id?: string | number | null): string {
|
||||
const params = parseControllerId(controllerId)
|
||||
const base = controllerPath(controllerId)
|
||||
if (!params || !base) {
|
||||
return '/'
|
||||
}
|
||||
const raw = (url ?? '').trim().split(/[?#]/, 1)[0] ?? ''
|
||||
const segments = raw.split('/').filter((segment) => segment !== '')
|
||||
const owner = [params.vendor, params.plugin, params.controller]
|
||||
const ownsUrl = owner.every((segment, index) => segments[index]?.toLowerCase() === segment.toLowerCase())
|
||||
if (!ownsUrl) {
|
||||
return base
|
||||
}
|
||||
const rest = segments.slice(owner.length)
|
||||
if (rest.length === 0) {
|
||||
return base
|
||||
}
|
||||
if (rest.length === 1 && rest[0] === 'create') {
|
||||
return `${base}/create`
|
||||
}
|
||||
if (rest.length === 2 && rest[0] === 'update') {
|
||||
const target = rest[1] === ':id' ? String(id ?? '') : (rest[1] ?? '')
|
||||
return DIGITS.test(target) ? `${base}/${target}` : base
|
||||
}
|
||||
return base
|
||||
}
|
||||
25
admin/src/components/form/FieldRenderer.vue
Normal file
25
admin/src/components/form/FieldRenderer.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { rendererFor, type FieldControlProps } from './registry'
|
||||
|
||||
// Picks the control for field.type from the registry (D-05) and forwards the
|
||||
// shared control props and the value update.
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>()
|
||||
|
||||
const control = computed(() => rendererFor(props.field.type))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="control"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:control-id="controlId"
|
||||
:invalid="invalid"
|
||||
:described-by="describedBy"
|
||||
:labels="labels"
|
||||
:source="source"
|
||||
@update:model-value="(value: unknown) => emit('update:modelValue', value)"
|
||||
/>
|
||||
</template>
|
||||
35
admin/src/components/form/FormErrorBanner.vue
Normal file
35
admin/src/components/form/FormErrorBanner.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { CircleAlert } from '@lucide/vue'
|
||||
import { t, tc } from '../../app/i18n'
|
||||
|
||||
// 422 banner (design screen 4): "Nie udało się zapisać. Popraw N pola…".
|
||||
// Messages of keys that are not form fields are listed here, so no server
|
||||
// message is lost.
|
||||
const props = defineProps<{ errors: Record<string, string[]>; fieldNames: string[] }>()
|
||||
|
||||
const count = computed(() => Object.keys(props.errors).length)
|
||||
const orphans = computed(() =>
|
||||
Object.entries(props.errors)
|
||||
.filter(([name]) => !props.fieldNames.includes(name))
|
||||
.flatMap(([, messages]) => messages),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="count > 0"
|
||||
role="alert"
|
||||
data-error-banner
|
||||
class="flex items-start gap-3 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger"
|
||||
>
|
||||
<CircleAlert :size="20" class="mt-px shrink-0" aria-hidden="true" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<p>
|
||||
<strong>{{ t('backend::lang.form.error_title') }}</strong>
|
||||
{{ tc('backend::lang.form.error_fields', count) }}
|
||||
</p>
|
||||
<p v-for="(text, index) in orphans" :key="index" class="text-[13px]">{{ text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
59
admin/src/components/form/FormField.vue
Normal file
59
admin/src/components/form/FormField.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { CircleAlert } from '@lucide/vue'
|
||||
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
|
||||
import FieldRenderer from './FieldRenderer.vue'
|
||||
import { ownsLabel } from './registry'
|
||||
|
||||
// One form row: label (600, red aria-hidden asterisk when required), the
|
||||
// control, the comment and the error line linked by aria-describedby.
|
||||
const props = defineProps<{
|
||||
field: FormField
|
||||
modelValue: unknown
|
||||
errors?: string[]
|
||||
labels?: RelationOption[]
|
||||
source?: ControllerParams | null
|
||||
/** Prefix for element ids, so two forms on a page never collide. */
|
||||
idPrefix?: string
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>()
|
||||
|
||||
const base = computed(() => `${props.idPrefix ?? 'field'}-${props.field.name}`)
|
||||
const controlId = computed(() => base.value)
|
||||
const commentId = computed(() => `${base.value}-comment`)
|
||||
const errorId = computed(() => `${base.value}-error`)
|
||||
const invalid = computed(() => (props.errors?.length ?? 0) > 0)
|
||||
const selfLabelled = computed(() => ownsLabel(props.field.type))
|
||||
const showComment = computed(() => !selfLabelled.value && !!props.field.comment)
|
||||
|
||||
const describedBy = computed(() =>
|
||||
[showComment.value || (selfLabelled.value && props.field.comment) ? commentId.value : '', invalid.value ? errorId.value : '']
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col gap-1.5" :data-field="field.name">
|
||||
<label v-if="!selfLabelled" :for="controlId" class="font-semibold">
|
||||
{{ field.label || field.name }}<span v-if="field.required" class="text-danger" aria-hidden="true"> *</span>
|
||||
</label>
|
||||
<FieldRenderer
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:control-id="controlId"
|
||||
:invalid="invalid"
|
||||
:described-by="describedBy"
|
||||
:labels="labels"
|
||||
:source="source"
|
||||
@update:model-value="(value: unknown) => emit('update:modelValue', value)"
|
||||
/>
|
||||
<p v-if="showComment" :id="commentId" class="text-[13px] text-muted">{{ field.comment }}</p>
|
||||
<div v-if="invalid" :id="errorId" class="flex flex-col gap-0.5 text-[13px] text-danger">
|
||||
<p v-for="(text, index) in errors" :key="index" class="flex items-center gap-1.5">
|
||||
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span>{{ text }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
48
admin/src/components/form/FormGrid.vue
Normal file
48
admin/src/components/form/FormGrid.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminRecord, ControllerParams, FormField as FormFieldSchema, RecordMeta } from '../../api/types'
|
||||
import FormField from './FormField.vue'
|
||||
|
||||
// Two-column form grid (design screen 4): span left -> column 1, right ->
|
||||
// column 2, full (Winter's default) -> the whole row, auto and row -> the next
|
||||
// free slot. Below 600px everything is one column. Gaps are 22px x 24px.
|
||||
defineProps<{
|
||||
fields: FormFieldSchema[]
|
||||
values: AdminRecord
|
||||
errors: Record<string, string[]>
|
||||
labels?: RecordMeta['labels']
|
||||
source?: ControllerParams | null
|
||||
idPrefix?: string
|
||||
}>()
|
||||
const emit = defineEmits<{ update: [name: string, value: unknown] }>()
|
||||
|
||||
function spanClass(span: string | undefined): string {
|
||||
switch (span) {
|
||||
case 'left':
|
||||
return 'min-[600px]:col-start-1'
|
||||
case 'right':
|
||||
return 'min-[600px]:col-start-2'
|
||||
case 'auto':
|
||||
case 'row':
|
||||
return ''
|
||||
default:
|
||||
return 'col-span-full'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-1 gap-x-6 gap-y-[22px] min-[600px]:grid-cols-2">
|
||||
<FormField
|
||||
v-for="field in fields"
|
||||
:key="field.name"
|
||||
:class="spanClass(field.span)"
|
||||
:field="field"
|
||||
:model-value="values[field.name]"
|
||||
:errors="errors[field.name]"
|
||||
:labels="labels?.[field.name]"
|
||||
:source="source"
|
||||
:id-prefix="idPrefix"
|
||||
@update:model-value="(value: unknown) => emit('update', field.name, value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
79
admin/src/components/form/fields/DropdownField.vue
Normal file
79
admin/src/components/form/fields/DropdownField.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import type { FormOption } from '../../../api/types'
|
||||
import { t } from '../../../app/i18n'
|
||||
import { controlClass, type FieldControlProps } from '../registry'
|
||||
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: FormOption['value']] }>()
|
||||
|
||||
const EMPTY = 'empty'
|
||||
const PLACEHOLDER = 'placeholder'
|
||||
|
||||
const options = computed(() => props.field.options ?? [])
|
||||
|
||||
function same(a: unknown, b: unknown): boolean {
|
||||
if (a === null || a === undefined || b === null || b === undefined) {
|
||||
return a === b || (a == null && b == null)
|
||||
}
|
||||
return String(a) === String(b)
|
||||
}
|
||||
|
||||
// The <select> works on indexes so option values keep their JSON type
|
||||
// (string, number or boolean) when they are sent back.
|
||||
const selected = computed(() => {
|
||||
const index = options.value.findIndex((option) => same(option.value, props.modelValue))
|
||||
if (index >= 0) {
|
||||
return String(index)
|
||||
}
|
||||
if (props.field.emptyOption !== undefined && (props.modelValue === null || props.modelValue === undefined || props.modelValue === '')) {
|
||||
return EMPTY
|
||||
}
|
||||
return PLACEHOLDER
|
||||
})
|
||||
|
||||
const muted = computed(() => selected.value === EMPTY || selected.value === PLACEHOLDER)
|
||||
|
||||
function onChange(event: Event): void {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
if (value === EMPTY) {
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
const option = options.value[Number(value)]
|
||||
if (option) {
|
||||
emit('update:modelValue', option.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<select
|
||||
:id="controlId"
|
||||
:name="field.name"
|
||||
:value="selected"
|
||||
:required="field.required || undefined"
|
||||
:aria-required="field.required ? 'true' : undefined"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
:class="[controlClass(invalid), muted ? 'text-muted' : '']"
|
||||
class="h-input appearance-none pr-10"
|
||||
@change="onChange"
|
||||
>
|
||||
<option v-if="field.emptyOption !== undefined" :value="EMPTY" class="text-muted">{{ field.emptyOption }}</option>
|
||||
<option v-else-if="selected === PLACEHOLDER" :value="PLACEHOLDER" disabled>
|
||||
{{ t('backend::lang.form.select_placeholder') }}
|
||||
</option>
|
||||
<option v-for="(option, index) in options" :key="index" :value="String(index)" class="text-text">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<ChevronDown
|
||||
:size="16"
|
||||
class="pointer-events-none absolute top-1/2 right-3.5 -translate-y-1/2 text-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
39
admin/src/components/form/fields/NumberField.vue
Normal file
39
admin/src/components/form/fields/NumberField.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { controlAttributes, controlClass, type FieldControlProps } from '../registry'
|
||||
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: number | null] }>()
|
||||
|
||||
const text = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
|
||||
const attrs = computed(() => controlAttributes(props.field))
|
||||
|
||||
// An empty input clears the value (null); anything else is sent as a number.
|
||||
function onInput(event: Event): void {
|
||||
const raw = (event.target as HTMLInputElement).value.trim()
|
||||
if (raw === '') {
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
const value = Number(raw)
|
||||
emit('update:modelValue', Number.isFinite(value) ? value : null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
:id="controlId"
|
||||
v-bind="attrs"
|
||||
:name="field.name"
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
:value="text"
|
||||
:required="field.required || undefined"
|
||||
:aria-required="field.required ? 'true' : undefined"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
:class="controlClass(invalid)"
|
||||
class="h-input tabular-nums"
|
||||
@input="onInput"
|
||||
/>
|
||||
</template>
|
||||
27
admin/src/components/form/fields/TextField.vue
Normal file
27
admin/src/components/form/fields/TextField.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { controlAttributes, controlClass, type FieldControlProps } from '../registry'
|
||||
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
|
||||
const text = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
|
||||
const attrs = computed(() => controlAttributes(props.field))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
:id="controlId"
|
||||
v-bind="attrs"
|
||||
:name="field.name"
|
||||
type="text"
|
||||
:value="text"
|
||||
:required="field.required || undefined"
|
||||
:aria-required="field.required ? 'true' : undefined"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
:class="controlClass(invalid)"
|
||||
class="h-input"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</template>
|
||||
31
admin/src/components/form/fields/TextareaField.vue
Normal file
31
admin/src/components/form/fields/TextareaField.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { controlAttributes, controlClass, type FieldControlProps } from '../registry'
|
||||
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
|
||||
// Winter textarea sizes mapped to rows; the design's Opis uses 4 (small).
|
||||
const rowsBySize: Record<string, number> = { tiny: 2, small: 4, large: 8, huge: 12, giant: 18 }
|
||||
|
||||
const rows = computed(() => rowsBySize[props.field.size ?? ''] ?? 5)
|
||||
const text = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
|
||||
const attrs = computed(() => controlAttributes(props.field))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
:id="controlId"
|
||||
v-bind="attrs"
|
||||
:name="field.name"
|
||||
:rows="rows"
|
||||
:value="text"
|
||||
:required="field.required || undefined"
|
||||
:aria-required="field.required ? 'true' : undefined"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
:class="controlClass(invalid)"
|
||||
class="min-h-input resize-y py-2.5"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
</template>
|
||||
34
admin/src/components/form/fields/UnsupportedField.vue
Normal file
34
admin/src/components/form/fields/UnsupportedField.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Puzzle } from '@lucide/vue'
|
||||
import { t } from '../../../app/i18n'
|
||||
import type { FieldControlProps } from '../registry'
|
||||
|
||||
// D-05 fallback for any field type without a renderer. The translated
|
||||
// sentence is split around the type so the type renders in DM Mono, as text.
|
||||
const props = defineProps<FieldControlProps>()
|
||||
|
||||
const MARK = '\u0000'
|
||||
|
||||
const parts = computed(() => {
|
||||
const [before = '', after = ''] = t('backend::lang.form.unsupported_field', { type: MARK }).split(MARK)
|
||||
return { before, after }
|
||||
})
|
||||
|
||||
const hasType = computed(() => t('backend::lang.form.unsupported_field', { type: MARK }).includes(MARK))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:id="controlId"
|
||||
data-unsupported-field
|
||||
:aria-describedby="describedBy || undefined"
|
||||
class="flex min-h-input items-center gap-2.5 rounded-control border-[1.5px] border-dashed border-border-strong bg-subtle px-3.5 text-[13px] text-muted"
|
||||
>
|
||||
<Puzzle :size="16" aria-hidden="true" />
|
||||
<span v-if="hasType"
|
||||
>{{ parts.before }}<code class="font-mono text-text">{{ props.field.type }}</code>{{ parts.after }}</span
|
||||
>
|
||||
<span v-else>{{ t('backend::lang.form.unsupported_field') }} <code class="font-mono text-text">{{ props.field.type }}</code></span>
|
||||
</div>
|
||||
</template>
|
||||
83
admin/src/components/form/formState.ts
Normal file
83
admin/src/components/form/formState.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// Helpers shared by the record form and the settings form: context
|
||||
// filtering, the save payload, 422 detail mapping and focus.
|
||||
import type { AdminRecord, ErrorBody, FormField } from '../../api/types'
|
||||
import { isRegistered } from './registry'
|
||||
|
||||
export type FormMode = 'create' | 'update'
|
||||
|
||||
/** Winter `context:` semantics: no context shows the field everywhere. */
|
||||
export function contextAllows(field: FormField, mode: FormMode): boolean {
|
||||
const context = field.context
|
||||
if (context === undefined || context === null) {
|
||||
return true
|
||||
}
|
||||
const values = Array.isArray(context) ? context : [context]
|
||||
return values.length === 0 || values.includes(mode)
|
||||
}
|
||||
|
||||
/**
|
||||
* The save body: values keyed by field name for every editable field that
|
||||
* has a value. Read-only fields and types without a renderer are never sent.
|
||||
*/
|
||||
export function editablePayload(fields: FormField[], values: AdminRecord): AdminRecord {
|
||||
const out: AdminRecord = {}
|
||||
for (const field of fields) {
|
||||
if (field.readOnly || !isRegistered(field.type)) {
|
||||
continue
|
||||
}
|
||||
const value = values[field.name]
|
||||
if (value !== undefined) {
|
||||
out[field.name] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Initial values of a new record: schema defaults, toggles off, no ids. */
|
||||
export function initialValues(fields: FormField[]): AdminRecord {
|
||||
const out: AdminRecord = {}
|
||||
for (const field of fields) {
|
||||
if (field.default !== undefined) {
|
||||
out[field.name] = field.default
|
||||
} else if (field.type === 'switch' || field.type === 'checkbox') {
|
||||
out[field.name] = false
|
||||
} else if (field.type === 'relation' && field.multiple) {
|
||||
out[field.name] = []
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** D-10 422 details (field -> messages) as a string-list map. */
|
||||
export function fieldErrors(details: ErrorBody['details'] | undefined): Record<string, string[]> {
|
||||
const out: Record<string, string[]> = {}
|
||||
for (const [name, value] of Object.entries(details ?? {})) {
|
||||
const messages = Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === 'string' && item !== '')
|
||||
: typeof value === 'string' && value !== ''
|
||||
? [value]
|
||||
: []
|
||||
if (messages.length > 0) {
|
||||
out[name] = messages
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Stable serialization for dirty checks. */
|
||||
export function snapshot(record: AdminRecord): string {
|
||||
return JSON.stringify(Object.keys(record).sort().map((key) => [key, record[key]]))
|
||||
}
|
||||
|
||||
/** Focuses a field's control (FormField ids are `${prefix}-${name}`). */
|
||||
export function focusField(prefix: string, name: string): boolean {
|
||||
const element = document.getElementById(`${prefix}-${name}`)
|
||||
if (!element) {
|
||||
return false
|
||||
}
|
||||
if (!element.matches('input, select, textarea, button, [tabindex]')) {
|
||||
element.setAttribute('tabindex', '-1')
|
||||
}
|
||||
element.focus()
|
||||
return true
|
||||
}
|
||||
84
admin/src/components/form/registry.ts
Normal file
84
admin/src/components/form/registry.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// Field renderer registry (D-05). A form field's `type` selects the control
|
||||
// component; any type without a renderer gets UnsupportedField, the design's
|
||||
// dashed box, so an unknown type never breaks the form. Phase 10.1 turns this
|
||||
// seam into the plugin extension point.
|
||||
import type { Component } from 'vue'
|
||||
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
|
||||
import DropdownField from './fields/DropdownField.vue'
|
||||
import NumberField from './fields/NumberField.vue'
|
||||
import TextField from './fields/TextField.vue'
|
||||
import TextareaField from './fields/TextareaField.vue'
|
||||
import UnsupportedField from './fields/UnsupportedField.vue'
|
||||
|
||||
/** Props every field control receives from FieldRenderer. */
|
||||
export interface FieldControlProps {
|
||||
field: FormField
|
||||
modelValue: unknown
|
||||
/** id of the focusable control; the label points at it. */
|
||||
controlId: string
|
||||
invalid?: boolean
|
||||
/** Space-separated ids of the comment and error lines. */
|
||||
describedBy?: string
|
||||
/** Display labels of the field's current relation values (meta.labels). */
|
||||
labels?: RelationOption[]
|
||||
/** Controller whose option endpoints serve this form; null for settings. */
|
||||
source?: ControllerParams | null
|
||||
}
|
||||
|
||||
const renderers = new Map<string, Component>([
|
||||
['text', TextField],
|
||||
['textarea', TextareaField],
|
||||
['number', NumberField],
|
||||
['dropdown', DropdownField],
|
||||
])
|
||||
|
||||
/** Types whose control shows the label itself (toggle cards). */
|
||||
const selfLabelled = new Set<string>()
|
||||
|
||||
export function rendererFor(type: string): Component {
|
||||
return renderers.get(type) ?? UnsupportedField
|
||||
}
|
||||
|
||||
/** Whether the SPA can edit values of this field type. */
|
||||
export function isRegistered(type: string): boolean {
|
||||
return renderers.has(type)
|
||||
}
|
||||
|
||||
export function ownsLabel(type: string): boolean {
|
||||
return selfLabelled.has(type)
|
||||
}
|
||||
|
||||
// Winter `attributes:` are HTML attributes on the control. Only these are
|
||||
// passed through; anything else (event handlers included) is dropped.
|
||||
const allowedAttributes = new Set([
|
||||
'readonly',
|
||||
'placeholder',
|
||||
'maxlength',
|
||||
'minlength',
|
||||
'min',
|
||||
'max',
|
||||
'step',
|
||||
'autocomplete',
|
||||
'spellcheck',
|
||||
'pattern',
|
||||
])
|
||||
|
||||
export function controlAttributes(field: FormField): Record<string, string | number | boolean> {
|
||||
const out: Record<string, string | number | boolean> = {}
|
||||
for (const [name, value] of Object.entries(field.attributes ?? {})) {
|
||||
const key = name.toLowerCase()
|
||||
if (!allowedAttributes.has(key) || value === null || value === false) {
|
||||
continue
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Shared control styling: 44px, radius 10, danger border when invalid. */
|
||||
export function controlClass(invalid: boolean | undefined): string {
|
||||
return [
|
||||
'w-full rounded-control border bg-surface px-3.5 text-text transition-colors duration-150 ease-out',
|
||||
invalid ? 'border-danger' : 'border-border-strong',
|
||||
].join(' ')
|
||||
}
|
||||
@@ -1,17 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import type { AdminRecord, ListColumn } from '../../api/types'
|
||||
import { t } from '../../app/i18n'
|
||||
|
||||
// Read-only, schema-driven table: columns come from the list schema and each
|
||||
// record is read through its column keys (D-16). Selection, sorting and
|
||||
// row links arrive with the list screens.
|
||||
defineProps<{
|
||||
// Schema-driven table: columns come from the list schema and each record is
|
||||
// read through its column keys (D-16). With rowLink a row opens its record
|
||||
// route; the first cell carries the link for keyboard users.
|
||||
const props = defineProps<{
|
||||
columns: ListColumn[]
|
||||
rows: AdminRecord[]
|
||||
loading?: boolean
|
||||
emptyText?: string
|
||||
rowLink?: (row: AdminRecord) => string | null
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function linkOf(row: AdminRecord): string | null {
|
||||
return props.rowLink ? props.rowLink(row) : null
|
||||
}
|
||||
|
||||
function openRow(event: MouseEvent, row: AdminRecord): void {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (target?.closest('a, button, input, label')) {
|
||||
return
|
||||
}
|
||||
const to = linkOf(row)
|
||||
if (to) {
|
||||
void router.push(to)
|
||||
}
|
||||
}
|
||||
|
||||
function cellText(row: AdminRecord, column: ListColumn): string {
|
||||
return formatValue(row[column.key])
|
||||
}
|
||||
@@ -68,14 +87,23 @@ function rowKey(row: AdminRecord, index: number): string {
|
||||
</td>
|
||||
</tr>
|
||||
<template v-else>
|
||||
<tr v-for="(row, index) in rows" :key="rowKey(row, index)" class="h-row border-b border-border">
|
||||
<tr
|
||||
v-for="(row, index) in rows"
|
||||
:key="rowKey(row, index)"
|
||||
:class="linkOf(row) ? 'cursor-pointer hover:bg-hover' : ''"
|
||||
class="h-row border-b border-border"
|
||||
@click="openRow($event, row)"
|
||||
>
|
||||
<td
|
||||
v-for="(column, columnIndex) in columns"
|
||||
:key="column.key"
|
||||
:class="columnIndex === 0 ? 'font-semibold' : 'text-muted'"
|
||||
class="px-3.5 first:pl-5 last:pr-5"
|
||||
>
|
||||
{{ cellText(row, column) }}
|
||||
<RouterLink v-if="columnIndex === 0 && linkOf(row)" :to="linkOf(row)!" class="text-text no-underline">
|
||||
{{ cellText(row, column) }}
|
||||
</RouterLink>
|
||||
<template v-else>{{ cellText(row, column) }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
|
||||
import { ChevronRight } from '@lucide/vue'
|
||||
import PluginRail from './PluginRail.vue'
|
||||
import SectionPanel from './SectionPanel.vue'
|
||||
import Toast from '../ui/Toast.vue'
|
||||
import { t } from '../../app/i18n'
|
||||
import { controllerIdFromPath } from '../../app/controllerRoutes'
|
||||
import { currentUser } from '../../state/useAuth'
|
||||
@@ -68,5 +69,6 @@ const initials = computed(() =>
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
<Toast />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
42
admin/src/components/ui/Button.vue
Normal file
42
admin/src/components/ui/Button.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, type Component } from 'vue'
|
||||
import { RouterLink, type RouteLocationRaw } from 'vue-router'
|
||||
|
||||
// One button for the whole admin (design tokens): primary, outline, ghost and
|
||||
// danger (outline). With `to` it renders a router link styled the same way.
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'primary' | 'outline' | 'ghost' | 'danger'
|
||||
size?: 'md' | 'sm'
|
||||
type?: 'button' | 'submit'
|
||||
disabled?: boolean
|
||||
icon?: Component
|
||||
to?: RouteLocationRaw
|
||||
}>(),
|
||||
{ variant: 'outline', size: 'md', type: 'button', disabled: false, icon: undefined, to: undefined },
|
||||
)
|
||||
|
||||
const variants: Record<NonNullable<typeof props.variant>, string> = {
|
||||
primary: 'bg-primary text-on-primary hover:opacity-90',
|
||||
outline: 'border border-border-strong bg-surface text-text hover:bg-hover',
|
||||
ghost: 'text-muted hover:bg-hover hover:text-text',
|
||||
danger: 'border border-danger bg-surface text-danger hover:bg-danger-soft',
|
||||
}
|
||||
|
||||
const classes = computed(() => [
|
||||
'inline-flex shrink-0 items-center justify-center gap-2 rounded-control px-4 font-semibold whitespace-nowrap no-underline transition-colors duration-150 ease-out disabled:cursor-not-allowed disabled:opacity-60',
|
||||
props.size === 'sm' ? 'h-[38px]' : 'h-button',
|
||||
variants[props.variant],
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink v-if="to !== undefined" :to="to" :class="classes">
|
||||
<component :is="icon" v-if="icon" :size="16" aria-hidden="true" />
|
||||
<slot />
|
||||
</RouterLink>
|
||||
<button v-else :type="type" :disabled="disabled" :class="classes">
|
||||
<component :is="icon" v-if="icon" :size="16" aria-hidden="true" />
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
36
admin/src/components/ui/Toast.vue
Normal file
36
admin/src/components/ui/Toast.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, CircleAlert, X } from '@lucide/vue'
|
||||
import { t } from '../../app/i18n'
|
||||
import { dismissToast, toasts } from '../../state/useToasts'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Bottom centre, 88px up so it clears the sticky form footer. -->
|
||||
<div class="pointer-events-none fixed inset-x-0 bottom-[88px] z-50 flex flex-col items-center gap-2 px-4">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
:role="toast.tone === 'danger' ? 'alert' : 'status'"
|
||||
:data-tone="toast.tone"
|
||||
class="pointer-events-auto flex max-w-[560px] items-center gap-3 rounded-[14px] bg-[#1d2740] py-3 pr-2.5 pl-3.5 text-white shadow-toast"
|
||||
>
|
||||
<span
|
||||
:class="toast.tone === 'danger' ? 'bg-[#f58a8a] text-[#1d2740]' : 'bg-accent text-on-accent'"
|
||||
class="flex size-[26px] shrink-0 items-center justify-center rounded-full"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<CircleAlert v-if="toast.tone === 'danger'" :size="16" />
|
||||
<Check v-else :size="16" />
|
||||
</span>
|
||||
<span class="font-medium">{{ toast.text }}</span>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('backend::lang.form.close')"
|
||||
class="flex size-8 items-center justify-center rounded-pager text-[#c3cbda] hover:bg-side-hover hover:text-white"
|
||||
@click="dismissToast(toast.id)"
|
||||
>
|
||||
<X :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,11 +2,15 @@ import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { createAdminRouter } from './app/router'
|
||||
import { onUnauthorized } from './api/client'
|
||||
import { loadStrings } from './app/i18n'
|
||||
import { clearUser, me } from './state/useAuth'
|
||||
import { loadNavigation } from './state/useNavigation'
|
||||
import './styles/main.css'
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
// Boot order (research Pattern 7): the public strings bundle first, so the
|
||||
// login screen is translated, then the session, then navigation.
|
||||
await loadStrings().catch(() => undefined)
|
||||
// A 401 here only means "not signed in"; the router guard sends the
|
||||
// visitor to the login route with the requested path as redirect.
|
||||
const user = await me().catch(() => null)
|
||||
|
||||
52
admin/src/state/useToasts.ts
Normal file
52
admin/src/state/useToasts.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Global toast queue (design: State Management). Toasts outlive route
|
||||
// changes because AppShell mounts the renderer, so "Zapisz i zamknij" can
|
||||
// show its toast on the list it returns to.
|
||||
import { readonly, ref } from 'vue'
|
||||
|
||||
export type ToastTone = 'success' | 'danger'
|
||||
|
||||
export interface ToastItem {
|
||||
id: number
|
||||
text: string
|
||||
tone: ToastTone
|
||||
}
|
||||
|
||||
/** Auto-dismiss delay in milliseconds (design: about 4 s). */
|
||||
export const TOAST_TIMEOUT = 4000
|
||||
|
||||
const items = ref<ToastItem[]>([])
|
||||
const timers = new Map<number, ReturnType<typeof setTimeout>>()
|
||||
let nextId = 1
|
||||
|
||||
export const toasts = readonly(items)
|
||||
|
||||
export function showToast(text: string, tone: ToastTone = 'success'): number {
|
||||
const id = nextId++
|
||||
items.value = [...items.value, { id, text, tone }]
|
||||
timers.set(
|
||||
id,
|
||||
setTimeout(() => dismissToast(id), TOAST_TIMEOUT),
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
export function dismissToast(id: number): void {
|
||||
const timer = timers.get(id)
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer)
|
||||
timers.delete(id)
|
||||
}
|
||||
items.value = items.value.filter((item) => item.id !== id)
|
||||
}
|
||||
|
||||
export function clearToasts(): void {
|
||||
for (const timer of timers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
timers.clear()
|
||||
items.value = []
|
||||
}
|
||||
|
||||
export function useToasts() {
|
||||
return { toasts, showToast, dismissToast, clearToasts }
|
||||
}
|
||||
209
admin/src/views/FormView.vue
Normal file
209
admin/src/views/FormView.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../api/client'
|
||||
import type { AdminRecord, ErrorEnvelope, FormView, RecordEnvelope, RecordMeta } from '../api/types'
|
||||
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
|
||||
import { message, t } from '../app/i18n'
|
||||
import { mapWinterUrl } from '../app/winterUrl'
|
||||
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
|
||||
import FormGrid from '../components/form/FormGrid.vue'
|
||||
import {
|
||||
contextAllows,
|
||||
editablePayload,
|
||||
fieldErrors,
|
||||
focusField,
|
||||
initialValues,
|
||||
snapshot,
|
||||
type FormMode,
|
||||
} from '../components/form/formState'
|
||||
import Button from '../components/ui/Button.vue'
|
||||
import { showToast } from '../state/useToasts'
|
||||
|
||||
// Schema-driven create/update form (D-05, D-09, D-10, D-18). The view is
|
||||
// keyed by route path, so one instance serves one record or one create.
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const ID_PREFIX = 'field'
|
||||
|
||||
const path = {
|
||||
vendor: String(route.params.vendor ?? ''),
|
||||
plugin: String(route.params.plugin ?? ''),
|
||||
controller: String(route.params.controller ?? ''),
|
||||
}
|
||||
const controllerId = controllerIdFromParams(path)
|
||||
const listPath = controllerPath(controllerId) ?? '/'
|
||||
const mode: FormMode = route.name === 'create' ? 'create' : 'update'
|
||||
const recordId = mode === 'update' ? Number(route.params.id) : null
|
||||
|
||||
const schema = ref<FormView | null>(null)
|
||||
const values = ref<AdminRecord>({})
|
||||
const labels = ref<RecordMeta['labels']>({})
|
||||
const errors = ref<Record<string, string[]>>({})
|
||||
const loading = ref(true)
|
||||
const failed = ref(false)
|
||||
const saving = ref(false)
|
||||
const saved = ref('')
|
||||
|
||||
const fields = computed(() => (schema.value?.fields ?? []).filter((field) => contextAllows(field, mode)))
|
||||
const fieldNames = computed(() => fields.value.map((field) => field.name))
|
||||
|
||||
/** The record's display name: the first text field's value, in schema order. */
|
||||
const recordName = computed(() => {
|
||||
const first = schema.value?.fields.find((field) => field.type === 'text')
|
||||
const value = first ? values.value[first.name] : undefined
|
||||
return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : ''
|
||||
})
|
||||
|
||||
const title = computed(() => {
|
||||
if (mode === 'update' && recordName.value !== '') {
|
||||
return recordName.value
|
||||
}
|
||||
const forms = mode === 'create' ? schema.value?.messages.create : schema.value?.messages.update
|
||||
return message(forms, undefined, { name: recordName.value })
|
||||
})
|
||||
|
||||
const subtitle = computed(() =>
|
||||
mode === 'update' ? message(schema.value?.messages.update, undefined, { name: recordName.value }) : '',
|
||||
)
|
||||
|
||||
const dirty = computed(() => snapshot(editablePayload(fields.value, values.value)) !== saved.value)
|
||||
|
||||
function adopt(record: RecordEnvelope | undefined): void {
|
||||
if (record) {
|
||||
values.value = { ...record.data }
|
||||
labels.value = record.meta.labels ?? {}
|
||||
}
|
||||
saved.value = snapshot(editablePayload(fields.value, values.value))
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
const params = { path }
|
||||
const [schemaResult, recordResult] = await Promise.all([
|
||||
api.GET('/{vendor}/{plugin}/{controller}/schema/form', { params }),
|
||||
recordId !== null
|
||||
? api.GET('/{vendor}/{plugin}/{controller}/{id}', { params: { path: { ...path, id: recordId } } })
|
||||
: Promise.resolve(null),
|
||||
])
|
||||
schema.value = schemaResult.data?.data ?? null
|
||||
failed.value = !schemaResult.data || (recordResult !== null && !recordResult.data)
|
||||
if (schema.value && mode === 'create') {
|
||||
values.value = initialValues(fields.value)
|
||||
}
|
||||
adopt(recordResult?.data)
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function update(name: string, value: unknown): void {
|
||||
values.value = { ...values.value, [name]: value }
|
||||
if (errors.value[name]) {
|
||||
const next = { ...errors.value }
|
||||
delete next[name]
|
||||
errors.value = next
|
||||
}
|
||||
}
|
||||
|
||||
async function showErrors(error: ErrorEnvelope['error'] | undefined): Promise<void> {
|
||||
errors.value = fieldErrors(error?.details)
|
||||
await nextTick()
|
||||
const first = fields.value.find((field) => errors.value[field.name])
|
||||
if (first) {
|
||||
focusField(ID_PREFIX, first.name)
|
||||
}
|
||||
}
|
||||
|
||||
function redirectTarget(kind: 'redirect' | 'redirectClose', id: unknown): string {
|
||||
const redirects = schema.value?.redirects
|
||||
const url = (redirects?.[mode]?.[kind] || redirects?.default) ?? ''
|
||||
return mapWinterUrl(url, controllerId, typeof id === 'number' || typeof id === 'string' ? id : null)
|
||||
}
|
||||
|
||||
/** Saves the record; returns the saved envelope, or null after an error. */
|
||||
async function save(): Promise<RecordEnvelope | null> {
|
||||
if (saving.value || !schema.value) {
|
||||
return null
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const body = editablePayload(fields.value, values.value)
|
||||
const result =
|
||||
recordId === null
|
||||
? await api.POST('/{vendor}/{plugin}/{controller}', { params: { path }, body })
|
||||
: await api.PUT('/{vendor}/{plugin}/{controller}/{id}', { params: { path: { ...path, id: recordId } }, body })
|
||||
if (result.data) {
|
||||
errors.value = {}
|
||||
adopt(result.data)
|
||||
showToast(message(schema.value.messages.saved, undefined, { name: recordName.value }))
|
||||
return result.data
|
||||
}
|
||||
if (result.response.status === 422) {
|
||||
await showErrors(result.error?.error)
|
||||
} else {
|
||||
showToast(result.error?.error.message || t('backend::lang.form.error_generic'), 'danger')
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||
return null
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave(): Promise<void> {
|
||||
const record = await save()
|
||||
if (record && mode === 'create') {
|
||||
await router.push(redirectTarget('redirect', record.data.id))
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ dirty })
|
||||
|
||||
void load()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto flex w-full max-w-[980px] flex-col gap-5 pb-24">
|
||||
<header class="flex items-center gap-4">
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<h1 class="truncate text-[24px] font-bold tracking-[-0.02em]">{{ loading ? '' : title }}</h1>
|
||||
<p v-if="subtitle" class="text-muted">{{ subtitle }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="failed && !loading" role="alert" class="rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger">
|
||||
{{ t('backend::lang.form.load_failed') }}
|
||||
</p>
|
||||
|
||||
<template v-else-if="schema">
|
||||
<FormErrorBanner :errors="errors" :field-names="fieldNames" />
|
||||
<form
|
||||
class="rounded-card border border-border bg-surface p-7 shadow-card"
|
||||
novalidate
|
||||
@submit.prevent="onSave"
|
||||
>
|
||||
<FormGrid
|
||||
:fields="fields"
|
||||
:values="values"
|
||||
:errors="errors"
|
||||
:labels="labels"
|
||||
:source="path"
|
||||
:id-prefix="ID_PREFIX"
|
||||
@update="update"
|
||||
/>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<footer
|
||||
class="fixed inset-x-0 bottom-0 z-30 flex items-center justify-end gap-2.5 border-t border-border bg-surface px-8 py-3.5"
|
||||
>
|
||||
<Button variant="ghost" :to="listPath">{{ t('backend::lang.form.cancel') }}</Button>
|
||||
<Button variant="primary" :disabled="saving || loading || !schema" data-action="save" @click="onSave">
|
||||
{{ saving ? t('backend::lang.form.saving') : t('backend::lang.form.save') }}
|
||||
</Button>
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -5,6 +5,7 @@ import { api } from '../api/client'
|
||||
import type { AdminRecord, ListMeta, ListSchema } from '../api/types'
|
||||
import { controllerIdFromParams } from '../app/controllerRoutes'
|
||||
import { t } from '../app/i18n'
|
||||
import { mapWinterUrl } from '../app/winterUrl'
|
||||
import DataTable from '../components/list/DataTable.vue'
|
||||
import { activeEntry } from '../state/useNavigation'
|
||||
|
||||
@@ -41,6 +42,16 @@ const range = computed(() => {
|
||||
return t('backend::lang.list.pagination_range', { from, to, total: m.total })
|
||||
})
|
||||
|
||||
/** Record route of a row when the list declares a recordUrl (D-10). */
|
||||
function rowLink(row: AdminRecord): string | null {
|
||||
const recordUrl = schema.value?.recordUrl
|
||||
const id = row.id
|
||||
if (!recordUrl || (typeof id !== 'number' && typeof id !== 'string')) {
|
||||
return null
|
||||
}
|
||||
return mapWinterUrl(recordUrl, controllerIdFromParams(path.value), id)
|
||||
}
|
||||
|
||||
let generation = 0
|
||||
|
||||
async function load(): Promise<void> {
|
||||
@@ -80,6 +91,7 @@ watch(path, load, { immediate: true, deep: true })
|
||||
:rows="rows"
|
||||
:loading="loading"
|
||||
:empty-text="schema?.noRecordsMessage"
|
||||
:row-link="rowLink"
|
||||
/>
|
||||
<footer class="border-t border-border px-5 py-3.5 text-[13px] text-muted">
|
||||
{{ loading ? t('backend::lang.list.loading') : range }}
|
||||
|
||||
Reference in New Issue
Block a user