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:
@@ -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]" />
|
||||
|
||||
Reference in New Issue
Block a user