feat(10-04): search, link and unlink related records through the relation manager

- relation-manager registered in the field registry; renders only on an
  existing record, never on create, and is never part of the save body
- RelationManager: relation schema label and comment, debounced search,
  selectable linked list (DataTable relation variant), toolbar buttons in
  declared order, confirmed unlink with plural messages and toasts
- RelationPickerModal: Reka Dialog (aria-modal, focus trap, Esc) over the
  candidates endpoint five per page, selection kept across pages, Dodaj (N)
  POSTs link, focus returns to the opener
- admin OpenAPI documents search, sort, dir, page and per_page on the linked
  and candidate relation routes so the SPA sends them typed
- neutral acme.demo.widgets members fixtures and relation smoke tests
This commit is contained in:
Jakub Zych
2026-09-27 17:18:14 +02:00
parent 6e1b6dd5bf
commit f4e97cccad
26 changed files with 1509 additions and 33 deletions

View File

@@ -0,0 +1,328 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import { Search, 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 { showToast } from '../../state/useToasts'
import DataTable from '../list/DataTable.vue'
import Pagination from '../list/Pagination.vue'
import Button from '../ui/Button.vue'
import ConfirmDialog from '../ui/ConfirmDialog.vue'
import { useConfirm } from '../ui/confirm'
import type { FieldControlProps } from '../form/registry'
import RelationPickerModal from './RelationPickerModal.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
// 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.
const props = defineProps<FieldControlProps>()
const SEARCH_DEBOUNCE = 300
const relationName = computed(() => props.field.relation || props.field.name)
const ready = computed(() => props.source != null && typeof props.recordId === 'number')
const schema = ref<RelationSchema | null>(null)
const rows = ref<AdminRecord[]>([])
const meta = ref<ListMeta | null>(null)
const loading = ref(true)
const failed = ref(false)
const search = ref('')
const page = ref(1)
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.
let linkButton: HTMLElement | null = null
const confirm = useConfirm()
let generation = 0
let searchTimer: ReturnType<typeof setTimeout> | null = null
const columns = computed(() => schema.value?.view.list.columns ?? [])
const buttons = computed(() => schema.value?.view.toolbarButtons ?? [])
const messages = computed(() => schema.value?.messages)
const headingId = computed(() => `${props.controlId}-heading`)
const commentId = computed(() => `${props.controlId}-comment`)
function pathParams() {
return { ...props.source!, id: props.recordId as number, name: relationName.value }
}
async function loadSchema(): Promise<void> {
const { data } = await api.GET('/{vendor}/{plugin}/{controller}/schema/relation/{name}', {
params: { path: { ...props.source!, name: relationName.value } },
})
schema.value = data?.data ?? null
}
async function loadRows(): Promise<void> {
const current = ++generation
loading.value = true
failed.value = false
try {
const term = search.value.trim()
const query: RelationQuery = { page: page.value }
if (term !== '') {
query.search = term
}
if (sort.value) {
query.sort = sort.value.column
query.dir = sort.value.dir
}
const { data } = await api.GET('/{vendor}/{plugin}/{controller}/{id}/relations/{name}', {
params: { path: pathParams(), query },
})
if (current !== generation) {
return
}
rows.value = data?.data ?? []
meta.value = data?.meta ?? null
failed.value = !data
} catch {
if (current === generation) {
rows.value = []
meta.value = null
failed.value = true
}
} finally {
if (current === generation) {
loading.value = false
}
}
}
async function load(): Promise<void> {
if (!ready.value) {
return
}
try {
await loadSchema()
} catch {
schema.value = null
}
if (!schema.value) {
loading.value = false
failed.value = true
return
}
await loadRows()
}
function onSearch(event: Event): void {
search.value = (event.target as HTMLInputElement).value
if (searchTimer !== null) {
clearTimeout(searchTimer)
}
searchTimer = setTimeout(() => {
searchTimer = null
page.value = 1
selected.value = []
void loadRows()
}, SEARCH_DEBOUNCE)
}
function onPage(next: number): void {
page.value = next
selected.value = []
void loadRows()
}
/** asc -> desc -> none, like the main list. */
function onSort(column: string): void {
const current = sort.value
if (current?.column !== column) {
sort.value = { column, dir: 'asc' }
} else if (current.dir === 'asc') {
sort.value = { column, dir: 'desc' }
} else {
sort.value = null
}
page.value = 1
selected.value = []
void loadRows()
}
function selectedIds(): number[] {
return selected.value.map(Number).filter((id) => Number.isInteger(id) && id > 0)
}
async function onUnlink(): Promise<void> {
const ids = selectedIds()
if (busy.value || ids.length === 0 || !schema.value) {
return
}
const ok = await confirm.ask({
message: message(messages.value?.unlinkConfirm, ids.length),
confirmLabel: message(messages.value?.unlinkSelected),
danger: true,
})
if (!ok) {
return
}
busy.value = true
try {
const result = await api.POST('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink', {
params: { path: pathParams() },
body: { ids },
})
if (result.data) {
selected.value = []
showToast(message(messages.value?.unlinked, result.data.data.removed ?? ids.length))
await loadRows()
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
}
}
async function onLinked(count: number): Promise<void> {
showToast(message(messages.value?.linked, count))
selected.value = []
await loadRows()
}
/** Focus returns to the button that opened the picker. */
function returnFocus(): void {
linkButton?.focus()
}
function setLinkButton(instance: unknown): void {
const element = (instance as { $el?: unknown } | null)?.$el
linkButton = element instanceof HTMLElement ? element : null
}
onBeforeUnmount(() => {
if (searchTimer !== null) {
clearTimeout(searchTimer)
}
})
void load()
</script>
<template>
<section
v-if="ready"
:id="controlId"
data-relation-manager
:data-relation-name="relationName"
: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>
<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') }}
</p>
<div v-else class="overflow-hidden rounded-inner border border-border">
<DataTable
v-model:selected="selected"
variant="relation"
:columns="columns"
:rows="rows"
:loading="loading"
:selectable="buttons.includes('unlink')"
:sortable="true"
:sort="sort"
@sort="onSort"
>
<template #empty>
<span data-relation-empty class="text-muted">{{ message(messages?.empty) }}</span>
</template>
</DataTable>
<Pagination
v-if="meta && meta.last_page > 1"
:meta="meta"
:loading="loading"
:per-page-options="[]"
:per-page="meta.per_page"
@page="onPage"
/>
</div>
<RelationPickerModal
v-if="schema && buttons.includes('link')"
v-model:open="pickerOpen"
:source="source!"
:record-id="recordId as number"
:relation="relationName"
:schema="schema"
@linked="onLinked"
@closed="returnFocus"
/>
<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"
/>
</section>
</template>