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)
})
})

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,8 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-DcD36JxU.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DdPWaFzL.css">
<script type="module" crossorigin src="./assets/index-ccm5iY7G.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-B-HOzWeq.css">
</head>
<body>
<div id="app"></div>

View File

@@ -94,6 +94,11 @@ relation:
candidates_range: ":from–:to of :total"
no_candidates: There are no records to add.
load_failed: The linked records could not be loaded.
child_load_failed: The record could not be loaded. Close this window and try again.
child_gone: This record no longer exists. The list has been refreshed.
next: Next
back: Back
pending_note: Changes in this section are saved together with the record.
settings:
title: Settings
empty: No settings are available.

View File

@@ -102,6 +102,11 @@ relation:
candidates_range: ":from–:to z :total"
no_candidates: Brak rekordów do dodania.
load_failed: Nie udało się wczytać powiązanych rekordów.
child_load_failed: Nie udało się wczytać rekordu. Zamknij to okno i spróbuj ponownie.
child_gone: Ten rekord już nie istnieje. Lista została odświeżona.
next: Dalej
back: Wstecz
pending_note: Zmiany w tej sekcji zostaną zapisane razem z rekordem.
settings:
title: Ustawienia
empty: Brak dostępnych ustawień.