Files
summercms/admin/src/views/PreviewView.vue
Jakub Zych a714bb4629 fix(admin): name records by translatable (mltext) fields
The record heading, breadcrumb crumb and document title took the first
plain text field only, so records named by an mltext field fell back to
the generic update message. A shared recordDisplayName helper now takes
the first text or ML field and reads its default locale, then the first
non-empty one.
2026-10-07 19:05:47 +02:00

299 lines
9.8 KiB
Vue

<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,
recordDisplayName,
schemaEnabledLocales,
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-like field's value, in schema order. */
const recordName = computed(() =>
recordDisplayName(schema.value?.fields, values.value, schemaEnabledLocales(schema.value?.meta)),
)
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>