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:
134
admin/src/components/form/PreviewField.vue
Normal file
134
admin/src/components/form/PreviewField.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user