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

@@ -12,6 +12,8 @@ import CellValue from './CellValue.vue'
// column with a tri-state header over the current page, sortable headers
// (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.
const props = withDefaults(
defineProps<{
@@ -23,8 +25,17 @@ const props = withDefaults(
selected?: RowId[]
sortable?: boolean
sort?: { column: string; dir: 'asc' | 'desc' } | null
variant?: 'list' | 'relation'
}>(),
{ loading: false, rowLink: undefined, selectable: false, selected: () => [], sortable: false, sort: null },
{
loading: false,
rowLink: undefined,
selectable: false,
selected: () => [],
sortable: false,
sort: null,
variant: 'list',
},
)
const emit = defineEmits<{ 'update:selected': [ids: RowId[]]; sort: [column: string] }>()
@@ -102,6 +113,28 @@ function canSort(column: ListColumn): boolean {
return props.sortable && column.sortable
}
const relation = computed(() => props.variant === 'relation')
const rowHeight = computed(() => (relation.value ? 'h-[56px]' : 'h-row'))
const skeletonRows = computed(() => (relation.value ? 3 : 8))
/** Initials of a cell value for the relation variant's avatar. */
function initials(value: unknown): string {
const text = typeof value === 'string' || typeof value === 'number' ? String(value) : ''
return text
.split(/[\s@._-]+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('')
}
function cellClass(column: ListColumn, columnIndex: number): string {
if (columnIndex === 0) {
return 'font-semibold'
}
return relation.value || column.relation ? 'text-muted' : ''
}
const span = computed(() => Math.max(props.columns.length + (props.selectable ? 1 : 0), 1))
// Varied skeleton bar widths, as in the design.
@@ -110,7 +143,7 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
<template>
<div class="overflow-x-auto">
<table class="w-full min-w-[640px] border-collapse text-left">
<table :class="relation ? 'min-w-[480px]' : 'min-w-[640px]'" class="w-full border-collapse text-left">
<thead class="sticky top-0 z-10">
<tr class="h-row-head border-y border-border bg-subtle">
<th v-if="selectable" scope="col" data-select class="w-[52px] pl-5">
@@ -153,7 +186,7 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
</thead>
<tbody :aria-busy="loading ? 'true' : undefined">
<template v-if="loading">
<tr v-for="n in 8" :key="`skeleton-${n}`" data-skeleton class="h-row border-b border-border">
<tr v-for="n in skeletonRows" :key="`skeleton-${n}`" data-skeleton :class="rowHeight" class="border-b border-border">
<td v-if="selectable" class="pl-5">
<span class="block size-[18px] rounded-checkbox bg-skel" />
</td>
@@ -173,9 +206,13 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
<tr
v-for="(row, index) in rows"
:key="rowKey(row, index)"
:class="[isSelected(row) ? 'bg-sel' : linkOf(row) ? 'hover:bg-hover' : '', linkOf(row) ? 'cursor-pointer' : '']"
:class="[
rowHeight,
isSelected(row) ? 'bg-sel' : linkOf(row) ? 'hover:bg-hover' : '',
linkOf(row) ? 'cursor-pointer' : '',
]"
:aria-selected="selectable ? (isSelected(row) ? 'true' : 'false') : undefined"
class="h-row border-b border-border transition-colors duration-150 ease-out"
class="border-b border-border transition-colors duration-150 ease-out"
@click="openRow($event, row)"
>
<td v-if="selectable" data-select class="pl-5">
@@ -194,10 +231,19 @@ 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, columnIndex) in columns"
:key="column.key"
:class="columnIndex === 0 ? 'font-semibold' : column.relation ? 'text-muted' : ''"
:class="cellClass(column, columnIndex)"
class="px-3.5 first:pl-5 last:pr-5"
>
<RouterLink v-if="columnIndex === 0 && linkOf(row)" :to="linkOf(row)!" class="text-text no-underline">
<span v-if="relation && columnIndex === 0" class="flex items-center gap-3">
<span
data-avatar
class="flex size-[30px] shrink-0 items-center justify-center rounded-full bg-subtle text-[12px] font-bold text-text"
aria-hidden="true"
>{{ initials(row[column.key]) }}</span
>
<CellValue :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]" />