318 lines
11 KiB
Vue
318 lines
11 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 { FIELD_SCOPE, 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, isRegisteredField } 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']>({})
|
|
/** The controller's record title (meta.title, pact.FormRecordTitle); '' when it names none. */
|
|
const recordTitle = ref('')
|
|
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 Edit button renders only when the form holds at least one value field
|
|
// the SPA can edit whose context allows update (UI-SPEC F6): a record whose
|
|
// form shows only partials has nothing to edit and no primary button.
|
|
const hasEditableField = computed(() =>
|
|
(schema.value?.fields ?? []).some((field) => isRegisteredField(field) && contextAllows(field, 'update')),
|
|
)
|
|
|
|
// 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 controller's title, else the first text-like field's value, in schema order. */
|
|
const recordName = computed(
|
|
() =>
|
|
recordTitle.value ||
|
|
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 ?? {}
|
|
recordTitle.value = result.data.meta.title ?? ''
|
|
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()
|
|
// A money field reads its currencyFrom sibling through the field scope, as
|
|
// on a form.
|
|
provide(FIELD_SCOPE, {
|
|
fields: computed(() => fields.value),
|
|
values: computed(() => values.value),
|
|
path: (name: string) => name,
|
|
})
|
|
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 right-0 bottom-0 left-[var(--shell-offset,0px)] 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 && hasEditableField"
|
|
variant="primary"
|
|
data-action="edit"
|
|
:icon="Pencil"
|
|
:disabled="loading || acting"
|
|
@click="router.push(recordPath)"
|
|
>
|
|
{{ message(schema?.messages.edit) }}
|
|
</Button>
|
|
</div>
|
|
</footer>
|
|
</section>
|
|
</template>
|