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

@@ -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"
/>