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:
Jakub Zych
2026-10-02 19:56:10 +02:00
parent a0c182745e
commit 69a37456f6
20 changed files with 1698 additions and 112 deletions

View File

@@ -34,6 +34,12 @@ export type { FieldControlProps } from './control'
/** The relation manager's field type (design screen 5). */
export const RELATION_MANAGER = 'relation-manager'
// The relation manager renders child forms (FormGrid -> FormField -> this
// registry), so it sits on an import cycle with this module. It is looked up
// when a field renders, never while this module evaluates: whichever module
// of the cycle loads first, the binding is complete by then.
const lazyRenderers = new Map<string, () => Component>([[RELATION_MANAGER, () => RelationManager]])
const renderers = new Map<string, Component>([
['text', TextField],
['textarea', TextareaField],
@@ -42,7 +48,6 @@ const renderers = new Map<string, Component>([
['switch', SwitchField],
['checkbox', CheckboxField],
['relation', RelationField],
[RELATION_MANAGER, RelationManager],
['widget', WidgetField],
['partial', PartialField],
['fileupload', FileuploadField],
@@ -54,7 +59,8 @@ const selfLabelled = new Set<string>(['switch', 'checkbox', RELATION_MANAGER])
/**
* Types that need a saved record: never rendered on create (D-05, design
* screen 5).
* screen 5), except a relation manager whose field is deferrable (Phase
* 12.2, D-03): FormView keeps it and it works on owner id 0.
*/
const recordBound = new Set<string>([RELATION_MANAGER])
@@ -68,12 +74,12 @@ const valueless = new Set<string>([RELATION_MANAGER, 'widget', 'partial', 'fileu
const groupLabelledTypes = new Set<string>(['widget', 'partial', 'fileupload'])
export function rendererFor(type: string): Component {
return renderers.get(type) ?? UnsupportedField
return renderers.get(type) ?? lazyRenderers.get(type)?.() ?? UnsupportedField
}
/** Whether the SPA can edit values of this field type. */
export function isRegistered(type: string): boolean {
return renderers.has(type) && !valueless.has(type)
return (renderers.has(type) || lazyRenderers.has(type)) && !valueless.has(type)
}
/** Whether a field type renders only on an existing record (relation manager). */

View File

@@ -13,7 +13,10 @@ import CellValue from './CellValue.vue'
// (asc -> desc -> none) with aria-sort, row links to the record route, eight
// skeleton rows while loading and an `empty` slot for the empty states.
// The relation variant (design screen 5) has 56px rows, an initials avatar
// before the first column's text and the remaining columns muted.
// before the first column's text and the remaining columns muted. Phase
// 12.2: with `openable` its rows open (the first cell's text is a button and
// a row click outside other controls does the same), and `trailing` adds a
// 56px last cell filled by the `trailing` slot.
const props = withDefaults(
defineProps<{
@@ -26,6 +29,10 @@ const props = withDefaults(
sortable?: boolean
sort?: { column: string; dir: 'asc' | 'desc' } | null
variant?: 'list' | 'relation'
/** Rows open on click (relation variant): emits `open`. */
openable?: boolean
/** Adds a trailing 56px cell per row, filled by the `trailing` slot. */
trailing?: boolean
}>(),
{
loading: false,
@@ -35,9 +42,15 @@ const props = withDefaults(
sortable: false,
sort: null,
variant: 'list',
openable: false,
trailing: false,
},
)
const emit = defineEmits<{ 'update:selected': [ids: RowId[]]; sort: [column: string] }>()
const emit = defineEmits<{
'update:selected': [ids: RowId[]]
sort: [column: string]
open: [row: AdminRecord, trigger: HTMLElement | null]
}>()
const router = useRouter()
@@ -60,6 +73,11 @@ function openRow(event: MouseEvent, row: AdminRecord): void {
if (target?.closest('a, button, input, label, select')) {
return
}
if (props.openable) {
const opener = (event.currentTarget as HTMLElement | null)?.querySelector<HTMLElement>('[data-row-open]') ?? null
emit('open', row, opener)
return
}
const to = linkOf(row)
if (to) {
void router.push(to)
@@ -135,7 +153,13 @@ function cellClass(column: ListColumn, columnIndex: number): string {
return relation.value || column.relation ? 'text-muted' : ''
}
const span = computed(() => Math.max(props.columns.length + (props.selectable ? 1 : 0), 1))
const span = computed(() =>
Math.max(props.columns.length + (props.selectable ? 1 : 0) + (props.trailing ? 1 : 0), 1),
)
function openFrom(event: MouseEvent, row: AdminRecord): void {
emit('open', row, event.currentTarget as HTMLElement | null)
}
// Varied skeleton bar widths, as in the design.
const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', 'w-2/5']
@@ -182,6 +206,7 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
</button>
<template v-else>{{ column.label }}</template>
</th>
<td v-if="trailing" class="w-[56px] pr-5" />
</tr>
</thead>
<tbody :aria-busy="loading ? 'true' : undefined">
@@ -193,6 +218,7 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
<td v-for="column in columns" :key="column.key" class="px-3.5 first:pl-5 last:pr-5">
<span :class="widths[(n - 1) % widths.length]" class="block h-3 rounded-[6px] bg-skel" />
</td>
<td v-if="trailing" class="pr-5" />
</tr>
</template>
<tr v-else-if="rows.length === 0">
@@ -208,8 +234,8 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
:key="rowKey(row, index)"
:class="[
rowHeight,
isSelected(row) ? 'bg-sel' : linkOf(row) ? 'hover:bg-hover' : '',
linkOf(row) ? 'cursor-pointer' : '',
isSelected(row) ? 'bg-sel' : linkOf(row) || openable ? 'hover:bg-hover' : '',
linkOf(row) || openable ? 'cursor-pointer' : '',
]"
:aria-selected="selectable ? (isSelected(row) ? 'true' : 'false') : undefined"
class="border-b border-border transition-colors duration-150 ease-out"
@@ -241,13 +267,25 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
aria-hidden="true"
>{{ initials(row[column.key]) }}</span
>
<CellValue :column="column" :value="row[column.key]" />
<button
v-if="openable"
type="button"
data-row-open
class="min-w-0 truncate text-left font-semibold text-text"
@click="openFrom($event, row)"
>
<CellValue :column="column" :value="row[column.key]" />
</button>
<CellValue v-else :column="column" :value="row[column.key]" />
</span>
<RouterLink v-else-if="columnIndex === 0 && linkOf(row)" :to="linkOf(row)!" class="text-text no-underline">
<CellValue :column="column" :value="row[column.key]" />
</RouterLink>
<CellValue v-else :column="column" :value="row[column.key]" />
</td>
<td v-if="trailing" data-trailing class="w-[56px] pr-5">
<slot name="trailing" :row="row" />
</td>
</tr>
</template>
</tbody>

View 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>

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import { Search, UserMinus, UserPlus } from '@lucide/vue'
import { computed, inject, onBeforeUnmount, ref } from 'vue'
import { Info, Plus, Search, SlidersHorizontal, Trash2, UserMinus, UserPlus } from '@lucide/vue'
import { api } from '../../api/client'
import type { AdminRecord, ListMeta, RelationQuery, RelationSchema } from '../../api/types'
import { message, t } from '../../app/i18n'
import type { RowId } from '../../app/listQuery'
import { SESSION_HEADER, newSessionKey } from '../../app/sessionKey'
import { showToast } from '../../state/useToasts'
import DataTable from '../list/DataTable.vue'
import Pagination from '../list/Pagination.vue'
@@ -12,22 +13,46 @@ import Button from '../ui/Button.vue'
import ConfirmDialog from '../ui/ConfirmDialog.vue'
import { useConfirm } from '../ui/confirm'
import type { FieldControlProps } from '../form/control'
import { FORM_SESSION } from '../form/formContext'
import RelationChildModal from './RelationChildModal.vue'
import RelationPickerModal from './RelationPickerModal.vue'
import RelationPivotModal from './RelationPivotModal.vue'
// Relation manager field (D-05, design screen 5). Renders only for an
// existing record: it lists the linked rows from GET {id}/relations/{name}
// with the relation schema's view columns, a debounced search and row
// selection, and runs the view's toolbar buttons in declared order: `link`
// opens the candidate picker, `unlink` detaches the selected rows after a
// Relation manager field (D-05, design screen 5). It lists the linked rows
// from GET {id}/relations/{name} with the relation schema's view columns, a
// debounced search and row selection, and runs the view's toolbar buttons
// in declared order: `create` opens the child modal, `link` the candidate
// picker, `delete` deletes and `unlink` detaches the selected rows after a
// confirmation. It holds no form value; every change goes to the relation
// endpoints directly. Candidate scoping (owner, inactive users) is the
// server's: the SPA never filters rows itself.
//
// Phase 12.2 (D-03, D-11, D-12, D-14): a row opens the child modal (update,
// or the view form read-only) or the pivot modal; a belongsToMany with a
// pivot form and update gets a trailing pivot button. On the create screen
// a deferrable manager works on owner id 0 with the form's session key: the
// server holds every change until the record's first save, and each change
// marks the form dirty. On a saved record changes are immediate.
const props = defineProps<FieldControlProps>()
const SEARCH_DEBOUNCE = 300
const session = inject(FORM_SESSION, null)
const relationName = computed(() => props.field.relation || props.field.name)
const ready = computed(() => props.source != null && typeof props.recordId === 'number')
/**
* The owner id the routes use: the saved record's id, or 0 for the record
* being created when the field is deferrable and the form has a session.
*/
const ownerId = computed<number | null>(() => {
if (typeof props.recordId === 'number') {
return props.recordId
}
return session && session.recordId === 0 && props.field.deferrable ? 0 : null
})
const deferred = computed(() => ownerId.value === 0)
const ready = computed(() => props.source != null && ownerId.value !== null)
// The record form's key; a stray manager without a form still gets one.
const sessionKey = session?.key ?? newSessionKey()
const schema = ref<RelationSchema | null>(null)
const rows = ref<AdminRecord[]>([])
@@ -40,8 +65,17 @@ const sort = ref<{ column: string; dir: 'asc' | 'desc' } | null>(null)
const selected = ref<RowId[]>([])
const busy = ref(false)
const pickerOpen = ref(false)
// A function ref: a template ref inside v-for would collect an array.
const childOpen = ref(false)
const childMode = ref<'create' | 'update' | 'preview'>('create')
const childId = ref<number | null>(null)
const pivotOpen = ref(false)
const pivotChild = ref<number | null>(null)
const pivotName = ref('')
// The element that opened a dialog gets focus back when it closes. Function
// refs: a template ref inside v-for would collect an array.
let opener: HTMLElement | null = null
let linkButton: HTMLElement | null = null
let createButton: HTMLElement | null = null
const confirm = useConfirm()
let generation = 0
let searchTimer: ReturnType<typeof setTimeout> | null = null
@@ -52,8 +86,41 @@ const messages = computed(() => schema.value?.messages)
const headingId = computed(() => `${props.controlId}-heading`)
const commentId = computed(() => `${props.controlId}-comment`)
const canCreate = computed(() => buttons.value.includes('create') && (schema.value?.manageForm?.length ?? 0) > 0)
const canUpdate = computed(() => buttons.value.includes('update') && (schema.value?.manageForm?.length ?? 0) > 0)
const hasPivot = computed(
() =>
schema.value?.kind === 'belongsToMany' &&
(schema.value?.pivotForm?.length ?? 0) > 0 &&
(buttons.value.includes('link') || buttons.value.includes('update')),
)
const canPreview = computed(() => (schema.value?.viewForm?.length ?? 0) > 0)
/** What a row click opens, in the UI-SPEC order. */
const rowAction = computed<'update' | 'pivot' | 'preview' | null>(() => {
if (canUpdate.value) {
return 'update'
}
if (hasPivot.value) {
return 'pivot'
}
return canPreview.value ? 'preview' : null
})
const pivotButton = computed(() => canUpdate.value && hasPivot.value)
const selectable = computed(() => buttons.value.includes('unlink') || buttons.value.includes('delete'))
function header(): { 'X-Session-Key': string } | undefined {
return deferred.value ? ({ [SESSION_HEADER]: sessionKey } as { 'X-Session-Key': string }) : undefined
}
function pathParams() {
return { ...props.source!, id: props.recordId as number, name: relationName.value }
return { ...props.source!, id: ownerId.value as number, name: relationName.value }
}
/** On the create screen every change waits for the save: the form is dirty. */
function changed(): void {
if (deferred.value) {
session?.markDirty()
}
}
async function loadSchema(): Promise<void> {
@@ -78,7 +145,7 @@ async function loadRows(): Promise<void> {
query.dir = sort.value.dir
}
const { data } = await api.GET('/{vendor}/{plugin}/{controller}/{id}/relations/{name}', {
params: { path: pathParams(), query },
params: { path: pathParams(), query, header: header() },
})
if (current !== generation) {
return
@@ -154,6 +221,23 @@ function selectedIds(): number[] {
return selected.value.map(Number).filter((id) => Number.isInteger(id) && id > 0)
}
function idOf(row: AdminRecord): number | null {
const id = row.id
if (typeof id === 'number' && Number.isInteger(id)) {
return id
}
if (typeof id === 'string' && /^\d+$/.test(id)) {
return Number(id)
}
return null
}
function nameOf(row: AdminRecord): string {
const key = columns.value[0]?.key
const value = key ? row[key] : undefined
return typeof value === 'string' || typeof value === 'number' ? String(value) : ''
}
async function onUnlink(): Promise<void> {
const ids = selectedIds()
if (busy.value || ids.length === 0 || !schema.value) {
@@ -170,11 +254,12 @@ async function onUnlink(): Promise<void> {
busy.value = true
try {
const result = await api.POST('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink', {
params: { path: pathParams() },
params: { path: pathParams(), header: header() },
body: { ids },
})
if (result.data) {
selected.value = []
changed()
showToast(message(messages.value?.unlinked, result.data.data.removed ?? ids.length))
await loadRows()
return
@@ -187,20 +272,125 @@ async function onUnlink(): Promise<void> {
}
}
/** Runs the confirmed delete; the confirm button stays busy meanwhile. */
async function deleteSelected(ids: number[]): Promise<void> {
busy.value = true
try {
const result = await api.POST('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete', {
params: { path: pathParams(), header: header() },
body: { ids },
})
if (result.data) {
selected.value = []
changed()
showToast(message(messages.value?.deleted, result.data.data.deleted || ids.length))
await loadRows()
return
}
// The selection is kept so the admin can try again.
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> {
const ids = selectedIds()
if (busy.value || ids.length === 0 || !schema.value) {
return
}
await confirm.ask(
{
message: message(messages.value?.deleteConfirm, ids.length),
confirmLabel: message(messages.value?.deleteSelected),
danger: true,
},
() => deleteSelected(ids),
)
}
async function onLinked(count: number): Promise<void> {
changed()
showToast(message(messages.value?.linked, count))
selected.value = []
await loadRows()
}
/** Focus returns to the button that opened the picker. */
function onCreate(event: MouseEvent): void {
opener = (event.currentTarget as HTMLElement | null) ?? createButton
childMode.value = 'create'
childId.value = null
childOpen.value = true
}
function openPivot(row: AdminRecord, trigger: HTMLElement | null): void {
const id = idOf(row)
if (id === null) {
return
}
opener = trigger
pivotChild.value = id
pivotName.value = nameOf(row)
pivotOpen.value = true
}
/** Row click: update, else pivot, else read-only preview (UI-SPEC order). */
function onRowOpen(row: AdminRecord, trigger: HTMLElement | null): void {
const action = rowAction.value
const id = idOf(row)
if (action === null || id === null) {
return
}
if (action === 'pivot') {
openPivot(row, trigger)
return
}
opener = trigger
childMode.value = action
childId.value = id
childOpen.value = true
}
async function onChildChanged(): Promise<void> {
changed()
selected.value = []
await loadRows()
}
async function onPivotSaved(): Promise<void> {
changed()
await loadRows()
}
async function onGone(): Promise<void> {
selected.value = []
await loadRows()
}
/** Focus returns to the element that opened the dialog. */
function returnFocus(): void {
const target = opener && opener.isConnected ? opener : null
opener = null
target?.focus()
}
function returnToLink(): void {
linkButton?.focus()
}
function setLinkButton(instance: unknown): void {
function elementOf(instance: unknown): HTMLElement | null {
const element = (instance as { $el?: unknown } | null)?.$el
linkButton = element instanceof HTMLElement ? element : null
return element instanceof HTMLElement ? element : null
}
function setLinkButton(instance: unknown): void {
linkButton = elementOf(instance)
}
function setCreateButton(instance: unknown): void {
createButton = elementOf(instance)
}
onBeforeUnmount(() => {
@@ -218,62 +408,93 @@ void load()
:id="controlId"
data-relation-manager
:data-relation-name="relationName"
:data-deferred="deferred ? 'true' : undefined"
:aria-labelledby="headingId"
:aria-describedby="field.comment ? commentId : undefined"
tabindex="-1"
class="flex flex-col gap-4"
>
<header class="flex flex-wrap items-center gap-3">
<div class="flex min-w-0 flex-1 flex-col">
<h2 :id="headingId" class="text-[17px] font-bold tracking-[-0.01em]">
{{ schema?.label || field.label || relationName }}
</h2>
<p v-if="field.comment" :id="commentId" class="text-[13px] text-muted">{{ field.comment }}</p>
</div>
<label v-if="schema?.view.showSearch" class="relative block w-[220px] max-w-full">
<span class="sr-only">{{ t('backend::lang.list.search') }}</span>
<Search
:size="16"
class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-muted"
aria-hidden="true"
/>
<input
type="search"
data-relation-search
:value="search"
:placeholder="t('backend::lang.list.search_prompt')"
class="h-[38px] w-full rounded-control border border-transparent bg-subtle pr-3 pl-9"
@input="onSearch"
@keydown.enter.prevent
/>
</label>
<template v-for="button in buttons" :key="button">
<Button
v-if="button === 'unlink'"
data-action="unlink"
size="sm"
variant="danger"
:icon="UserMinus"
:disabled="busy || selected.length === 0"
@click="onUnlink"
>
{{ message(messages?.unlinkSelected) }}
</Button>
<Button
v-else-if="button === 'link'"
:ref="setLinkButton"
data-action="link"
size="sm"
variant="primary"
:icon="UserPlus"
:disabled="busy || !schema"
aria-haspopup="dialog"
@click="pickerOpen = true"
>
{{ message(messages?.link) }}
</Button>
</template>
</header>
<div class="flex flex-col gap-2">
<header class="flex flex-wrap items-center gap-3">
<div class="flex min-w-0 flex-1 flex-col">
<h2 :id="headingId" class="text-[17px] font-bold tracking-[-0.01em]">
{{ schema?.label || field.label || relationName }}
</h2>
<p v-if="field.comment" :id="commentId" class="text-[13px] text-muted">{{ field.comment }}</p>
</div>
<label v-if="schema?.view.showSearch" class="relative block w-[220px] max-w-full">
<span class="sr-only">{{ t('backend::lang.list.search') }}</span>
<Search
:size="16"
class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-muted"
aria-hidden="true"
/>
<input
type="search"
data-relation-search
:value="search"
:placeholder="t('backend::lang.list.search_prompt')"
class="h-[38px] w-full rounded-control border border-transparent bg-subtle pr-3 pl-9"
@input="onSearch"
@keydown.enter.prevent
/>
</label>
<template v-for="button in buttons" :key="button">
<Button
v-if="button === 'create' && canCreate"
:ref="setCreateButton"
data-action="create"
size="sm"
variant="primary"
:icon="Plus"
:disabled="busy || !schema"
aria-haspopup="dialog"
@click="onCreate"
>
{{ message(messages?.create) }}
</Button>
<Button
v-else-if="button === 'link'"
:ref="setLinkButton"
data-action="link"
size="sm"
:variant="canCreate ? 'outline' : 'primary'"
:icon="UserPlus"
:disabled="busy || !schema"
aria-haspopup="dialog"
@click="pickerOpen = true"
>
{{ message(messages?.link) }}
</Button>
<Button
v-else-if="button === 'delete'"
data-action="delete"
size="sm"
variant="danger"
:icon="Trash2"
:disabled="busy || selected.length === 0"
@click="onDelete"
>
{{ message(messages?.deleteSelected) }}
</Button>
<Button
v-else-if="button === 'unlink'"
data-action="unlink"
size="sm"
variant="danger"
:icon="UserMinus"
:disabled="busy || selected.length === 0"
@click="onUnlink"
>
{{ message(messages?.unlinkSelected) }}
</Button>
</template>
</header>
<p v-if="deferred" data-relation-pending class="flex items-center gap-2 text-[13px] text-muted">
<Info :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ t('backend::lang.relation.pending_note') }}</span>
</p>
</div>
<p v-if="failed && !loading" role="alert" class="rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger">
{{ t('backend::lang.relation.load_failed') }}
@@ -286,14 +507,28 @@ void load()
:columns="columns"
:rows="rows"
:loading="loading"
:selectable="buttons.includes('unlink')"
:selectable="selectable"
:sortable="true"
:sort="sort"
:openable="rowAction !== null"
:trailing="pivotButton"
@sort="onSort"
@open="onRowOpen"
>
<template #empty>
<span data-relation-empty class="text-muted">{{ message(messages?.empty) }}</span>
</template>
<template #trailing="{ row }">
<button
type="button"
data-action="edit-pivot"
:aria-label="message(messages?.editPivot, undefined, { name: nameOf(row) })"
class="flex size-8 items-center justify-center rounded-pager text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text"
@click="openPivot(row, $event.currentTarget as HTMLElement)"
>
<SlidersHorizontal :size="16" aria-hidden="true" />
</button>
</template>
</DataTable>
<Pagination
v-if="meta && meta.last_page > 1"
@@ -309,10 +544,42 @@ void load()
v-if="schema && buttons.includes('link')"
v-model:open="pickerOpen"
:source="source!"
:record-id="recordId as number"
:record-id="ownerId as number"
:relation="relationName"
:schema="schema"
:session-key="deferred ? sessionKey : null"
@linked="onLinked"
@closed="returnToLink"
/>
<RelationChildModal
v-if="schema && (canCreate || rowAction === 'update' || rowAction === 'preview')"
v-model:open="childOpen"
:source="source!"
:owner-id="ownerId as number"
:relation="relationName"
:schema="schema"
:mode="childMode"
:child-id="childId"
:session-key="sessionKey"
:can-delete="buttons.includes('delete')"
@changed="onChildChanged"
@gone="onGone"
@closed="returnFocus"
/>
<RelationPivotModal
v-if="schema && hasPivot"
v-model:open="pivotOpen"
:source="source!"
:owner-id="ownerId as number"
:relation="relationName"
:schema="schema"
:child-id="pivotChild"
:child-name="pivotName"
:session-key="sessionKey"
@saved="onPivotSaved"
@gone="onGone"
@closed="returnFocus"
/>
@@ -321,6 +588,7 @@ void load()
:message="confirm.request.value?.message ?? ''"
:confirm-label="confirm.request.value?.confirmLabel"
:danger="confirm.request.value?.danger"
:busy="confirm.busy.value"
@confirm="confirm.confirm"
@cancel="confirm.cancel"
/>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import {
DialogClose,
DialogContent,
@@ -13,7 +13,11 @@ import { Check, ChevronLeft, ChevronRight, Search, X } from '@lucide/vue'
import { api } from '../../api/client'
import type { AdminRecord, ControllerParams, ListMeta, RelationSchema } from '../../api/types'
import { message, t } from '../../app/i18n'
import { SESSION_HEADER } from '../../app/sessionKey'
import { showToast } from '../../state/useToasts'
import FormErrorBanner from '../form/FormErrorBanner.vue'
import FormGrid from '../form/FormGrid.vue'
import { editablePayload, fieldErrors, focusField, initialValues } from '../form/formState'
// Candidate picker (design screen 5, "Dodaj redaktora"): a Reka Dialog
// (role dialog, aria-modal, focus trapped, Esc closes) over GET
@@ -21,12 +25,19 @@ import { showToast } from '../../state/useToasts'
// ids kept across pages and search changes and cleared on close. "Dodaj (N)"
// POSTs link with the selected ids. Candidates are shown exactly as the
// server returns them: owner exclusion and scoping are server-side.
// Phase 12.2 (D-14): when the relation has a pivot form the picker selects
// one record, Next shows the pivot form in the same dialog, and Add link
// posts `{ids: [id], pivot: {...}}`; Back returns to the list with its
// search, page and selection. On an unsaved record (id 0) every call sends
// the form's session key.
const props = defineProps<{
open: boolean
source: ControllerParams
recordId: number
relation: string
schema: RelationSchema
/** The record form's session key; sent when the owner is unsaved. */
sessionKey?: string | null
}>()
const emit = defineEmits<{
'update:open': [open: boolean]
@@ -45,6 +56,12 @@ const loading = ref(false)
const busy = ref(false)
const chosen = ref<Set<number>>(new Set())
const searchInput = ref<HTMLInputElement | null>(null)
// Pivot link flow: step 1 picks one record, step 2 fills the pivot form.
const step = ref<1 | 2>(1)
const chosenRow = ref<AdminRecord | null>(null)
const pivotValues = ref<AdminRecord>({})
const pivotErrors = ref<Record<string, string[]>>({})
const stepTwo = ref<HTMLElement | null>(null)
let generation = 0
let searchTimer: ReturnType<typeof setTimeout> | null = null
@@ -53,6 +70,15 @@ const primary = computed(() => columns.value[0]?.key ?? '')
const secondary = computed(() => columns.value[1]?.key ?? '')
const count = computed(() => chosen.value.size)
const listId = `relation-${props.relation}-candidates`
const pivotFields = computed(() => props.schema.pivotForm ?? [])
const pivotFieldNames = computed(() => pivotFields.value.map((field) => field.name))
/** With a pivot form one record is linked at a time, with its pivot values. */
const single = computed(() => pivotFields.value.length > 0)
const pivotPrefix = computed(() => `link-${props.relation}`)
function header(): { 'X-Session-Key': string } | undefined {
return props.sessionKey ? ({ [SESSION_HEADER]: props.sessionKey } as { 'X-Session-Key': string }) : undefined
}
const range = computed(() => {
const m = meta.value
@@ -100,6 +126,7 @@ async function fetchPage(): Promise<void> {
params: {
path: { ...props.source, id: props.recordId, name: props.relation },
query: { search: term === '' ? undefined : term, page: page.value, per_page: PER_PAGE },
header: header(),
},
})
if (current !== generation) {
@@ -131,6 +158,10 @@ function reset(): void {
meta.value = null
loading.value = false
chosen.value = new Set()
step.value = 1
chosenRow.value = null
pivotValues.value = {}
pivotErrors.value = {}
}
watch(
@@ -176,6 +207,12 @@ function toggle(row: AdminRecord): void {
if (id === null) {
return
}
if (single.value) {
// Radio behaviour: the chosen record is replaced, never added to.
chosen.value = new Set([id])
chosenRow.value = row
return
}
const next = new Set(chosen.value)
if (next.has(id)) {
next.delete(id)
@@ -185,12 +222,51 @@ function toggle(row: AdminRecord): void {
chosen.value = next
}
/** Step 2 of the pivot flow: the chosen record and its pivot form. */
async function goNext(): Promise<void> {
if (!single.value || chosen.value.size === 0 || !chosenRow.value) {
return
}
step.value = 2
pivotValues.value = initialValues(pivotFields.value)
pivotErrors.value = {}
await nextTick()
stepTwo.value?.querySelector<HTMLElement>('input, select, textarea, button, [contenteditable="true"]')?.focus()
}
/** Back to the list, keeping its search, page and selection. */
async function goBack(): Promise<void> {
if (busy.value) {
return
}
step.value = 1
pivotErrors.value = {}
await nextTick()
const chosenOption = document.querySelector<HTMLElement>(`#${listId} [aria-selected="true"]`)
;(chosenOption ?? searchInput.value)?.focus()
}
function updatePivot(name: string, value: unknown): void {
pivotValues.value = { ...pivotValues.value, [name]: value }
if (pivotErrors.value[name]) {
const next = { ...pivotErrors.value }
delete next[name]
pivotErrors.value = next
}
}
function onOptionKeydown(event: KeyboardEvent, row: AdminRecord, index: number): void {
switch (event.key) {
case ' ':
event.preventDefault()
toggle(row)
break
case 'Enter':
event.preventDefault()
toggle(row)
if (single.value) {
void goNext()
}
break
case 'ArrowDown':
case 'ArrowUp': {
@@ -212,17 +288,42 @@ async function onConfirm(): Promise<void> {
if (busy.value || ids.length === 0) {
return
}
const pivot = single.value
busy.value = true
try {
const result = await api.POST('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link', {
params: { path: { ...props.source, id: props.recordId, name: props.relation } },
body: { ids },
params: { path: { ...props.source, id: props.recordId, name: props.relation }, header: header() },
body: pivot ? { ids: ids.slice(0, 1), pivot: editablePayload(pivotFields.value, pivotValues.value) } : { ids },
})
if (result.data) {
setOpen(false)
emit('linked', result.data.data.linked ?? ids.length)
return
}
const status = result.response.status
if (pivot && status === 422) {
// Pivot errors stay in the dialog, under their fields.
pivotErrors.value = fieldErrors(result.error?.error.details)
const first = pivotFields.value.find((field) => pivotErrors.value[field.name])
if (first) {
await nextTick()
focusField(pivotPrefix.value, first.name)
}
return
}
if (pivot && status === 404) {
// The chosen record is gone: back to the list, reloaded.
showToast(t('backend::lang.relation.child_gone'), 'danger')
chosen.value = new Set()
chosenRow.value = null
step.value = 1
void fetchPage()
return
}
if (pivot) {
showToast(t('backend::lang.form.error_generic'), 'danger')
return
}
showToast(result.error?.error.message || t('backend::lang.form.error_generic'), 'danger')
} catch {
showToast(t('backend::lang.form.error_generic'), 'danger')
@@ -267,8 +368,16 @@ const pagerButton =
>
<div class="flex items-start gap-3">
<div class="flex min-w-0 flex-1 flex-col gap-1">
<DialogTitle class="text-[20px] font-bold tracking-[-0.01em]">{{ message(schema.messages.link) }}</DialogTitle>
<DialogDescription class="text-muted">{{ message(schema.messages.linkHint) }}</DialogDescription>
<DialogTitle class="text-[20px] font-bold tracking-[-0.01em]">{{
step === 2 ? message(schema.messages.pivotTitle) : message(schema.messages.link)
}}</DialogTitle>
<DialogDescription v-if="step === 1" class="text-muted">{{ message(schema.messages.linkHint) }}</DialogDescription>
<DialogDescription v-else class="flex min-w-0 flex-col" data-chosen-record>
<span class="truncate font-semibold text-text" :title="text(chosenRow ?? {}, primary)">{{
text(chosenRow ?? {}, primary)
}}</span>
<span v-if="secondary" class="truncate text-[13px] text-muted">{{ text(chosenRow ?? {}, secondary) }}</span>
</DialogDescription>
</div>
<DialogClose
data-action="close"
@@ -279,7 +388,24 @@ const pagerButton =
</DialogClose>
</div>
<label class="relative block">
<div v-if="step === 2" ref="stepTwo" data-pivot-step class="flex min-h-0 flex-col gap-4 overflow-y-auto">
<FormErrorBanner :errors="pivotErrors" :field-names="pivotFieldNames" />
<form novalidate @submit.prevent="onConfirm">
<fieldset :disabled="busy" class="m-0 min-w-0 border-0 p-0">
<FormGrid
:fields="pivotFields"
:values="pivotValues"
:errors="pivotErrors"
:source="source"
:record-id="null"
:id-prefix="pivotPrefix"
@update="updatePivot"
/>
</fieldset>
</form>
</div>
<label v-show="step === 1" class="relative block">
<span class="sr-only">{{ t('backend::lang.list.search') }}</span>
<Search
:size="18"
@@ -299,9 +425,10 @@ const pagerButton =
</label>
<ul
v-show="step === 1"
:id="listId"
role="listbox"
aria-multiselectable="true"
:aria-multiselectable="single ? 'false' : 'true'"
:aria-label="message(schema.messages.link)"
:aria-busy="loading ? 'true' : undefined"
class="flex min-h-0 flex-col gap-2 overflow-y-auto"
@@ -328,6 +455,16 @@ const pagerButton =
<span v-if="secondary" class="truncate text-[13px] text-muted">{{ text(row, secondary) }}</span>
</span>
<span
v-if="single"
data-radio
:class="isChosen(row) ? 'border-primary' : 'border-border-strong bg-surface'"
class="flex size-[18px] shrink-0 items-center justify-center rounded-full border"
aria-hidden="true"
>
<span v-if="isChosen(row)" class="block size-2.5 rounded-full bg-primary" />
</span>
<span
v-else
:class="isChosen(row) ? 'border-primary bg-primary text-on-primary' : 'border-border-strong bg-surface'"
class="flex size-[18px] shrink-0 items-center justify-center rounded-checkbox border"
aria-hidden="true"
@@ -349,7 +486,7 @@ const pagerButton =
</ul>
<nav
v-if="meta && meta.total > 0"
v-if="step === 1 && meta && meta.total > 0"
:aria-label="t('backend::lang.list.pagination')"
class="flex items-center gap-2 text-[13px] text-muted"
>
@@ -377,20 +514,44 @@ const pagerButton =
</nav>
<div class="flex gap-2.5">
<button
v-if="step === 2"
type="button"
data-action="back"
:disabled="busy"
class="inline-flex h-button flex-1 items-center justify-center rounded-control border border-border-strong bg-surface font-semibold hover:bg-hover disabled:cursor-not-allowed disabled:opacity-60"
@click="goBack"
>
{{ t('backend::lang.relation.back') }}
</button>
<DialogClose
v-else
data-action="cancel"
class="inline-flex h-button flex-1 items-center justify-center rounded-control border border-border-strong bg-surface font-semibold hover:bg-hover"
>
{{ t('backend::lang.form.cancel') }}
</DialogClose>
<button
v-if="single && step === 1"
type="button"
data-action="next"
:disabled="count === 0"
class="inline-flex h-button flex-1 items-center justify-center rounded-control bg-primary font-semibold text-on-primary hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
@click="goNext"
>
{{ t('backend::lang.relation.next') }}
</button>
<button
v-else
type="button"
data-action="confirm-link"
:disabled="busy || count === 0"
:aria-busy="busy ? 'true' : undefined"
class="inline-flex h-button flex-1 items-center justify-center rounded-control bg-primary font-semibold text-on-primary hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
@click="onConfirm"
>
{{ t('backend::lang.relation.add_count', { count }) }}
<template v-if="single">{{ busy ? t('backend::lang.form.saving') : message(schema.messages.linkSubmit) }}</template>
<template v-else>{{ t('backend::lang.relation.add_count', { count }) }}</template>
</button>
</div>
</DialogContent>

View File

@@ -0,0 +1,288 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import {
DialogClose,
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
} from 'reka-ui'
import { X } from '@lucide/vue'
import { api } from '../../api/client'
import type { AdminRecord, ControllerParams, RelationSchema } from '../../api/types'
import { message, t } from '../../app/i18n'
import { SESSION_HEADER } from '../../app/sessionKey'
import FormErrorBanner from '../form/FormErrorBanner.vue'
import FormGrid from '../form/FormGrid.vue'
import { editablePayload, fieldErrors, focusField, snapshot } from '../form/formState'
import ConfirmDialog from '../ui/ConfirmDialog.vue'
import { useConfirm } from '../ui/confirm'
import { showToast } from '../../state/useToasts'
// Link details (Phase 12.2, D-14; UI-SPEC section 4, pivot modal). Edits
// the pivot form (pivot.form) values of one belongsToMany link through
// GET/PUT .../relations/{name}/pivot/{child}. Only the pivot form's fields
// are sent; server-owned pivot columns never appear. A 422 stays in the
// dialog, a 404 closes it and reloads the list, any other failure keeps the
// input with a danger toast. Closing with changes asks first.
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
childId: number | null
/** The related record's display name. */
childName: string
/** The record form's session key (X-Session-Key). */
sessionKey: string
}>()
const emit = defineEmits<{
'update:open': [open: boolean]
saved: []
gone: []
closed: []
}>()
const values = ref<AdminRecord>({})
const errors = ref<Record<string, string[]>>({})
const loading = ref(false)
const failed = ref(false)
const busy = ref(false)
const saved = ref('')
const confirm = useConfirm()
let generation = 0
const idPrefix = computed(() => `pivot-${props.relation}`)
const fields = computed(() => props.schema.pivotForm ?? [])
const fieldNames = computed(() => fields.value.map((field) => field.name))
const dirty = computed(
() => !loading.value && !failed.value && snapshot(editablePayload(fields.value, values.value)) !== saved.value,
)
function header() {
return { [SESSION_HEADER]: props.sessionKey } as { 'X-Session-Key': string }
}
function path(child: number) {
return { ...props.source, id: props.ownerId, name: props.relation, child }
}
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 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
values.value = {}
saved.value = snapshot({})
if (props.childId === null) {
return
}
loading.value = true
try {
const result = await api.GET('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}', {
params: { path: path(props.childId), header: header() },
})
if (current !== generation) {
return
}
if (result.data) {
values.value = { ...result.data.data }
saved.value = snapshot(editablePayload(fields.value, values.value))
} else if (result.response.status === 404) {
gone()
} else {
failed.value = true
}
} catch {
if (current === generation) {
failed.value = true
}
} finally {
if (current === generation) {
loading.value = false
}
}
}
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)
}
async function onSubmit(): Promise<void> {
if (busy.value || loading.value || failed.value || props.childId === null) {
return
}
busy.value = true
try {
const result = await api.PUT('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}', {
params: { path: path(props.childId), header: header() },
body: editablePayload(fields.value, values.value),
})
if (result.data) {
errors.value = {}
saved.value = snapshot(editablePayload(fields.value, values.value))
emit('update:open', false)
showToast(message(props.schema.messages.pivotSaved))
emit('saved')
return
}
const status = result.response.status
if (status === 422) {
errors.value = fieldErrors(result.error?.error.details)
const first = fields.value.find((field) => errors.value[field.name])
if (first) {
await nextTick()
focusField(idPrefix.value, first.name)
}
} else if (status === 404) {
gone()
} else {
showToast(t('backend::lang.form.error_generic'), 'danger')
}
} catch {
showToast(t('backend::lang.form.error_generic'), 'danger')
} finally {
busy.value = false
}
}
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-pivot-modal
data-dialog-content
aria-modal="true"
class="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100vh-32px)] w-[calc(100%-32px)] max-w-[560px] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-y-auto rounded-modal bg-surface p-6 text-text shadow-pop"
@close-auto-focus="onCloseAutoFocus"
>
<div class="flex items-start gap-3">
<div class="flex min-w-0 flex-1 flex-col gap-1">
<DialogTitle class="text-[20px] font-bold tracking-[-0.01em]">{{ message(schema.messages.pivotTitle) }}</DialogTitle>
<DialogDescription class="truncate text-muted" :title="childName">{{ childName }}</DialogDescription>
</div>
<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
v-if="loading"
data-pivot-loading
aria-hidden="true"
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>
<p v-else-if="failed" role="alert" class="rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger">
{{ t('backend::lang.relation.child_load_failed') }}
</p>
<form v-else class="flex flex-col gap-4" novalidate @submit.prevent="onSubmit">
<FormErrorBanner :errors="errors" :field-names="fieldNames" />
<fieldset :disabled="busy" class="m-0 min-w-0 border-0 p-0">
<FormGrid
:fields="fields"
:values="values"
:errors="errors"
:source="source"
:record-id="null"
:id-prefix="idPrefix"
@update="update"
/>
</fieldset>
</form>
<div class="flex gap-2.5">
<button
type="button"
data-action="cancel"
:disabled="busy"
class="inline-flex h-button flex-1 items-center justify-center rounded-control border border-border-strong bg-surface font-semibold hover:bg-hover disabled:cursor-not-allowed disabled:opacity-60"
@click="setOpen(false)"
>
{{ t('backend::lang.form.cancel') }}
</button>
<button
type="button"
data-action="save-pivot"
:disabled="busy || loading || failed"
class="inline-flex h-button flex-1 items-center justify-center rounded-control bg-primary font-semibold text-on-primary hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
@click="onSubmit"
>
{{ busy ? t('backend::lang.form.saving') : message(schema.messages.pivotSubmit) }}
</button>
</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>

View File

@@ -8,12 +8,14 @@ import {
AlertDialogRoot,
AlertDialogTitle,
} from 'reka-ui'
import { LoaderCircle } from '@lucide/vue'
import { t } from '../../app/i18n'
// Confirmation (Reka AlertDialog: role alertdialog, focus trapped, Esc
// cancels, focus returns to the trigger). Text only: messages come from the
// server and are never rendered as HTML.
withDefaults(
// server and are never rendered as HTML. While `busy` (an action started by
// the confirm is running) both buttons are disabled and the dialog stays.
const props = withDefaults(
defineProps<{
open: boolean
message: string
@@ -21,13 +23,17 @@ withDefaults(
confirmLabel?: string
cancelLabel?: string
danger?: boolean
busy?: boolean
}>(),
{ title: undefined, confirmLabel: undefined, cancelLabel: undefined, danger: false },
{ title: undefined, confirmLabel: undefined, cancelLabel: undefined, danger: false, busy: false },
)
const emit = defineEmits<{ 'update:open': [open: boolean]; confirm: []; cancel: [] }>()
// Closing any other way than the confirm button (Esc, Anuluj) cancels.
function onOpenChange(open: boolean): void {
if (!open && props.busy) {
return
}
emit('update:open', open)
if (!open) {
emit('cancel')
@@ -35,6 +41,9 @@ function onOpenChange(open: boolean): void {
}
function onConfirm(): void {
if (props.busy) {
return
}
emit('confirm')
emit('update:open', false)
}
@@ -55,17 +64,21 @@ function onConfirm(): void {
<div class="flex gap-2.5">
<AlertDialogCancel
data-action="cancel"
class="inline-flex h-button flex-1 items-center justify-center rounded-control border border-border-strong bg-surface font-semibold hover:bg-hover"
:disabled="busy"
class="inline-flex h-button flex-1 items-center justify-center rounded-control border border-border-strong bg-surface font-semibold hover:bg-hover disabled:cursor-not-allowed disabled:opacity-60"
>
{{ cancelLabel ?? t('backend::lang.form.cancel') }}
</AlertDialogCancel>
<button
type="button"
data-action="confirm"
:disabled="busy"
:aria-busy="busy ? 'true' : undefined"
:class="danger ? 'bg-danger text-white' : 'bg-primary text-on-primary'"
class="inline-flex h-button flex-1 items-center justify-center rounded-control font-semibold hover:opacity-90"
class="inline-flex h-button flex-1 items-center justify-center gap-2 rounded-control font-semibold hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
@click="onConfirm"
>
<LoaderCircle v-if="busy" :size="16" class="animate-spin" aria-hidden="true" />
{{ confirmLabel ?? t('backend::lang.form.confirm') }}
</button>
</div>

View File

@@ -1,5 +1,8 @@
// Promise-based confirmation for ConfirmDialog: `await ask(text)` resolves
// true on confirm and false on Anuluj, Esc or a second request.
// true on confirm and false on Anuluj, Esc or a second request. With an
// action, the dialog stays open and busy while the action runs after the
// confirm, and closes when it settles (a toolbar delete shows its progress
// on the confirm button).
import { ref } from 'vue'
export interface ConfirmRequest {
@@ -10,22 +13,49 @@ export interface ConfirmRequest {
export function useConfirm() {
const request = ref<ConfirmRequest | null>(null)
const busy = ref(false)
let resolver: ((ok: boolean) => void) | null = null
let action: (() => Promise<unknown>) | null = null
function settle(ok: boolean): void {
const resolve = resolver
resolver = null
action = null
request.value = null
resolve?.(ok)
}
function ask(next: ConfirmRequest): Promise<boolean> {
function ask(next: ConfirmRequest, run?: () => Promise<unknown>): Promise<boolean> {
settle(false)
request.value = next
action = run ?? null
return new Promise<boolean>((resolve) => {
resolver = resolve
})
}
return { request, ask, confirm: () => settle(true), cancel: () => settle(false) }
async function confirm(): Promise<void> {
if (busy.value) {
return
}
const run = action
if (run) {
busy.value = true
try {
await run()
} finally {
busy.value = false
}
}
settle(true)
}
function cancel(): void {
if (busy.value) {
return
}
settle(false)
}
return { request, busy, ask, confirm, cancel }
}

View File

@@ -14,7 +14,7 @@ import FormErrorBanner from '../components/form/FormErrorBanner.vue'
import FormGrid from '../components/form/FormGrid.vue'
import FormTabs from '../components/form/FormTabs.vue'
import { FORM_ASSETS, FORM_LOCALE, FORM_PATCH, FORM_SESSION, FORM_VALUES } from '../components/form/formContext'
import { needsRecord } from '../components/form/registry'
import { RELATION_MANAGER, needsRecord } from '../components/form/registry'
import {
DEFAULT_TAB,
contextAllows,
@@ -82,9 +82,13 @@ const revision = ref(0)
// A relation manager needs a saved record: on create it is dropped with its
// tab even when the YAML forgets `context: update` (D-05, design screen 5).
// A deferrable one (Phase 12.2, D-03) stays: it works on owner id 0 with the
// form's session key and the save commits its changes.
const fields = computed(() =>
(schema.value?.fields ?? []).filter(
(field) => contextAllows(field, mode) && (mode === 'update' || !needsRecord(field.type)),
(field) =>
contextAllows(field, mode) &&
(mode === 'update' || !needsRecord(field.type) || (field.type === RELATION_MANAGER && !!field.deferrable)),
),
)
const fieldNames = computed(() => fields.value.map((field) => field.name))

View File

@@ -38,6 +38,13 @@
"mode": "crop"
},
"useCaption": true
},
{
"name": "parts",
"label": "Parts",
"type": "relation-manager",
"relation": "parts",
"deferrable": true
}
],
"messages": {

View File

@@ -150,3 +150,77 @@ describe('relation manager (D-05, design screen 5)', () => {
expect(wrapper.find('[data-action="unlink"]').attributes('disabled')).toBeUndefined()
})
})
describe('relation manager child editing (Phase 12.2, D-11, D-12)', () => {
const editable = {
...relationSchemaFixture,
data: {
...relationSchemaFixture.data,
view: { ...relationSchemaFixture.data.view, toolbarButtons: ['create', 'update', 'delete', 'link'] },
manageForm: [{ name: 'name', label: 'Name', type: 'text' }],
},
}
const RECORDS = `${LINKED}/records`
const DELETE = `${LINKED}/delete`
it('renders the toolbar in order with create as the only primary button', async () => {
const { wrapper } = await mountManager({ [`GET ${SCHEMA}`]: { body: editable } })
const actions = wrapper.findAll('header [data-action]').map((button) => button.attributes('data-action'))
expect(actions).toEqual(['create', 'delete', 'link'])
expect(wrapper.find('[data-action="link"]').classes()).not.toContain('bg-primary')
expect(wrapper.find('[data-action="create"]').classes()).toContain('bg-primary')
})
it('opens the child modal in update mode from the row button and saves the child', async () => {
const first = linkedFixture.all.data[0]!
const { calls } = await mountManager({
[`GET ${SCHEMA}`]: { body: editable },
[`GET ${RECORDS}/${first.id}`]: { body: { data: { ...first }, meta: { labels: {} } } },
[`PUT ${RECORDS}/${first.id}`]: { body: { data: { ...first }, meta: { labels: {} } } },
})
;(document.body.querySelector('[data-row-open]') as HTMLButtonElement).click()
await flushPromises()
const modal = document.body.querySelector<HTMLElement>('[data-relation-child-modal]')!
expect(modal.dataset.mode).toBe('update')
const input = modal.querySelector<HTMLInputElement>('#child-members-name')!
expect(input.value).toBe(String(first.name))
input.value = 'Renamed'
input.dispatchEvent(new Event('input'))
await flushPromises()
;(modal.querySelector('[data-action="save-child"]') as HTMLButtonElement).click()
await flushPromises()
const [put] = requestsTo(calls, 'PUT', `${RECORDS}/${first.id}`)
expect(await put!.clone().json()).toEqual({ name: 'Renamed' })
expect(put!.headers.get('X-Child-Session-Key')).toMatch(/^[A-Za-z0-9_-]{43}$/)
expect(document.body.querySelector('[data-relation-child-modal]')).toBeNull()
})
it('closes, toasts and reloads when the child is gone', async () => {
const first = linkedFixture.all.data[0]!
const { calls } = await mountManager({
[`GET ${SCHEMA}`]: { body: editable },
[`GET ${RECORDS}/${first.id}`]: failure(404, 'Not found'),
})
;(document.body.querySelector('[data-row-open]') as HTMLButtonElement).click()
await flushPromises()
expect(document.body.querySelector('[data-relation-child-modal]')).toBeNull()
expect(toasts.value[0]).toMatchObject({ text: 'backend::lang.relation.child_gone', tone: 'danger' })
expect(requestsTo(calls, 'GET', LINKED)).toHaveLength(2)
})
it('deletes the selected children after a confirmation and keeps them on failure', async () => {
const { wrapper, calls } = await mountManager({
[`GET ${SCHEMA}`]: { body: editable },
[`POST ${DELETE}`]: failure(422, 'Refused'),
})
expect(wrapper.find('[data-action="delete"]').attributes('disabled')).toBeDefined()
await wrapper.find('tbody [role="checkbox"]').trigger('click')
await wrapper.find('[data-action="delete"]').trigger('click')
await flushPromises()
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
await flushPromises()
expect(requestsTo(calls, 'POST', DELETE)).toHaveLength(1)
expect(toasts.value[0]).toMatchObject({ text: 'Refused', tone: 'danger' })
expect(wrapper.find('tbody [role="checkbox"]').attributes('aria-checked')).toBe('true')
})
})

View File

@@ -158,3 +158,68 @@ describe('relation picker modal', () => {
expect(queryOf(requestsTo(calls, 'GET', CANDIDATES).at(-1)!).get('page')).toBe('1')
})
})
describe('pivot link step (Phase 12.2, D-14)', () => {
const pivotSchema = {
...relationSchemaFixture.data,
pivotForm: [{ name: 'role', label: 'Role', type: 'text' }],
}
async function openPivot(overrides: Record<string, Route> = {}) {
const calls = mockApi(routes(overrides))
const wrapper = mount(RelationPickerModal, {
props: { open: true, source, recordId: 1, relation: 'members', schema: pivotSchema },
attachTo: document.body,
})
await flushPromises()
await wait(0)
await flushPromises()
return { wrapper, calls }
}
const nextButton = () => dialog()!.querySelector<HTMLButtonElement>('[data-action="next"]')!
it('selects one record, asks for its pivot values and links it with them', async () => {
const { wrapper, calls } = await openPivot({ [`POST ${LINK}`]: { body: { data: { linked: 1 }, meta: {} } } })
expect(dialog()!.querySelector('[role="listbox"]')!.getAttribute('aria-multiselectable')).toBe('false')
expect(nextButton().disabled).toBe(true)
await click(options()[0])
await click(options()[1])
expect(options().filter((option) => option.getAttribute('aria-selected') === 'true')).toHaveLength(1)
await click(nextButton())
expect(dialog()!.querySelector('[data-pivot-step]')).not.toBeNull()
const role = dialog()!.querySelector<HTMLInputElement>('#link-members-role')!
role.value = 'editor'
role.dispatchEvent(new Event('input'))
await flushPromises()
await click(confirmButton())
const [post] = requestsTo(calls, 'POST', LINK)
expect(await post!.clone().json()).toEqual({ ids: [candidatesFixture.page1.data[1]!.id], pivot: { role: 'editor' } })
expect(wrapper.emitted('linked')).toEqual([[1]])
})
it('goes back to the list with the selection kept', async () => {
await openPivot()
await click(options()[1])
await click(nextButton())
await click(dialog()!.querySelector('[data-action="back"]'))
expect(dialog()!.querySelector('[data-pivot-step]')).toBeNull()
expect(options()[1]!.getAttribute('aria-selected')).toBe('true')
})
it('keeps pivot errors in the dialog on a 422', async () => {
const { wrapper } = await openPivot({
[`POST ${LINK}`]: {
status: 422,
body: { error: { code: 'validation_failed', message: 'Invalid', details: { role: ['The role is invalid.'] } } },
},
})
await click(options()[0])
await click(nextButton())
await click(confirmButton())
expect(dialog()!.querySelector('[data-pivot-step]')!.textContent).toContain('The role is invalid.')
expect(wrapper.emitted('update:open')).toBeUndefined()
})
})

View File

@@ -4,17 +4,20 @@
// key so the server can attach the files. The upload XMLHttpRequest is
// replaced by a fake the test answers by hand. The datepicker part runs in a
// fixed non-UTC zone: a stored UTC instant shows as local time and goes back
// unchanged, a date goes out as YYYY-MM-DD.
// unchanged, a date goes out as YYYY-MM-DD. A deferrable hasMany manager
// on the create screen works on owner id 0 with the same key, and its child
// modal adds its own key for the child's save.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, type DOMWrapper, type VueWrapper } from '@vue/test-utils'
import { resetLocalTimeZone, setLocalTimeZone } from '@internationalized/date'
import { setBundle } from '../../src/app/i18n'
import { SESSION_HEADER } from '../../src/app/sessionKey'
import type { RelationSchema } from '../../src/api/types'
import { CHILD_SESSION_HEADER, SESSION_HEADER } from '../../src/app/sessionKey'
import { clearUser } from '../../src/state/useAuth'
import { setNavigation } from '../../src/state/useNavigation'
import { clearToasts } from '../../src/state/useToasts'
import lang from '../fixtures/lang.json'
import { deferredFormSchemaFixture } from '../fixtures/typed'
import { deferredFormSchemaFixture, relationSchemaFixture } from '../fixtures/typed'
import { API, mountApp, requestsTo, type Route } from '../helpers'
const BASE = `${API}/acme/demo/gadgets`
@@ -81,6 +84,18 @@ function fileItem(id: number, name: string, pending: boolean) {
const created = { data: { id: 5, name: 'Box' }, meta: { labels: {} } }
// A hasMany of parts: create and delete in the toolbar, a one-field manage form.
const partsSchema: RelationSchema = {
...relationSchemaFixture.data,
name: 'parts',
label: 'Parts',
kind: 'hasMany',
deferrable: true,
view: { ...relationSchemaFixture.data.view, toolbarButtons: ['create', 'delete'], showSearch: false },
manageForm: [{ name: 'title', label: 'Title', type: 'text', required: true }],
}
const noParts = { data: [], meta: { page: 1, per_page: 20, total: 0, last_page: 1 } }
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${BASE}/schema/form`]: { body: deferredFormSchemaFixture },
@@ -88,6 +103,10 @@ function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
[`POST ${BASE}`]: { status: 201, body: created },
[`GET ${BASE}/5`]: { body: created },
[`GET ${BASE}/5/files/photos`]: { body: { data: [fileItem(41, 'box.png', false)], meta: {} } },
[`GET ${BASE}/schema/relation/parts`]: { body: { data: partsSchema, meta: {} } },
[`GET ${BASE}/0/relations/parts`]: { body: noParts },
[`GET ${BASE}/5/relations/parts`]: { body: noParts },
[`GET ${BASE}/7/relations/parts`]: { body: noParts },
...overrides,
}
}
@@ -307,3 +326,77 @@ describe('datepicker values in a fixed time zone', () => {
expect((await put!.clone().json()).starts_at).toBeNull()
})
})
describe('relation manager on a new record', () => {
it('creates a child on owner 0 with the form key and its own key, turns the form dirty and saves with the key', async () => {
const { wrapper, calls } = await mountApp(
'/acme/demo/gadgets/create',
routes({
[`POST ${BASE}/0/relations/parts/records`]: {
status: 201,
body: { data: { id: 90, title: 'Bolt' }, meta: { labels: {} } },
},
}),
{ attach: true },
)
const manager = wrapper.find('[data-relation-manager]')
expect(manager.attributes('data-deferred')).toBe('true')
expect(manager.find('[data-relation-pending]').text()).toContain('backend::lang.relation.pending_note')
const [list] = requestsTo(calls, 'GET', `${BASE}/0/relations/parts`)
const key = list!.headers.get(SESSION_HEADER) ?? ''
expect(key).toMatch(KEY_PATTERN)
expect(new URL(list!.url).search).not.toContain(key)
// create is the toolbar's only primary; delete waits for a selection.
expect(manager.find('[data-action="delete"]').attributes('disabled')).toBeDefined()
await manager.find('[data-action="create"]').trigger('click')
await flushPromises()
const modal = document.body.querySelector<HTMLElement>('[data-relation-child-modal]')
expect(modal).not.toBeNull()
expect(modal!.dataset.mode).toBe('create')
const title = modal!.querySelector<HTMLInputElement>('#child-parts-title')!
title.value = 'Bolt'
title.dispatchEvent(new Event('input'))
await flushPromises()
;(modal!.querySelector('[data-action="save-child"]') as HTMLButtonElement).click()
await flushPromises()
const [post] = requestsTo(calls, 'POST', `${BASE}/0/relations/parts/records`)
expect(post).toBeDefined()
expect(post!.headers.get(SESSION_HEADER)).toBe(key)
const childKey = post!.headers.get(CHILD_SESSION_HEADER) ?? ''
expect(childKey).toMatch(KEY_PATTERN)
expect(childKey).not.toBe(key)
expect(await post!.clone().json()).toEqual({ title: 'Bolt' })
expect(document.body.querySelector('[data-relation-child-modal]')).toBeNull()
// The list reloads with the same key.
expect(requestsTo(calls, 'GET', `${BASE}/0/relations/parts`)).toHaveLength(2)
// The deferred child alone makes the form dirty.
await wrapper.find('[data-action="cancel"]').trigger('click')
await flushPromises()
const confirm = document.body.querySelector('[data-confirm-dialog]')
expect(confirm).not.toBeNull()
;(confirm!.querySelector('[data-action="cancel"]') as HTMLButtonElement).click()
await flushPromises()
await wrapper.find('#field-name').setValue('Box')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
const [save] = requestsTo(calls, 'POST', BASE)
expect(save!.headers.get(SESSION_HEADER)).toBe(key)
})
it('hides a non-deferrable manager on the create screen', async () => {
const schema = structuredClone(deferredFormSchemaFixture)
const parts = schema.data.fields.find((field) => field.name === 'parts')!
parts.deferrable = false
const { wrapper, calls } = await mountApp(
'/acme/demo/gadgets/create',
routes({ [`GET ${BASE}/schema/form`]: { body: schema } }),
)
expect(wrapper.find('[data-relation-manager]').exists()).toBe(false)
expect(requestsTo(calls, 'GET', `${BASE}/0/relations/parts`)).toHaveLength(0)
})
})