feat(12.1-02): read-only preview screen with a status hint and record actions

- config_form.yaml preview block (optional headerPartial), reported in the form schema as preview
- fields with context: preview show only on the preview screen and are never written
- form messages preview and edit; recordActions without a preview block stops boot
- SPA route {id}/preview, PreviewView and PreviewField, record actions in the footer
- mapWinterUrl maps preview/:id; the update form returns to the preview
- summer-callout partial style classes for status hints
- README, docs, OpenAPI document, TS types and the embedded build updated
This commit is contained in:
Jakub Zych
2026-10-05 00:10:48 +02:00
parent 1c99de5013
commit a65c670574
50 changed files with 1441 additions and 66 deletions

View File

@@ -1214,7 +1214,10 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Admin form schema */
/**
* Admin form schema
* @description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint.
*/
get: {
parameters: {
query?: never;
@@ -4469,6 +4472,9 @@ export interface components {
create: components["schemas"]["cabana.MessageForms"];
deleteConfirm: components["schemas"]["cabana.MessageForms"];
deleted: components["schemas"]["cabana.MessageForms"];
edit: components["schemas"]["cabana.MessageForms"];
/** @description The preview screen's subtitle and its edit button (D-11). */
preview: components["schemas"]["cabana.MessageForms"];
saved: components["schemas"]["cabana.MessageForms"];
update: components["schemas"]["cabana.MessageForms"];
};
@@ -4479,6 +4485,9 @@ export interface components {
label: string;
value: components["schemas"]["cabana.jsonScalar"];
};
"cabana.FormPreview": {
headerPartial?: string;
};
"cabana.FormRedirect": {
redirect: string;
redirectClose: string;
@@ -4500,6 +4509,11 @@ export interface components {
meta: components["schemas"]["cabana.FormMeta"];
modelClass?: string;
name?: string;
/**
* @description Preview is set when the form has a preview screen (D-11); a form
* without one omits the key.
*/
preview?: components["schemas"]["cabana.FormPreview"];
/**
* @description Redirects are the raw Winter config_form.yaml targets; the SPA maps
* them onto its routes.

View File

@@ -24,6 +24,8 @@ export type FormField = Schemas['cabana.FormField']
export type FormOption = Schemas['cabana.FormOption']
export type FormMessages = Schemas['cabana.FormMessages']
export type FormRedirects = Schemas['cabana.FormRedirects']
/** A form's preview screen: present when config_form.yaml declares `preview:`. */
export type FormPreview = Schemas['cabana.FormPreview']
export type RelationSchema = Schemas['cabana.RelationSchema']
export type RelationMessages = Schemas['cabana.RelationMessages']
export type RelationMutationResult = Schemas['cabana.RelationMutationResult']

View File

@@ -7,6 +7,7 @@ import { homePath } from '../state/useNavigation'
import LoginView from '../views/LoginView.vue'
import ListView from '../views/ListView.vue'
import FormView from '../views/FormView.vue'
import PreviewView from '../views/PreviewView.vue'
import SettingsFormView from '../views/SettingsFormView.vue'
import SettingsIndexView from '../views/SettingsIndexView.vue'
import NotFoundView from '../views/NotFoundView.vue'
@@ -34,7 +35,7 @@ export function safeRedirect(value: unknown): string | null {
return value
}
const CONTROLLER_ROUTES = new Set(['list', 'create', 'record'])
const CONTROLLER_ROUTES = new Set(['list', 'create', 'record', 'preview'])
/**
* The controller a route shows, or '' for every other screen (settings,
@@ -69,6 +70,13 @@ export function createAdminRouter(history: RouterHistory = createWebHistory(runt
{ 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 } },
// The read-only record screen of a form with a preview block (D-11).
{
path: '/:vendor/:plugin/:controller/:id(\\d+)/preview',
name: 'preview',
component: PreviewView,
meta: { shell: true },
},
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView, meta: { shell: true } },
],
})

View File

@@ -1,11 +1,12 @@
// 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).
// never used verbatim: only the current controller's list, create, record
// and preview routes can come out (research Gap 8, T-10-20, T-12.1-16).
//
// <vendor>/<plugin>/<controller> -> list
// <vendor>/<plugin>/<controller>/create -> create
// <vendor>/<plugin>/<controller>/update/:id -> record (:id substituted)
// <vendor>/<plugin>/<controller>/preview/:id -> preview (:id substituted)
// anything else, or another controller -> list
import { controllerPath, parseControllerId } from './controllerRoutes'
@@ -35,5 +36,9 @@ export function mapWinterUrl(url: string | null | undefined, controllerId: strin
const target = rest[1] === ':id' ? String(id ?? '') : (rest[1] ?? '')
return DIGITS.test(target) ? `${base}/${target}` : base
}
if (rest.length === 2 && rest[0] === 'preview') {
const target = rest[1] === ':id' ? String(id ?? '') : (rest[1] ?? '')
return DIGITS.test(target) ? `${base}/${target}/preview` : base
}
return base
}

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
import { t } from '../../app/i18n'
import { toggleOn } from './control'
import { rendererFor } from './registry'
// One field of the preview screen (UI-SPEC S3, D-11): a label and its value
// as a dt/dd pair. Values are text, never disabled inputs, so they keep full
// contrast and are read in order. Controls that already have a read-only
// mode (datepicker, fileupload, permissioneditor) and partials are rendered
// through their own component with the field marked read-only.
const props = defineProps<{
field: FormField
value: unknown
labels?: RelationOption[]
source?: ControllerParams | null
recordId?: number | null
idPrefix?: string
}>()
/** Types rendered by their own control in its read-only mode. */
const ownControl = new Set(['datepicker', 'fileupload', 'partial', 'permissioneditor'])
const controlId = computed(() => `${props.idPrefix ?? 'preview'}-${props.field.name}`)
const labelId = computed(() => `${controlId.value}-label`)
const kind = computed(() => {
const type = props.field.type
if (ownControl.has(type)) {
return 'control'
}
if (type === 'checkbox' || type === 'switch') {
return 'toggle'
}
if (type === 'relation') {
return props.field.multiple ? 'chips' : 'relation'
}
return type === 'textarea' ? 'textarea' : 'text'
})
/** The field as its own control sees it on preview: always read-only. */
const readOnlyField = computed<FormField>(() => ({ ...props.field, readOnly: true }))
const control = computed(() => rendererFor(props.field.type))
function isEmpty(value: unknown): boolean {
return value === null || value === undefined || value === ''
}
const text = computed(() => {
const value = props.value
if (isEmpty(value) || typeof value === 'object') {
return ''
}
if (props.field.type === 'dropdown') {
const option = (props.field.options ?? []).find((item) => String(item.value) === String(value))
return option ? option.label : String(value)
}
return String(value)
})
const relationLabel = computed(() => props.labels?.[0]?.label ?? '')
const chips = computed(() => (Array.isArray(props.value) ? (props.labels ?? []) : []))
function initials(label: string): string {
const parts = label.trim().split(/\s+/).filter(Boolean)
return parts
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('')
}
const box = 'flex min-h-input items-center rounded-control border border-border bg-subtle px-3.5 [overflow-wrap:anywhere]'
</script>
<template>
<div class="flex min-w-0 flex-col gap-1.5" :data-preview-field="field.name">
<dt :id="labelId" class="font-semibold">{{ field.label || field.name }}</dt>
<dd class="m-0 min-w-0">
<component
:is="control"
v-if="kind === 'control'"
:field="readOnlyField"
:model-value="value"
:control-id="controlId"
:labels="labels"
:source="source"
:record-id="recordId"
/>
<template v-else-if="kind === 'toggle'">
<span
v-if="toggleOn(value)"
data-switch="true"
class="inline-flex h-6 items-center rounded-pill bg-ok-bg px-2.5 text-[12px] font-semibold text-ok-text"
>{{ t('backend::lang.list.column_switch_true') }}</span
>
<span
v-else
data-switch="false"
class="inline-flex h-6 items-center rounded-pill border border-border-strong px-2.5 text-[12px] font-semibold text-muted"
>{{ t('backend::lang.list.column_switch_false') }}</span
>
</template>
<div v-else-if="kind === 'chips'" data-preview-value :class="box" class="flex-wrap gap-1.5 p-1.5">
<span
v-for="chip in chips"
:key="String(chip.value)"
data-chip
class="inline-flex min-h-[30px] items-center gap-1.5 rounded-pill bg-surface pr-3 pl-1"
>
<span
class="flex size-[22px] shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-on-primary"
aria-hidden="true"
>{{ initials(chip.label) }}</span
>
<span class="font-semibold">{{ chip.label }}</span>
</span>
<span v-if="chips.length === 0" class="px-2 text-muted" data-empty>{{ t('backend::lang.list.empty_value') }}</span>
</div>
<div v-else-if="kind === 'relation'" data-preview-value :class="box">
<span v-if="relationLabel !== ''">{{ relationLabel }}</span>
<span v-else class="text-muted" data-empty>{{ field.emptyOption || t('backend::lang.list.empty_value') }}</span>
</div>
<div
v-else
data-preview-value
:class="[box, kind === 'textarea' ? 'items-start! py-3 whitespace-pre-wrap' : '']"
>
<span v-if="text !== ''">{{ text }}</span>
<span v-else class="text-muted" data-empty>{{ t('backend::lang.list.empty_value') }}</span>
</div>
</dd>
</div>
</template>

View File

@@ -3,7 +3,8 @@
import type { AdminRecord, ErrorBody, FormField } from '../../api/types'
import { isRegistered } from './registry'
export type FormMode = 'create' | 'update'
/** The screen a field is filtered for: the two form modes and the read-only preview (D-11). */
export type FormMode = 'create' | 'update' | 'preview'
/** One form tab: the YAML tab label, or the default tab for untabbed fields. */
export interface TabItem {

View File

@@ -12,7 +12,8 @@ import { renderPartialNodes } from './partialNodes'
// The first load shows a skeleton; a reload (reloadKey change) keeps the
// current nodes visible and only marks the host busy. Zero nodes render
// nothing; a failure shows the extension failure box. No live region: the
// toast of the action that caused a reload is the announcement.
// toast of the action that caused a reload is the announcement. A reload
// that fails keeps nothing: the failure box replaces the content.
const props = withDefaults(
defineProps<{
source: ControllerParams
@@ -22,8 +23,10 @@ const props = withDefaults(
variant: 'header' | 'field'
/** Bumped by the parent to refetch. */
reloadKey?: number
/** A status hint (preview screen): the first load shows one 68px block. */
hint?: boolean
}>(),
{ recordId: null, reloadKey: 0 },
{ recordId: null, reloadKey: 0, hint: false },
)
const nodes = ref<PartialNode[] | null>(null)
@@ -66,7 +69,7 @@ void load()
<div v-else-if="nodes === null" data-partial-loading aria-busy="true">
<div
data-partial-skeleton
:class="variant === 'header' ? 'h-[80px] w-full rounded-card' : 'h-[44px] w-full rounded-control'"
:class="hint ? 'h-[68px] w-full rounded-inner' : variant === 'header' ? 'h-[80px] w-full rounded-card' : 'h-[44px] w-full rounded-control'"
class="bg-skel"
aria-hidden="true"
/>

View File

@@ -273,6 +273,39 @@
color: var(--c-text);
font-variant-numeric: tabular-nums;
}
/* Callout (Phase 12.1, UI-SPEC): a status hint above a preview screen. */
.summer-callout {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
padding: 14px 18px;
border-radius: 12px;
font-size: 14px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.summer-callout--warning {
background: var(--c-sel);
color: var(--c-text);
}
.summer-callout--danger {
background: var(--c-danger-soft);
color: var(--c-danger);
}
.summer-callout__title {
margin: 0;
font-weight: 600;
}
.summer-callout__text {
margin: 0;
font-weight: 400;
}
}
@media (prefers-reduced-motion: reduce) {

View File

@@ -51,7 +51,7 @@ const path = {
}
const controllerId = controllerIdFromParams(path)
const listPath = controllerPath(controllerId) ?? '/'
const mode: FormMode = route.name === 'create' ? 'create' : 'update'
const mode: Exclude<FormMode, 'preview'> = route.name === 'create' ? 'create' : 'update'
const recordId = mode === 'update' ? Number(route.params.id) : null
const schema = ref<FormView | null>(null)
@@ -152,6 +152,11 @@ const subtitle = computed(() =>
mode === 'update' ? message(schema.value?.messages.update, undefined, { name: recordName.value }) : '',
)
// An update form of a record that has a preview screen is entered from it,
// so back and Cancel return there (UI-SPEC S3); a delete still goes to the list.
const backToPreview = computed(() => mode === 'update' && !!schema.value?.preview)
const backPath = computed(() => (backToPreview.value ? `${listPath}/${recordId}/preview` : listPath))
const dirty = computed(
() =>
!loading.value &&
@@ -366,7 +371,7 @@ async function onLeave(): Promise<void> {
return
}
}
await go(listPath)
await go(backPath.value)
}
onBeforeRouteLeave(guard)
@@ -395,7 +400,7 @@ void load()
<Button
variant="outline"
data-action="back"
:aria-label="t('backend::lang.form.return_to_list')"
:aria-label="t(backToPreview ? 'backend::lang.form.return_to_preview' : 'backend::lang.form.return_to_list')"
class="size-10! px-0!"
@click="onLeave"
>

View File

@@ -0,0 +1,291 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, provide, readonly, ref, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Pencil } from '@lucide/vue'
import { api } from '../api/client'
import { parentFileRoutes } from '../api/files'
import type { AdminRecord, FormField, FormView, RecordAction, RecordMeta } from '../api/types'
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
import { message, t } from '../app/i18n'
import { loadControllerAssets } from '../app/pluginAssets'
import { newSessionKey } from '../app/sessionKey'
import { FORM_SESSION, createUploadGate } from '../components/form/formContext'
import FormTabs from '../components/form/FormTabs.vue'
import PreviewField from '../components/form/PreviewField.vue'
import RecordActions from '../components/form/RecordActions.vue'
import { RELATION_MANAGER } from '../components/form/registry'
import { DEFAULT_TAB, contextAllows, panelDomId, tabDomId, tabOf, type TabItem } from '../components/form/formState'
import PartialHost from '../components/partial/PartialHost.vue'
import Button from '../components/ui/Button.vue'
import { clearRecordCrumb, setRecordCrumb } from '../state/useBreadcrumbs'
import { showToast } from '../state/useToasts'
// Read-only record screen (UI-SPEC S3, D-11) of a form whose config declares
// `preview:`. It shows the fields whose context allows preview as a dl grid,
// the status hint partial above the card, and a footer with the record
// actions the record response offers (S2) and the one primary edit button.
// Nothing here writes a field: the only requests besides the loads are the
// record actions. A form without a preview sends this route to the record
// (update) route.
const route = useRoute()
const router = useRouter()
const ID_PREFIX = 'preview'
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 recordId = Number(route.params.id)
const recordPath = `${listPath}/${recordId}`
const schema = ref<FormView | null>(null)
const values = ref<AdminRecord>({})
const labels = ref<RecordMeta['labels']>({})
const actions = ref<RecordAction[]>([])
const loading = ref(true)
const failed = ref(false)
// True while a record action's request runs: every footer button is disabled.
const acting = ref(false)
const activeTab = ref(DEFAULT_TAB)
// Bumped after every record action, so the status hint is refetched.
const hintKey = ref(0)
/** Types that are never shown on preview (UI-SPEC S3). */
const hidden = new Set(['password', 'widget', RELATION_MANAGER])
const fields = computed<FormField[]>(() =>
(schema.value?.fields ?? []).filter((field) => contextAllows(field, 'preview') && !hidden.has(field.type)),
)
// The same tabs as the form; a tab with no preview-visible field never
// appears, because tabs are built from the visible fields only.
const tabs = computed<TabItem[]>(() => {
if (!fields.value.some((field) => field.tab)) {
return []
}
const out: TabItem[] = []
for (const field of fields.value) {
const key = tabOf(field)
if (out.some((item) => item.key === key)) {
continue
}
const tab = { key, label: field.tab || t('backend::lang.form.tab_default'), errors: 0 }
if (key === DEFAULT_TAB) {
out.unshift(tab)
} else {
out.push(tab)
}
}
return out
})
const activeIndex = computed(() => Math.max(
tabs.value.findIndex((tab) => tab.key === activeTab.value),
0,
))
const panelFields = computed(() => {
if (tabs.value.length === 0) {
return fields.value
}
const key = tabs.value[activeIndex.value]?.key ?? DEFAULT_TAB
return fields.value.filter((field) => tabOf(field) === key)
})
/** 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 subtitle = computed(() => message(schema.value?.messages.preview, undefined, { name: recordName.value }))
const hintPartial = computed(() => schema.value?.preview?.headerPartial ?? '')
const ready = computed(() => !loading.value && !failed.value && schema.value !== null)
function spanClass(field: FormField): string {
switch (field.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'
}
}
/** Fetches the record; false when it is gone or out of scope. */
async function loadRecord(): Promise<boolean> {
try {
const result = await api.GET('/{vendor}/{plugin}/{controller}/{id}', {
params: { path: { ...path, id: recordId } },
})
if (!result.data) {
return false
}
values.value = { ...result.data.data }
labels.value = result.data.meta.labels ?? {}
actions.value = result.data.meta.actions ?? []
return true
} catch {
return false
}
}
async function load(): Promise<void> {
loading.value = true
failed.value = false
const [schemaResult, recordOk] = await Promise.all([
api.GET('/{vendor}/{plugin}/{controller}/schema/form', { params: { path } }).catch(() => null),
loadRecord(),
])
const view = schemaResult?.data?.data ?? null
if (view && !view.preview) {
// The form has no preview screen: the record route is its only screen.
await router.replace(recordPath)
return
}
schema.value = view
failed.value = !view || !recordOk
if (view) {
void loadControllerAssets(controllerId, view.assets)
}
activeTab.value = tabs.value[0]?.key ?? DEFAULT_TAB
loading.value = false
}
/**
* Reloads the record and the hint in place: the previous values stay on
* screen until the new ones arrive (no skeleton flash). A record that is gone
* turns the screen into the load failure.
*/
async function refresh(): Promise<void> {
hintKey.value++
if (!(await loadRecord())) {
failed.value = true
}
}
async function onDone(text: string): Promise<void> {
showToast(text)
await refresh()
}
function onGone(): void {
failed.value = true
}
// A fileupload field lists its files through the form session. The preview
// never uploads or saves, so the session only carries the routes.
const { activeUploads, beginUpload } = createUploadGate()
const sessionKey = newSessionKey()
provide(FORM_SESSION, {
key: sessionKey,
recordId,
routes: (field: string) => parentFileRoutes(path, recordId, field, sessionKey),
markDirty: () => undefined,
pendingChanges: readonly(ref(0)),
revision: readonly(ref(0)),
beginUpload,
activeUploads: readonly(activeUploads),
})
// The header's last breadcrumb is this record's name (design: Top header).
watchEffect(() => setRecordCrumb(loading.value ? '' : recordName.value))
onBeforeUnmount(clearRecordCrumb)
void load()
</script>
<template>
<section class="flex w-full flex-col gap-5 pb-24" data-preview>
<header class="flex flex-wrap items-center gap-4">
<Button
variant="outline"
data-action="back"
:to="listPath"
:aria-label="t('backend::lang.form.return_to_list')"
class="size-10! px-0!"
>
<ArrowLeft :size="18" aria-hidden="true" />
</Button>
<div class="flex min-w-0 flex-1 flex-col">
<h1 class="truncate text-[24px] font-bold tracking-[-0.02em]">{{ loading ? '' : recordName }}</h1>
<p v-if="ready && subtitle" class="text-muted">{{ subtitle }}</p>
</div>
<FormTabs v-if="ready && tabs.length > 0" v-model="activeTab" :tabs="tabs" :id-prefix="ID_PREFIX" />
</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="ready">
<!-- The status hint: outside every tab, so it is always visible. -->
<PartialHost
v-if="hintPartial !== ''"
variant="header"
hint
:source="path"
:name="hintPartial"
:record-id="recordId"
:reload-key="hintKey"
/>
<div
:id="tabs.length > 0 ? panelDomId(ID_PREFIX, activeIndex) : undefined"
:role="tabs.length > 0 ? 'tabpanel' : undefined"
:aria-labelledby="tabs.length > 0 ? tabDomId(ID_PREFIX, activeIndex) : undefined"
class="rounded-card border border-border bg-surface p-7 shadow-card"
>
<dl class="m-0 grid grid-cols-1 gap-x-6 gap-y-[22px] min-[600px]:grid-cols-2">
<PreviewField
v-for="field in panelFields"
:key="field.name"
:class="spanClass(field)"
:field="field"
:value="values[field.name]"
:labels="labels?.[field.name]"
:source="path"
:record-id="recordId"
:id-prefix="ID_PREFIX"
/>
</dl>
</div>
</template>
<footer
class="fixed inset-x-0 bottom-0 z-30 flex flex-wrap items-center gap-2.5 border-t border-border bg-surface px-8 py-3.5"
>
<div class="ml-auto flex flex-wrap items-center gap-2.5">
<RecordActions
v-if="ready"
:source="path"
:record-id="recordId"
:actions="actions"
:disabled="acting"
@busy="(running: boolean) => (acting = running)"
@done="onDone"
@stale="refresh"
@gone="onGone"
/>
<Button
v-if="!failed"
variant="primary"
data-action="edit"
:icon="Pencil"
:disabled="loading || acting"
@click="router.push(recordPath)"
>
{{ message(schema?.messages.edit) }}
</Button>
</div>
</footer>
</section>
</template>