Files
summercms/admin/src/components/relation/RelationChildModal.vue
Jakub Zych 4adb48a76e feat(16.2-01): translatable plugin fields, element uploads and save notices in the admin
- MLPluginField wraps PluginField in the MLField locale switcher; the
  element gets the active content locale and that locale's string
- isMLField treats a translatable plugin field as a locale map in the form
  state, FormView adopt and the relation child modal
- summer-upload posts the file with the admin session to the field upload
  route and answers summer-upload-result {id, url} or {id, error}
- noticeLines shows meta.notices under each field as a warning status line
  (text only) and in the saved toast; the stored value is reloaded
- docs/backend/field-types.md documents the full element contract;
  modules/boardwalk/dist rebuilt
2026-10-10 11:25:26 +02:00

548 lines
17 KiB
Vue

<script setup lang="ts">
import { computed, inject, nextTick, provide, readonly, ref, watch } from 'vue'
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
} from 'reka-ui'
import { Trash2, X } from '@lucide/vue'
import { api } from '../../api/client'
import { childFileRoutes } from '../../api/files'
import type { AdminRecord, ControllerParams, FormField, RecordMeta, RelationSchema } from '../../api/types'
import { message, t } from '../../app/i18n'
import { CHILD_SESSION_HEADER, SESSION_HEADER, newSessionKey } from '../../app/sessionKey'
import FormErrorBanner from '../form/FormErrorBanner.vue'
import FormGrid from '../form/FormGrid.vue'
import FormTabs from '../form/FormTabs.vue'
import { FORM_ENABLED_LOCALES, FORM_PATCH, FORM_SESSION, FORM_VALUES, createUploadGate, type FormSession } from '../form/formContext'
import {
DEFAULT_TAB,
contextAllows,
editablePayload,
fieldErrors,
focusField,
initialValues,
isMLField,
mergeMLValue,
panelDomId,
snapshot,
tabDomId,
tabOf,
type TabItem,
} from '../form/formState'
import Button from '../ui/Button.vue'
import ConfirmDialog from '../ui/ConfirmDialog.vue'
import { useConfirm } from '../ui/confirm'
import { showToast } from '../../state/useToasts'
// Related record modal (Phase 12.2, D-11, D-17; UI-SPEC section 4). Creates
// a child through POST .../relations/{name}/records, edits one through
// GET/PUT .../records/{child}, or shows the view form read-only. The form is
// the relation's manageForm (viewForm for a preview) rendered with the
// record form's own FormGrid, tabs and error banner. The modal makes its own
// session key when it opens: uploads inside it are held against that key
// (X-Child-Session-Key) and committed by the child's save, which also sends
// the record form's key so an unsaved parent is found (owner id 0).
const props = defineProps<{
open: boolean
source: ControllerParams
/** Owner record id; 0 for the record being created in the form's session. */
ownerId: number
relation: string
schema: RelationSchema
mode: 'create' | 'update' | 'preview'
/** Child id in update and preview mode. */
childId: number | null
/** The record form's session key (X-Session-Key). */
sessionKey: string
/** Whether the footer offers Delete (the view declares the delete button). */
canDelete: boolean
}>()
const emit = defineEmits<{
'update:open': [open: boolean]
/** A child was created, updated or deleted. */
changed: [kind: 'created' | 'updated' | 'deleted']
/** The child no longer exists (404): the list must reload. */
gone: []
closed: []
}>()
const values = ref<AdminRecord>({})
const labels = ref<RecordMeta['labels']>({})
const errors = ref<Record<string, string[]>>({})
const loading = ref(false)
const failed = ref(false)
const busy = ref(false)
const saved = ref('')
const activeTab = ref(DEFAULT_TAB)
const childKey = ref(newSessionKey())
const pendingChanges = ref(0)
const revision = ref(0)
const { activeUploads, beginUpload } = createUploadGate()
const body = ref<HTMLElement | null>(null)
const confirm = useConfirm()
let generation = 0
const idPrefix = computed(() => `child-${props.relation}`)
const preview = computed(() => props.mode === 'preview')
const formMode = computed(() => (props.mode === 'create' ? 'create' : 'update'))
/** The form fields: manageForm, or viewForm read-only, in the form's context. */
const fields = computed<FormField[]>(() => {
const source = (preview.value ? props.schema.viewForm : props.schema.manageForm) ?? []
const visible = source.filter((field) => contextAllows(field, formMode.value))
if (!preview.value) {
return visible
}
return visible.map((field) => ({
...field,
readOnly: true,
attributes: { ...(field.attributes ?? {}), readonly: true },
}))
})
const fieldNames = computed(() => fields.value.map((field) => field.name))
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)
let tab = out.find((item) => item.key === key)
if (!tab) {
tab = { key, label: field.tab || t('backend::lang.form.tab_default'), errors: 0 }
if (key === DEFAULT_TAB) {
out.unshift(tab)
} else {
out.push(tab)
}
}
if (errors.value[field.name]) {
tab.errors += 1
}
}
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)
})
const title = computed(() => {
const messages = props.schema.messages
if (props.mode === 'create') {
return message(messages.createTitle)
}
return message(preview.value ? messages.previewTitle : messages.updateTitle)
})
const submitLabel = computed(() =>
message(props.mode === 'create' ? props.schema.messages.createSubmit : props.schema.messages.updateSubmit),
)
const dirty = computed(
() =>
!preview.value &&
!loading.value &&
!failed.value &&
(pendingChanges.value > 0 ||
activeUploads.value > 0 ||
snapshot(editablePayload(fields.value, values.value)) !== saved.value),
)
function headers() {
return { [SESSION_HEADER]: props.sessionKey, [CHILD_SESSION_HEADER]: childKey.value } as {
'X-Session-Key': string
'X-Child-Session-Key': string
}
}
function pathParams() {
return { ...props.source, id: props.ownerId, name: props.relation }
}
// Fields inside the modal (fileupload, datepicker) work against the child:
// file routes of child id 0 on create, both keys on every call.
const session: FormSession = {
get key() {
return props.sessionKey
},
get childKey() {
return childKey.value
},
get recordId() {
return props.childId ?? 0
},
routes: (field: string) =>
childFileRoutes(
props.source,
props.ownerId,
props.relation,
props.childId ?? 0,
field,
props.sessionKey,
childKey.value,
),
markDirty: () => {
pendingChanges.value++
},
pendingChanges: readonly(pendingChanges),
revision: readonly(revision),
beginUpload,
activeUploads: readonly(activeUploads),
}
provide(FORM_SESSION, session)
provide(FORM_VALUES, computed(() => values.value))
provide(FORM_PATCH, update)
const enabledLocales = inject(FORM_ENABLED_LOCALES, computed(() => [] as readonly string[]))
provide(FORM_ENABLED_LOCALES, enabledLocales)
function update(name: string, value: unknown): void {
values.value = { ...values.value, [name]: value }
if (errors.value[name]) {
const next = { ...errors.value }
delete next[name]
errors.value = next
}
}
function adopt(record: AdminRecord, meta?: RecordMeta): void {
const locales = enabledLocales.value
const next: AdminRecord = { ...record }
for (const field of fields.value) {
if (field.type === 'password') {
next[field.name] = ''
} else if (isMLField(field)) {
next[field.name] = mergeMLValue(record[field.name], locales)
}
}
values.value = next
labels.value = meta?.labels ?? {}
saved.value = snapshot(editablePayload(fields.value, values.value))
}
/** The first focusable control of the body (a field, or the alert's Cancel). */
async function focusFirst(): Promise<void> {
await nextTick()
const target = body.value?.querySelector<HTMLElement>(
'input:not([type="hidden"]):not([tabindex="-1"]), select, textarea, button, [contenteditable="true"], [tabindex="0"]',
)
target?.focus()
}
function gone(): void {
emit('update:open', false)
showToast(t('backend::lang.relation.child_gone'), 'danger')
emit('gone')
}
async function load(): Promise<void> {
const current = ++generation
errors.value = {}
failed.value = false
busy.value = false
pendingChanges.value = 0
childKey.value = newSessionKey()
activeTab.value = DEFAULT_TAB
if (props.mode === 'create' || props.childId === null) {
loading.value = false
adopt(initialValues(fields.value, enabledLocales.value))
activeTab.value = tabs.value[0]?.key ?? DEFAULT_TAB
await focusFirst()
return
}
loading.value = true
try {
const result = await api.GET('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}', {
params: { path: { ...pathParams(), child: props.childId }, header: headers() },
})
if (current !== generation) {
return
}
if (result.data) {
adopt(result.data.data, result.data.meta)
activeTab.value = tabs.value[0]?.key ?? DEFAULT_TAB
} else if (result.response.status === 404) {
gone()
return
} else {
failed.value = true
}
} catch {
if (current === generation) {
failed.value = true
}
} finally {
if (current === generation) {
loading.value = false
}
}
if (current === generation) {
await focusFirst()
}
}
watch(
() => props.open,
(open) => {
if (open) {
void load()
} else {
generation++
}
},
{ immediate: true },
)
async function setOpen(open: boolean): Promise<void> {
if (open) {
emit('update:open', true)
return
}
if (busy.value) {
return
}
if (dirty.value) {
const ok = await confirm.ask({
message: t('backend::lang.form.unsaved_confirm'),
confirmLabel: t('backend::lang.form.discard'),
danger: true,
})
if (!ok) {
return
}
}
emit('update:open', false)
}
/** 422: messages under fields, the first invalid field focused (tab switched). */
async function showErrors(details: Record<string, unknown> | undefined): Promise<void> {
errors.value = fieldErrors(details)
const invalid = fields.value.filter((field) => errors.value[field.name])
const first = invalid.find((field) => tabOf(field) === activeTab.value) ?? invalid[0]
if (!first) {
return
}
if (tabs.value.length > 0) {
activeTab.value = tabOf(first)
}
await nextTick()
focusField(idPrefix.value, first.name)
}
async function onSubmit(): Promise<void> {
if (busy.value || preview.value || loading.value || failed.value || activeUploads.value > 0) {
return
}
busy.value = true
try {
const payload = editablePayload(fields.value, values.value)
const result =
props.mode === 'create' || props.childId === null
? await api.POST('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records', {
params: { path: pathParams(), header: headers() },
body: payload,
})
: await api.PUT('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}', {
params: { path: { ...pathParams(), child: props.childId }, header: headers() },
body: payload,
})
if (result.data) {
const kind = props.mode === 'create' ? 'created' : 'updated'
errors.value = {}
pendingChanges.value = 0
emit('update:open', false)
showToast(message(kind === 'created' ? props.schema.messages.created : props.schema.messages.updated))
emit('changed', kind)
return
}
const status = result.response.status
if (status === 422) {
await showErrors(result.error?.error.details)
} else if (status === 404) {
gone()
} else {
showToast(result.error?.error.message || t('backend::lang.form.error_generic'), 'danger')
}
} catch {
showToast(t('backend::lang.form.error_generic'), 'danger')
} finally {
busy.value = false
}
}
async function onDelete(): Promise<void> {
if (busy.value || props.childId === null) {
return
}
const childId = props.childId
const ok = await confirm.ask({
message: message(props.schema.messages.deleteOneConfirm),
confirmLabel: t('backend::lang.form.delete'),
danger: true,
})
if (!ok) {
return
}
busy.value = true
try {
const result = await api.POST('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete', {
params: { path: pathParams(), header: { [SESSION_HEADER]: props.sessionKey } as { 'X-Session-Key': string } },
body: { ids: [childId] },
})
if (result.data) {
pendingChanges.value = 0
emit('update:open', false)
showToast(message(props.schema.messages.deleted, result.data.data.deleted || 1))
emit('changed', 'deleted')
return
}
if (result.response.status === 404) {
gone()
return
}
showToast(result.error?.error.message || t('backend::lang.form.error_generic'), 'danger')
} catch {
showToast(t('backend::lang.form.error_generic'), 'danger')
} finally {
busy.value = false
}
}
/** Focus goes to the first field once it is there (see load). */
function onOpenAutoFocus(event: Event): void {
event.preventDefault()
}
/** Focus goes back to the opener, which the relation manager owns. */
function onCloseAutoFocus(event: Event): void {
event.preventDefault()
emit('closed')
}
</script>
<template>
<DialogRoot :open="open" @update:open="setOpen">
<DialogPortal>
<DialogOverlay data-dialog-overlay class="fixed inset-0 z-40 bg-overlay" />
<DialogContent
data-relation-child-modal
data-dialog-content
:data-mode="mode"
aria-modal="true"
:aria-describedby="undefined"
class="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100vh-32px)] w-[calc(100%-32px)] max-w-[720px] -translate-x-1/2 -translate-y-1/2 flex-col rounded-modal bg-surface text-text shadow-pop"
@open-auto-focus="onOpenAutoFocus"
@close-auto-focus="onCloseAutoFocus"
>
<div class="flex items-start gap-3 p-6 pb-4">
<DialogTitle class="min-w-0 flex-1 text-[20px] font-bold tracking-[-0.01em]">{{ title }}</DialogTitle>
<DialogClose
data-action="close"
:aria-label="t('backend::lang.form.close')"
:disabled="busy"
class="flex size-[34px] shrink-0 items-center justify-center rounded-tab bg-subtle text-muted hover:bg-hover hover:text-text"
>
<X :size="16" aria-hidden="true" />
</DialogClose>
</div>
<div ref="body" data-child-body class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-6 pb-6">
<div
v-if="loading"
data-child-loading
aria-hidden="true"
class="grid grid-cols-1 gap-x-6 gap-y-[22px] min-[600px]:grid-cols-2"
>
<div v-for="n in 6" :key="n" class="flex flex-col gap-1.5">
<span class="block h-3 w-24 rounded-[6px] bg-skel" />
<span class="block h-input rounded-control bg-skel" />
</div>
</div>
<p
v-else-if="failed"
role="alert"
data-child-load-failed
class="rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger"
>
{{ t('backend::lang.relation.child_load_failed') }}
</p>
<template v-else>
<FormErrorBanner :errors="errors" :field-names="fieldNames" />
<FormTabs v-if="tabs.length > 0" v-model="activeTab" :tabs="tabs" :id-prefix="idPrefix" class="self-start" />
<form
:id="tabs.length > 0 ? panelDomId(idPrefix, activeIndex) : undefined"
:role="tabs.length > 0 ? 'tabpanel' : undefined"
:aria-labelledby="tabs.length > 0 ? tabDomId(idPrefix, activeIndex) : undefined"
novalidate
@submit.prevent="onSubmit"
>
<fieldset :disabled="busy || preview" class="m-0 min-w-0 border-0 p-0">
<FormGrid
:fields="panelFields"
:values="values"
:errors="errors"
:labels="labels"
:source="source"
:record-id="childId"
:id-prefix="idPrefix"
@update="update"
/>
</fieldset>
</form>
</template>
</div>
<div class="flex items-center gap-2.5 border-t border-border px-6 py-4">
<Button
v-if="mode === 'update' && canDelete && !loading && !failed"
variant="danger"
data-action="delete-child"
:icon="Trash2"
:disabled="busy"
@click="onDelete"
>
{{ t('backend::lang.form.delete') }}
</Button>
<div class="ml-auto flex gap-2.5">
<Button v-if="preview" variant="outline" data-action="cancel" @click="setOpen(false)">
{{ t('backend::lang.form.close') }}
</Button>
<template v-else>
<Button variant="ghost" data-action="cancel" :disabled="busy" @click="setOpen(false)">
{{ t('backend::lang.form.cancel') }}
</Button>
<Button
v-if="!failed"
variant="primary"
data-action="save-child"
:disabled="busy || loading || activeUploads > 0"
@click="onSubmit"
>
{{ busy ? t('backend::lang.form.saving') : submitLabel }}
</Button>
</template>
</div>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
<ConfirmDialog
:open="confirm.request.value !== null"
:message="confirm.request.value?.message ?? ''"
:confirm-label="confirm.request.value?.confirmLabel"
:danger="confirm.request.value?.danger"
@confirm="confirm.confirm"
@cancel="confirm.cancel"
/>
</template>