- 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
548 lines
17 KiB
Vue
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>
|