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:
@@ -846,6 +846,17 @@
|
||||
"deleted": {
|
||||
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||
},
|
||||
"edit": {
|
||||
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||
},
|
||||
"preview": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||
}
|
||||
],
|
||||
"description": "The preview screen's subtitle and its edit button (D-11)."
|
||||
},
|
||||
"saved": {
|
||||
"$ref": "#/components/schemas/cabana.MessageForms"
|
||||
},
|
||||
@@ -857,6 +868,8 @@
|
||||
"create",
|
||||
"deleteConfirm",
|
||||
"deleted",
|
||||
"edit",
|
||||
"preview",
|
||||
"saved",
|
||||
"update"
|
||||
],
|
||||
@@ -888,6 +901,14 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.FormPreview": {
|
||||
"properties": {
|
||||
"headerPartial": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"cabana.FormRedirect": {
|
||||
"properties": {
|
||||
"redirect": {
|
||||
@@ -955,6 +976,14 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"preview": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/cabana.FormPreview"
|
||||
}
|
||||
],
|
||||
"description": "Preview is set when the form has a preview screen (D-11); a form\nwithout one omits the key."
|
||||
},
|
||||
"redirects": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -3262,6 +3291,7 @@
|
||||
},
|
||||
"/{vendor}/{plugin}/{controller}/schema/form": {
|
||||
"get": {
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Vendor",
|
||||
|
||||
16
admin/src/api/schema.d.ts
vendored
16
admin/src/api/schema.d.ts
vendored
@@ -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.
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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 } },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
291
admin/src/views/PreviewView.vue
Normal file
291
admin/src/views/PreviewView.vue
Normal 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>
|
||||
@@ -24,6 +24,14 @@ describe('mapWinterUrl', () => {
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/9', ID)).toBe(`${LIST}/9`)
|
||||
})
|
||||
|
||||
it('substitutes :id in preview/:id and falls back to the list without a usable id (D-11)', () => {
|
||||
expect(mapWinterUrl('acme/demo/widgets/preview/:id', ID, 42)).toBe(`${LIST}/42/preview`)
|
||||
expect(mapWinterUrl('acme/demo/widgets/preview/9', ID)).toBe(`${LIST}/9/preview`)
|
||||
expect(mapWinterUrl('acme/demo/widgets/preview/:id', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/preview/abc', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/gadgets/preview/:id', ID, 1)).toBe(LIST)
|
||||
})
|
||||
|
||||
it('falls back to the list when update has no usable id', () => {
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', ID, null)).toBe(LIST)
|
||||
@@ -41,7 +49,7 @@ describe('mapWinterUrl', () => {
|
||||
it('ignores the query and hash, matches the owner case-insensitively and rejects unknown actions', () => {
|
||||
expect(mapWinterUrl('acme/demo/widgets/create?x=1#top', ID)).toBe(`${LIST}/create`)
|
||||
expect(mapWinterUrl('Acme/Demo/Widgets/update/:id', ID, 3)).toBe(`${LIST}/3`)
|
||||
expect(mapWinterUrl('acme/demo/widgets/preview/3', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/history/3', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/3/extra', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/CREATE', ID)).toBe(LIST)
|
||||
})
|
||||
|
||||
@@ -62,6 +62,12 @@
|
||||
},
|
||||
"deleted": {
|
||||
"other": "Gadget deleted"
|
||||
},
|
||||
"preview": {
|
||||
"other": "Record preview"
|
||||
},
|
||||
"edit": {
|
||||
"other": "Edit record"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
|
||||
@@ -47,6 +47,12 @@
|
||||
},
|
||||
"deleted": {
|
||||
"other": "Widget deleted"
|
||||
},
|
||||
"preview": {
|
||||
"other": "Record preview"
|
||||
},
|
||||
"edit": {
|
||||
"other": "Edit record"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
|
||||
29
admin/tests/fixtures/roster.form-schema.json
vendored
Normal file
29
admin/tests/fixtures/roster.form-schema.json
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"data": {
|
||||
"name": "Person",
|
||||
"modelClass": "Person",
|
||||
"fields": [
|
||||
{ "name": "name", "type": "text", "label": "Name", "span": "left" },
|
||||
{ "name": "email", "type": "text", "label": "Email", "span": "right" },
|
||||
{ "name": "joined_ip", "type": "text", "label": "Joined from IP address", "context": "preview" }
|
||||
],
|
||||
"preview": { "headerPartial": "status" },
|
||||
"messages": {
|
||||
"create": { "other": "Person" },
|
||||
"update": { "other": "Edit record" },
|
||||
"saved": { "other": "Saved" },
|
||||
"deleteConfirm": { "other": "Delete this record?" },
|
||||
"deleted": { "other": "Record deleted" },
|
||||
"preview": { "other": "Person details" },
|
||||
"edit": { "other": "Edit person" }
|
||||
},
|
||||
"redirects": {
|
||||
"default": "acme/roster/people",
|
||||
"create": { "redirect": "acme/roster/people/preview/:id", "redirectClose": "acme/roster/people" },
|
||||
"update": { "redirect": "acme/roster/people", "redirectClose": "acme/roster/people/preview/:id" }
|
||||
},
|
||||
"meta": { "locale": "en" },
|
||||
"assets": { "scripts": [], "styles": [] }
|
||||
},
|
||||
"meta": { "locale": "en" }
|
||||
}
|
||||
2
admin/tests/fixtures/roster.list-schema.json
vendored
2
admin/tests/fixtures/roster.list-schema.json
vendored
@@ -8,7 +8,7 @@
|
||||
"showCheckboxes": true,
|
||||
"showSorting": true,
|
||||
"searchTerm": "search",
|
||||
"recordUrl": "acme/roster/people/update/:id",
|
||||
"recordUrl": "acme/roster/people/preview/:id",
|
||||
"toolbarButtons": ["create", "delete"],
|
||||
"toolbarActions": [],
|
||||
"assets": { "scripts": [], "styles": [] },
|
||||
|
||||
2
admin/tests/fixtures/roster.record.json
vendored
2
admin/tests/fixtures/roster.record.json
vendored
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"data": { "id": 1, "name": "Ada Lovelace", "email": "ada@example.test" },
|
||||
"data": { "id": 1, "name": "Ada Lovelace", "email": "ada@example.test", "joined_ip": "203.0.113.7" },
|
||||
"meta": {
|
||||
"labels": {},
|
||||
"actions": [
|
||||
|
||||
6
admin/tests/fixtures/settings.json
vendored
6
admin/tests/fixtures/settings.json
vendored
@@ -70,6 +70,12 @@
|
||||
},
|
||||
"deleted": {
|
||||
"other": "Deleted"
|
||||
},
|
||||
"preview": {
|
||||
"other": "Record preview"
|
||||
},
|
||||
"edit": {
|
||||
"other": "Edit record"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
|
||||
3
admin/tests/fixtures/typed.ts
vendored
3
admin/tests/fixtures/typed.ts
vendored
@@ -18,6 +18,7 @@ import listJson from './widgets.list.json'
|
||||
import listSchemaJson from './widgets.list-schema.json'
|
||||
import optionsJson from './widgets.options.json'
|
||||
import rosterListJson from './roster.list.json'
|
||||
import rosterFormSchemaJson from './roster.form-schema.json'
|
||||
import rosterListSchemaJson from './roster.list-schema.json'
|
||||
import rosterRecordJson from './roster.record.json'
|
||||
import recordJson from './widgets.record.json'
|
||||
@@ -59,6 +60,8 @@ export const extensionPartialFixture = extensionPartialJson as S['cabana.Envelop
|
||||
export const listFixture: Rows = listJson
|
||||
/** A people list with declared bulk actions (Phase 12.1). */
|
||||
export const rosterListSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = rosterListSchemaJson
|
||||
/** The people form: a preview block with a status hint and a preview-only field (Phase 12.1). */
|
||||
export const rosterFormSchemaFixture: S['cabana.Envelope-cabana_FormView'] = rosterFormSchemaJson
|
||||
/** One person with two offered record actions (Phase 12.1). */
|
||||
export const rosterRecordFixture: S['cabana.RecordEnvelope'] = rosterRecordJson
|
||||
/** The people of the roster list (Phase 12.1). */
|
||||
|
||||
@@ -138,6 +138,12 @@
|
||||
},
|
||||
"deleted": {
|
||||
"other": "Widget deleted"
|
||||
},
|
||||
"preview": {
|
||||
"other": "Record preview"
|
||||
},
|
||||
"edit": {
|
||||
"other": "Edit record"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
|
||||
@@ -489,7 +489,7 @@ describe('row state (UI-SPEC S4, D-12)', () => {
|
||||
expect(row.find('[data-row-state]').exists()).toBe(false)
|
||||
const link = firstCell(wrapper, 0).find('a')
|
||||
expect(link.classes()).toEqual(expect.arrayContaining(['text-text', 'no-underline']))
|
||||
expect(link.attributes('href')).toBe('/acme/roster/people/1')
|
||||
expect(link.attributes('href')).toBe('/acme/roster/people/1/preview')
|
||||
expect(firstCell(wrapper, 0).classes()).not.toContain('text-muted')
|
||||
})
|
||||
|
||||
@@ -507,7 +507,7 @@ describe('row state (UI-SPEC S4, D-12)', () => {
|
||||
const link = cell.find('a')
|
||||
expect(link.classes()).toEqual(expect.arrayContaining(['line-through', 'text-danger', 'min-w-0', 'truncate']))
|
||||
expect(link.find('[data-row-state]').exists()).toBe(false)
|
||||
expect(link.attributes('href')).toBe('/acme/roster/people/2')
|
||||
expect(link.attributes('href')).toBe('/acme/roster/people/2/preview')
|
||||
expect(cell.classes()).toEqual(expect.arrayContaining(['font-semibold', 'text-muted']))
|
||||
expect(row.findAll('td:not([data-select])')[1]!.classes()).toContain('text-muted')
|
||||
const deleted = cell.find('[data-row-state="deleted"]')
|
||||
|
||||
@@ -68,7 +68,8 @@ describe('winter URLs', () => {
|
||||
expect(mapWinterUrl('/acme/demo/widgets/create', id)).toBe('/acme/demo/widgets/create')
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', id, 42)).toBe('/acme/demo/widgets/42')
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', id, null)).toBe('/acme/demo/widgets')
|
||||
expect(mapWinterUrl('acme/demo/widgets/preview/:id', id, 42)).toBe('/acme/demo/widgets')
|
||||
expect(mapWinterUrl('acme/demo/widgets/preview/:id', id, 42)).toBe('/acme/demo/widgets/42/preview')
|
||||
expect(mapWinterUrl('acme/demo/widgets/history/:id', id, 42)).toBe('/acme/demo/widgets')
|
||||
expect(mapWinterUrl('https://evil.example/acme/demo/widgets', id, 1)).toBe('/acme/demo/widgets')
|
||||
expect(mapWinterUrl('acme/other/things/update/:id', id, 1)).toBe('/acme/demo/widgets')
|
||||
expect(mapWinterUrl('', id)).toBe('/acme/demo/widgets')
|
||||
|
||||
369
admin/tests/smoke/preview.smoke.test.ts
Normal file
369
admin/tests/smoke/preview.smoke.test.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
// Phase 12.1 preview screen (UI-SPEC S3, D-11) with the record actions in
|
||||
// its footer (UI-SPEC S2, D-10): the read-only record route, the status hint,
|
||||
// the dl grid, the preview-only field, the URL mapping and the update form's
|
||||
// way back. Fixtures are neutral acme.roster.* data; no application names
|
||||
// appear in framework tests.
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import { routeControllerId } from '../../src/app/router'
|
||||
import { mapWinterUrl } from '../../src/app/winterUrl'
|
||||
import {
|
||||
clone,
|
||||
langFixture,
|
||||
rosterFormSchemaFixture,
|
||||
rosterListFixture,
|
||||
rosterListSchemaFixture,
|
||||
rosterRecordFixture,
|
||||
} from '../fixtures/typed'
|
||||
import { API, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
|
||||
|
||||
const LIST = `${API}/acme/roster/people`
|
||||
const RECORD = `${LIST}/1`
|
||||
const PREVIEW = '/acme/roster/people/1/preview'
|
||||
|
||||
// The framework strings this screen uses, as GET /lang serves them in en.
|
||||
const strings = {
|
||||
'backend::lang.form.return_to_list': { other: 'Back to list' },
|
||||
'backend::lang.form.return_to_preview': { other: 'Back to preview' },
|
||||
'backend::lang.form.load_failed': { other: 'The form could not be loaded.' },
|
||||
'backend::lang.form.action_confirm': { other: 'Run “:action” on this record?' },
|
||||
'backend::lang.form.action_done': { other: 'Action completed.' },
|
||||
'backend::lang.form.action_stale': {
|
||||
other: 'This action no longer applies to this record. The page has been refreshed.',
|
||||
},
|
||||
'backend::lang.form.cancel': { other: 'Cancel' },
|
||||
'backend::lang.list.empty_value': { other: '—' },
|
||||
'backend::lang.list.column_switch_true': { other: 'Yes' },
|
||||
'backend::lang.list.column_switch_false': { other: 'No' },
|
||||
'backend::lang.extension.partial_failed': { other: 'This section could not be loaded.' },
|
||||
}
|
||||
|
||||
const callout = (title: string, text: string) => ({
|
||||
data: {
|
||||
nodes: [
|
||||
{
|
||||
tag: 'div',
|
||||
attrs: { class: 'summer-callout summer-callout--warning', role: 'status' },
|
||||
children: [
|
||||
{ tag: 'p', attrs: { class: 'summer-callout__title' }, children: [{ text: title }] },
|
||||
{ tag: 'p', attrs: { class: 'summer-callout__text' }, children: [{ text }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {},
|
||||
})
|
||||
|
||||
function recordWith(change: (record: typeof rosterRecordFixture) => void): typeof rosterRecordFixture {
|
||||
const record = clone(rosterRecordFixture)
|
||||
change(record)
|
||||
return record
|
||||
}
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${LIST}/schema/form`]: { body: rosterFormSchemaFixture },
|
||||
[`GET ${LIST}/schema/list`]: { body: rosterListSchemaFixture },
|
||||
[`GET ${LIST}`]: { body: rosterListFixture },
|
||||
[`GET ${RECORD}`]: { body: rosterRecordFixture },
|
||||
[`GET ${LIST}/partials/status`]: { body: callout('This person is not active', 'Activate the person to let them sign in.') },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const dialog = () => document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
|
||||
const footer = (wrapper: VueWrapper) => wrapper.find('[data-preview] footer')
|
||||
const value = (wrapper: VueWrapper, name: string) => wrapper.find(`[data-preview-field="${name}"] dd`)
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await wait(10)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
setBundle({ ...langFixture.data, ...strings }, 'en')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('preview screen (UI-SPEC S3, D-11)', () => {
|
||||
it('renders the title, the subtitle and the fields as a dl grid of text', async () => {
|
||||
const { wrapper, router } = await mountApp(PREVIEW, routes())
|
||||
expect(router.currentRoute.value.name).toBe('preview')
|
||||
expect(routeControllerId(router.currentRoute.value)).toBe('acme.roster.people')
|
||||
expect(wrapper.find('h1').text()).toBe('Ada Lovelace')
|
||||
expect(wrapper.find('header p').text()).toBe('Person details')
|
||||
|
||||
const grid = wrapper.find('[data-preview] dl')
|
||||
expect(grid.exists()).toBe(true)
|
||||
expect(grid.findAll('dt').map((item) => item.text())).toEqual(['Name', 'Email', 'Joined from IP address'])
|
||||
expect(value(wrapper, 'name').text()).toBe('Ada Lovelace')
|
||||
expect(value(wrapper, 'email').text()).toBe('ada@example.test')
|
||||
// Values wrap and are never truncated; labels carry no required mark.
|
||||
expect(value(wrapper, 'name').find('[data-preview-value]').classes()).toContain('[overflow-wrap:anywhere]')
|
||||
expect(grid.text()).not.toContain('*')
|
||||
// Nothing on the screen can be edited or saved.
|
||||
expect(wrapper.find('[data-preview] input, [data-preview] textarea, [data-preview] select').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-action="save"], [data-action="delete"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows a preview-only field here and not on the update form', async () => {
|
||||
const { wrapper, router } = await mountApp(PREVIEW, routes())
|
||||
expect(value(wrapper, 'joined_ip').text()).toBe('203.0.113.7')
|
||||
expect(wrapper.find('[data-preview-field="joined_ip"] input').exists()).toBe(false)
|
||||
|
||||
await router.push('/acme/roster/people/1')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-field="name"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-field="joined_ip"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the muted dash for an empty value', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
PREVIEW,
|
||||
routes({ [`GET ${RECORD}`]: { body: recordWith((record) => (record.data.email = '')) } }),
|
||||
)
|
||||
const empty = value(wrapper, 'email').find('[data-empty]')
|
||||
expect(empty.text()).toBe('—')
|
||||
expect(empty.classes()).toContain('text-muted')
|
||||
})
|
||||
|
||||
it('renders the status hint between the header and the card with the record id', async () => {
|
||||
const { wrapper, calls } = await mountApp(PREVIEW, routes())
|
||||
const hint = wrapper.find('[data-partial="header"]')
|
||||
expect(hint.find('.summer-callout--warning[role="status"]').exists()).toBe(true)
|
||||
expect(hint.text()).toContain('This person is not active')
|
||||
const [request] = requestsTo(calls, 'GET', `${LIST}/partials/status`)
|
||||
expect(new URL(request!.url).searchParams.get('id')).toBe('1')
|
||||
// The hint sits above the card, outside the dl.
|
||||
expect(wrapper.find('[data-preview] dl [data-partial]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders nothing for a hint with zero nodes and the failure box for a failed one', async () => {
|
||||
const none = await mountApp(PREVIEW, routes({ [`GET ${LIST}/partials/status`]: { body: { data: { nodes: [] }, meta: {} } } }))
|
||||
expect(none.wrapper.find('[data-partial], [data-partial-loading], [data-partial-failed]').exists()).toBe(false)
|
||||
expect(none.wrapper.find('[data-preview] dl').exists()).toBe(true)
|
||||
none.wrapper.unmount()
|
||||
|
||||
const broken = await mountApp(
|
||||
PREVIEW,
|
||||
routes({ [`GET ${LIST}/partials/status`]: { status: 500, body: { error: { code: 'error', message: 'x', details: {} } } } }),
|
||||
)
|
||||
expect(broken.wrapper.find('[data-partial-failed]').exists()).toBe(true)
|
||||
expect(broken.wrapper.find('[data-preview] dl').exists()).toBe(true)
|
||||
expect(broken.wrapper.find('[data-action="edit"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows one 68px skeleton while the hint first loads', async () => {
|
||||
let release: ((reply: Reply) => void) | undefined
|
||||
const { wrapper } = await mountApp(
|
||||
PREVIEW,
|
||||
routes({
|
||||
[`GET ${LIST}/partials/status`]: () =>
|
||||
new Promise<Reply>((resolve) => {
|
||||
release = resolve
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const skeleton = wrapper.find('[data-partial-skeleton]')
|
||||
expect(skeleton.classes()).toEqual(expect.arrayContaining(['h-[68px]', 'rounded-inner', 'bg-skel']))
|
||||
release?.({ body: callout('a', 'b') })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-partial-skeleton]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('replaces the route with the record route when the form has no preview', async () => {
|
||||
const plain = clone(rosterFormSchemaFixture)
|
||||
delete plain.data.preview
|
||||
const { router } = await mountApp(PREVIEW, routes({ [`GET ${LIST}/schema/form`]: { body: plain } }))
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.name).toBe('record')
|
||||
expect(router.currentRoute.value.path).toBe('/acme/roster/people/1')
|
||||
})
|
||||
|
||||
it('shows the load failure and keeps the back button when the record is missing', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
PREVIEW,
|
||||
routes({ [`GET ${RECORD}`]: { status: 404, body: { error: { code: 'not_found', message: 'Not found', details: {} } } } }),
|
||||
)
|
||||
expect(wrapper.find('[role="alert"]').text()).toBe('The form could not be loaded.')
|
||||
expect(wrapper.find('[data-preview] dl').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-action="edit"]').exists()).toBe(false)
|
||||
const back = wrapper.find('[data-action="back"]')
|
||||
expect(back.attributes('href')).toBe('/acme/roster/people')
|
||||
expect(back.attributes('aria-label')).toBe('Back to list')
|
||||
})
|
||||
})
|
||||
|
||||
describe('preview footer (UI-SPEC S2, D-10)', () => {
|
||||
it('shows the offered record actions in order before the one primary edit button', async () => {
|
||||
const { wrapper, router } = await mountApp(PREVIEW, routes())
|
||||
const buttons = footer(wrapper).findAll('button')
|
||||
expect(buttons.map((item) => item.attributes('data-record-action') ?? item.attributes('data-action'))).toEqual([
|
||||
'activate',
|
||||
'reinstate',
|
||||
'edit',
|
||||
])
|
||||
const edit = wrapper.find('[data-action="edit"]')
|
||||
expect(edit.text()).toBe('Edit person')
|
||||
expect(edit.classes()).toContain('bg-primary')
|
||||
expect(edit.find('svg').exists()).toBe(true)
|
||||
expect(footer(wrapper).findAll('.bg-primary')).toHaveLength(1)
|
||||
// The footer and its right cluster wrap; buttons never shrink or truncate.
|
||||
expect(footer(wrapper).classes()).toContain('flex-wrap')
|
||||
expect(footer(wrapper).find('div').classes()).toEqual(expect.arrayContaining(['ml-auto', 'flex-wrap']))
|
||||
expect(buttons.every((item) => item.classes().includes('whitespace-nowrap') && item.classes().includes('shrink-0'))).toBe(true)
|
||||
|
||||
await edit.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/acme/roster/people/1')
|
||||
})
|
||||
|
||||
it('shows only the edit button when no record action is offered', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
PREVIEW,
|
||||
routes({ [`GET ${RECORD}`]: { body: recordWith((record) => delete record.meta.actions) } }),
|
||||
)
|
||||
expect(wrapper.find('[data-record-action]').exists()).toBe(false)
|
||||
expect(footer(wrapper).findAll('button').map((item) => item.attributes('data-action'))).toEqual(['edit'])
|
||||
})
|
||||
|
||||
it('after an action: a success toast, the record and the hint reloaded in place', async () => {
|
||||
let shows = 0
|
||||
const after = recordWith((record) => {
|
||||
record.data.email = 'ada@new.example.test'
|
||||
record.meta.actions = [{ name: 'reinstate', label: 'Reinstate' }]
|
||||
})
|
||||
const { wrapper, calls } = await mountApp(
|
||||
PREVIEW,
|
||||
routes({
|
||||
[`GET ${RECORD}`]: () => ({ body: shows++ === 0 ? rosterRecordFixture : after }),
|
||||
[`POST ${RECORD}/actions/activate`]: { body: { data: { message: 'The person was activated.', fill: {} }, meta: {} } },
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
await wrapper.find('[data-record-action="activate"]').trigger('click')
|
||||
await flushPromises()
|
||||
dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
|
||||
await flushPromises()
|
||||
// The previous content stays while the record reloads: no skeleton flash.
|
||||
expect(wrapper.find('[data-preview] dl').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-partial-skeleton]').exists()).toBe(false)
|
||||
await settle()
|
||||
|
||||
expect(document.body.textContent).toContain(
|
||||
'The person was activated.',
|
||||
)
|
||||
expect(value(wrapper, 'email').text()).toBe('ada@new.example.test')
|
||||
expect(wrapper.findAll('[data-record-action]').map((item) => item.attributes('data-record-action'))).toEqual(['reinstate'])
|
||||
expect(requestsTo(calls, 'GET', RECORD)).toHaveLength(2)
|
||||
expect(requestsTo(calls, 'GET', `${LIST}/partials/status`)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('disables every footer button while an action runs', async () => {
|
||||
let release: ((reply: Reply) => void) | undefined
|
||||
const { wrapper } = await mountApp(
|
||||
PREVIEW,
|
||||
routes({
|
||||
[`POST ${RECORD}/actions/activate`]: () =>
|
||||
new Promise<Reply>((resolve) => {
|
||||
release = resolve
|
||||
}),
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
await wrapper.find('[data-record-action="activate"]').trigger('click')
|
||||
await flushPromises()
|
||||
dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
|
||||
await flushPromises()
|
||||
expect(footer(wrapper).findAll('button').every((item) => item.attributes('disabled') !== undefined)).toBe(true)
|
||||
release?.({ body: { data: { message: '', fill: {} }, meta: {} } })
|
||||
await settle()
|
||||
expect(wrapper.find('[data-action="edit"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reloads the record and the hint when an action no longer applies', async () => {
|
||||
const { wrapper, calls } = await mountApp(
|
||||
PREVIEW,
|
||||
routes({
|
||||
[`POST ${RECORD}/actions/activate`]: { status: 409, body: { error: { code: 'conflict', message: 'Conflict', details: {} } } },
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
await wrapper.find('[data-record-action="activate"]').trigger('click')
|
||||
await flushPromises()
|
||||
dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
|
||||
await settle()
|
||||
expect(requestsTo(calls, 'GET', RECORD)).toHaveLength(2)
|
||||
expect(requestsTo(calls, 'GET', `${LIST}/partials/status`)).toHaveLength(2)
|
||||
expect(document.body.textContent).toContain('This action no longer applies to this record.')
|
||||
})
|
||||
|
||||
it('turns into the load failure when the record is gone', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
PREVIEW,
|
||||
routes({
|
||||
[`POST ${RECORD}/actions/activate`]: { status: 404, body: { error: { code: 'not_found', message: 'Not found', details: {} } } },
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
await wrapper.find('[data-record-action="activate"]').trigger('click')
|
||||
await flushPromises()
|
||||
dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
|
||||
await settle()
|
||||
expect(wrapper.find('[role="alert"]').text()).toBe('The form could not be loaded.')
|
||||
expect(wrapper.find('[data-record-action], [data-action="edit"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-action="back"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('preview URLs (T-12.1-16)', () => {
|
||||
const id = 'acme.roster.people'
|
||||
|
||||
it('maps preview/:id onto the preview route of the current controller only', () => {
|
||||
expect(mapWinterUrl('acme/roster/people/preview/:id', id, 42)).toBe('/acme/roster/people/42/preview')
|
||||
expect(mapWinterUrl('acme/roster/people/preview/7', id)).toBe('/acme/roster/people/7/preview')
|
||||
expect(mapWinterUrl('acme/roster/people/preview/:id', id, null)).toBe('/acme/roster/people')
|
||||
expect(mapWinterUrl('acme/roster/people/preview/1e3', id)).toBe('/acme/roster/people')
|
||||
expect(mapWinterUrl('acme/roster/people/preview/3/extra', id)).toBe('/acme/roster/people')
|
||||
expect(mapWinterUrl('acme/other/people/preview/:id', id, 1)).toBe('/acme/roster/people')
|
||||
expect(mapWinterUrl('https://evil.example/acme/roster/people/preview/1', id)).toBe('/acme/roster/people')
|
||||
})
|
||||
|
||||
it('opens the preview from a list row whose recordUrl points at it', async () => {
|
||||
const { wrapper, router } = await mountApp('/acme/roster/people', routes())
|
||||
const link = wrapper.find('tbody a')
|
||||
expect(link.attributes('href')).toMatch(/^\/acme\/roster\/people\/\d+\/preview$/)
|
||||
await link.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.name).toBe('preview')
|
||||
})
|
||||
|
||||
it('leads back to the preview from the update form of a form with a preview', async () => {
|
||||
const { wrapper, router } = await mountApp('/acme/roster/people/1', routes())
|
||||
const back = wrapper.find('[data-action="back"]')
|
||||
expect(back.attributes('aria-label')).toBe('Back to preview')
|
||||
await back.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe(PREVIEW)
|
||||
|
||||
await router.push('/acme/roster/people/1')
|
||||
await flushPromises()
|
||||
await wrapper.find('[data-action="cancel"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe(PREVIEW)
|
||||
})
|
||||
|
||||
it('follows update.redirectClose to the preview after save and close', async () => {
|
||||
const { wrapper, router } = await mountApp('/acme/roster/people/1', routes({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } }))
|
||||
await wrapper.find('[data-action="save-close"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe(PREVIEW)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user