feat(12.2-04): edit related records in modals and defer relation work on new records
- RelationManager renders create/link/delete/unlink in declared order with one primary, opens the child modal (update or view form) or the pivot modal on row click, and deletes selected children behind a busy confirm - RelationChildModal creates and edits children with its own session key (X-Child-Session-Key) so uploads and dates work inside it - RelationPivotModal edits link details; the picker links one record with its pivot values when the relation has a pivot form - deferrable managers render on the create screen with owner id 0, the form's X-Session-Key and the pending note, and mark the form dirty - the registry resolves RelationManager lazily (child forms close an import cycle); DataTable gains openable rows and a trailing cell - relation lang keys in en and pl; rebuilt boardwalk dist
This commit is contained in:
529
admin/src/components/relation/RelationChildModal.vue
Normal file
529
admin/src/components/relation/RelationChildModal.vue
Normal file
@@ -0,0 +1,529 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, 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_PATCH, FORM_SESSION, FORM_VALUES, type FormSession } from '../form/formContext'
|
||||
import {
|
||||
DEFAULT_TAB,
|
||||
contextAllows,
|
||||
editablePayload,
|
||||
fieldErrors,
|
||||
focusField,
|
||||
initialValues,
|
||||
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 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 || 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),
|
||||
}
|
||||
provide(FORM_SESSION, session)
|
||||
provide(FORM_VALUES, computed(() => values.value))
|
||||
provide(FORM_PATCH, update)
|
||||
|
||||
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 {
|
||||
values.value = { ...record }
|
||||
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))
|
||||
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) {
|
||||
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"
|
||||
@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>
|
||||
Reference in New Issue
Block a user