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

@@ -1454,7 +1454,18 @@ export interface paths {
/** List linked relation records */
get: {
parameters: {
query?: never;
query?: {
/** @description Search term over the panel's searchable columns */
search?: string;
/** @description Sort column (a sortable panel column) */
sort?: string;
/** @description Sort direction (asc or desc) */
dir?: string;
/** @description Page */
page?: number;
/** @description Records per page (1-100, default 20) */
per_page?: number;
};
header?: never;
path: {
/** @description Vendor */
@@ -1537,7 +1548,18 @@ export interface paths {
/** List relation candidates */
get: {
parameters: {
query?: never;
query?: {
/** @description Search term over the panel's searchable columns */
search?: string;
/** @description Sort column (a sortable panel column) */
sort?: string;
/** @description Sort direction (asc or desc) */
dir?: string;
/** @description Page */
page?: number;
/** @description Records per page (1-100, default 20) */
per_page?: number;
};
header?: never;
path: {
/** @description Vendor */

View File

@@ -5,6 +5,7 @@ import type { components, paths } from './schema'
type Schemas = components['schemas']
type ControllerPath = paths['/{vendor}/{plugin}/{controller}']
type FieldOptionsPath = paths['/{vendor}/{plugin}/{controller}/fields/{field}/options']
type RelationLinkedPath = paths['/{vendor}/{plugin}/{controller}/{id}/relations/{name}']
export type AdminLoginData = Schemas['cabana.AdminLoginData']
export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest']
@@ -27,6 +28,8 @@ export type RelationSchema = Schemas['cabana.RelationSchema']
export type RelationMessages = Schemas['cabana.RelationMessages']
export type RelationMutationResult = Schemas['cabana.RelationMutationResult']
export type RelationOption = Schemas['cabana.RelationOption']
export type RelationPanel = Schemas['cabana.RelationPanel']
export type RelationColumn = Schemas['cabana.RelationColumn']
export type FilterOption = Schemas['cabana.FilterOption']
export type RecordMeta = Schemas['cabana.RecordMeta']
export type RecordEnvelope = Schemas['cabana.RecordEnvelope']
@@ -46,5 +49,7 @@ export type IDsRequest = Schemas['cabana.AdminIDsRequest']
export type ControllerParams = ControllerPath['get']['parameters']['path']
/** The list query: search, sort, dir, page, per_page and filter[<name>]. */
export type ListQuery = NonNullable<ControllerPath['get']['parameters']['query']>
/** Linked and candidate relation query: search, sort, dir, page, per_page. */
export type RelationQuery = NonNullable<RelationLinkedPath['get']['parameters']['query']>
/** Relation field options query: search, page, per_page. */
export type FieldOptionsQuery = NonNullable<FieldOptionsPath['get']['parameters']['query']>

View File

@@ -20,6 +20,7 @@ const control = computed(() => rendererFor(props.field.type))
:described-by="describedBy"
:labels="labels"
:source="source"
:record-id="recordId"
@update:model-value="(value: unknown) => emit('update:modelValue', value)"
/>
</template>

View File

@@ -13,6 +13,7 @@ const props = defineProps<{
errors?: string[]
labels?: RelationOption[]
source?: ControllerParams | null
recordId?: number | null
/** Prefix for element ids, so two forms on a page never collide. */
idPrefix?: string
}>()
@@ -46,6 +47,7 @@ const describedBy = computed(() =>
:described-by="describedBy"
:labels="labels"
:source="source"
:record-id="recordId"
@update:model-value="(value: unknown) => emit('update:modelValue', value)"
/>
<p v-if="showComment" :id="commentId" class="text-[13px] text-muted">{{ field.comment }}</p>

View File

@@ -11,12 +11,17 @@ defineProps<{
errors: Record<string, string[]>
labels?: RecordMeta['labels']
source?: ControllerParams | null
recordId?: number | null
idPrefix?: string
}>()
const emit = defineEmits<{ update: [name: string, value: unknown] }>()
function spanClass(span: string | undefined): string {
switch (span) {
function spanClass(field: FormFieldSchema): string {
// The relation manager always takes the whole row (design screen 5).
if (field.type === 'relation-manager') {
return 'col-span-full'
}
switch (field.span) {
case 'left':
return 'min-[600px]:col-start-1'
case 'right':
@@ -35,12 +40,13 @@ function spanClass(span: string | undefined): string {
<FormField
v-for="field in fields"
:key="field.name"
:class="spanClass(field.span)"
:class="spanClass(field)"
:field="field"
:model-value="values[field.name]"
:errors="errors[field.name]"
:labels="labels?.[field.name]"
:source="source"
:record-id="recordId"
:id-prefix="idPrefix"
@update:model-value="(value: unknown) => emit('update', field.name, value)"
/>

View File

@@ -1,13 +1,15 @@
// Field renderer registry (D-05). A form field's `type` selects the control
// component; any type without a renderer gets UnsupportedField, the design's
// dashed box, so an unknown type never breaks the form. The relation manager
// arrives with its own component in a later plan. Phase 10.1 turns this seam
// into the plugin extension point.
// is registered like any control but holds no form value: it edits its
// relation through its own endpoints and renders only on an existing record.
// Phase 10.1 turns this seam into the plugin extension point.
import type { Component } from 'vue'
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
import CheckboxField from './fields/CheckboxField.vue'
import DropdownField from './fields/DropdownField.vue'
import NumberField from './fields/NumberField.vue'
import RelationManager from '../relation/RelationManager.vue'
import RelationField from './fields/RelationField.vue'
import SwitchField from './fields/SwitchField.vue'
import TextField from './fields/TextField.vue'
@@ -27,6 +29,8 @@ export interface FieldControlProps {
labels?: RelationOption[]
/** Controller whose option endpoints serve this form; null for settings. */
source?: ControllerParams | null
/** Id of the record being edited; null on create and on settings pages. */
recordId?: number | null
}
const renderers = new Map<string, Component>([
@@ -37,10 +41,17 @@ const renderers = new Map<string, Component>([
['switch', SwitchField],
['checkbox', CheckboxField],
['relation', RelationField],
['relation-manager', RelationManager],
])
/** Types whose control shows the label itself (toggle cards). */
const selfLabelled = new Set<string>(['switch', 'checkbox'])
/** Types whose control shows the label itself (toggle cards, relation manager). */
const selfLabelled = new Set<string>(['switch', 'checkbox', 'relation-manager'])
/**
* Types that need a saved record and hold no form value: never rendered on
* create, never part of the save body (D-05, design screen 5).
*/
const recordBound = new Set<string>(['relation-manager'])
export function rendererFor(type: string): Component {
return renderers.get(type) ?? UnsupportedField
@@ -48,7 +59,12 @@ export function rendererFor(type: string): Component {
/** Whether the SPA can edit values of this field type. */
export function isRegistered(type: string): boolean {
return renderers.has(type)
return renderers.has(type) && !recordBound.has(type)
}
/** Whether a field type renders only on an existing record (relation manager). */
export function needsRecord(type: string): boolean {
return recordBound.has(type)
}
export function ownsLabel(type: string): boolean {

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]" />

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>

View File

@@ -0,0 +1,399 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import {
DialogClose,
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
} from 'reka-ui'
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 { showToast } from '../../state/useToasts'
// Candidate picker (design screen 5, "Dodaj redaktora"): a Reka Dialog
// (role dialog, aria-modal, focus trapped, Esc closes) over GET
// {id}/relations/{name}/candidates, five per page. The selection is a set of
// 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.
const props = defineProps<{
open: boolean
source: ControllerParams
recordId: number
relation: string
schema: RelationSchema
}>()
const emit = defineEmits<{
'update:open': [open: boolean]
linked: [count: number]
closed: []
}>()
const PER_PAGE = 5
const SEARCH_DEBOUNCE = 300
const search = ref('')
const page = ref(1)
const candidates = ref<AdminRecord[]>([])
const meta = ref<ListMeta | null>(null)
const loading = ref(false)
const busy = ref(false)
const chosen = ref<Set<number>>(new Set())
const searchInput = ref<HTMLInputElement | null>(null)
let generation = 0
let searchTimer: ReturnType<typeof setTimeout> | null = null
const columns = computed(() => props.schema.manage.list.columns)
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 range = computed(() => {
const m = meta.value
if (!m || m.total === 0) {
return ''
}
const from = (m.page - 1) * m.per_page + 1
const to = Math.min(m.page * m.per_page, m.total)
return t('backend::lang.relation.candidates_range', { from, to, total: m.total })
})
const lastPage = computed(() => Math.max(meta.value?.last_page ?? 1, 1))
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 text(row: AdminRecord, key: string): string {
const value = key === '' ? undefined : row[key]
return typeof value === 'string' || typeof value === 'number' ? String(value) : ''
}
function initials(label: string): string {
return label
.split(/[\s@._-]+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('')
}
async function fetchPage(): Promise<void> {
const current = ++generation
loading.value = true
try {
const term = search.value.trim()
const { data } = await api.GET('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates', {
params: {
path: { ...props.source, id: props.recordId, name: props.relation },
query: { search: term === '' ? undefined : term, page: page.value, per_page: PER_PAGE },
},
})
if (current !== generation) {
return
}
candidates.value = data?.data ?? []
meta.value = data?.meta ?? null
} catch {
if (current === generation) {
candidates.value = []
meta.value = null
}
} finally {
if (current === generation) {
loading.value = false
}
}
}
function reset(): void {
if (searchTimer !== null) {
clearTimeout(searchTimer)
searchTimer = null
}
generation++
search.value = ''
page.value = 1
candidates.value = []
meta.value = null
loading.value = false
chosen.value = new Set()
}
watch(
() => props.open,
(open) => {
if (open) {
reset()
void fetchPage()
} else {
reset()
}
},
{ immediate: true },
)
function onSearch(event: Event): void {
search.value = (event.target as HTMLInputElement).value
if (searchTimer !== null) {
clearTimeout(searchTimer)
}
searchTimer = setTimeout(() => {
searchTimer = null
page.value = 1
void fetchPage()
}, SEARCH_DEBOUNCE)
}
function goTo(next: number): void {
if (next < 1 || next > lastPage.value || next === page.value) {
return
}
page.value = next
void fetchPage()
}
function isChosen(row: AdminRecord): boolean {
const id = idOf(row)
return id !== null && chosen.value.has(id)
}
function toggle(row: AdminRecord): void {
const id = idOf(row)
if (id === null) {
return
}
const next = new Set(chosen.value)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
chosen.value = next
}
function onOptionKeydown(event: KeyboardEvent, row: AdminRecord, index: number): void {
switch (event.key) {
case ' ':
case 'Enter':
event.preventDefault()
toggle(row)
break
case 'ArrowDown':
case 'ArrowUp': {
event.preventDefault()
const options = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLElement>('[role="option"]')
const step = event.key === 'ArrowDown' ? 1 : -1
options?.[Math.min(Math.max(index + step, 0), options.length - 1)]?.focus()
break
}
}
}
function setOpen(open: boolean): void {
emit('update:open', open)
}
async function onConfirm(): Promise<void> {
const ids = [...chosen.value]
if (busy.value || ids.length === 0) {
return
}
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 },
})
if (result.data) {
setOpen(false)
emit('linked', result.data.data.linked ?? ids.length)
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
}
}
/** The search input takes focus when the dialog opens. */
function onOpenAutoFocus(event: Event): void {
event.preventDefault()
searchInput.value?.focus()
}
/** Focus goes back to the opener, which the parent owns. */
function onCloseAutoFocus(event: Event): void {
event.preventDefault()
emit('closed')
}
onBeforeUnmount(() => {
if (searchTimer !== null) {
clearTimeout(searchTimer)
}
})
const pagerButton =
'inline-flex size-[34px] items-center justify-center rounded-tab border border-border text-text hover:bg-hover disabled:cursor-not-allowed disabled:opacity-60'
</script>
<template>
<DialogRoot :open="open" @update:open="setOpen">
<DialogPortal>
<DialogOverlay data-dialog-overlay class="fixed inset-0 z-40 bg-overlay" />
<DialogContent
data-relation-picker
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 rounded-modal bg-surface p-6 text-text shadow-pop"
@open-auto-focus="onOpenAutoFocus"
@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.link) }}</DialogTitle>
<DialogDescription class="text-muted">{{ message(schema.messages.linkHint) }}</DialogDescription>
</div>
<DialogClose
data-action="close"
:aria-label="t('backend::lang.form.close')"
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>
<label class="relative block">
<span class="sr-only">{{ t('backend::lang.list.search') }}</span>
<Search
:size="18"
class="pointer-events-none absolute top-1/2 left-3.5 -translate-y-1/2 text-muted"
aria-hidden="true"
/>
<input
ref="searchInput"
type="search"
data-candidate-search
:value="search"
:placeholder="message(schema.messages.candidateSearch)"
:aria-controls="listId"
class="h-input w-full rounded-control border border-border-strong bg-surface pr-3.5 pl-10"
@input="onSearch"
/>
</label>
<ul
:id="listId"
role="listbox"
aria-multiselectable="true"
:aria-label="message(schema.messages.link)"
:aria-busy="loading ? 'true' : undefined"
class="flex min-h-0 flex-col gap-2 overflow-y-auto"
>
<li
v-for="(row, index) in candidates"
:key="idOf(row) ?? `row-${index}`"
role="option"
tabindex="0"
:data-candidate="idOf(row) ?? undefined"
:aria-selected="isChosen(row) ? 'true' : 'false'"
:class="isChosen(row) ? 'border-[#e0b020] bg-sel' : 'border-border hover:bg-hover'"
class="flex h-[54px] cursor-pointer items-center gap-3 rounded-inner border px-3 transition-colors duration-150 ease-out"
@click="toggle(row)"
@keydown="onOptionKeydown($event, row, index)"
>
<span
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-subtle text-[12px] font-bold"
aria-hidden="true"
>{{ initials(text(row, primary)) }}</span
>
<span class="flex min-w-0 flex-1 flex-col leading-tight">
<span class="truncate font-semibold">{{ text(row, primary) }}</span>
<span v-if="secondary" class="truncate text-[13px] text-muted">{{ text(row, secondary) }}</span>
</span>
<span
: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"
>
<Check v-if="isChosen(row)" :size="14" />
</span>
</li>
<li v-if="loading && candidates.length === 0" role="presentation" class="px-1 py-3 text-[13px] text-muted">
{{ t('backend::lang.list.loading') }}
</li>
<li
v-else-if="!loading && candidates.length === 0"
role="presentation"
data-candidates-empty
class="px-1 py-3 text-[13px] text-muted"
>
{{ t('backend::lang.relation.no_candidates') }}
</li>
</ul>
<nav
v-if="meta && meta.total > 0"
:aria-label="t('backend::lang.list.pagination')"
class="flex items-center gap-2 text-[13px] text-muted"
>
<span data-candidates-range class="flex-1">{{ range }}</span>
<button
type="button"
data-page="prev"
:aria-label="t('backend::lang.list.prev_page')"
:class="pagerButton"
:disabled="loading || page <= 1"
@click="goTo(page - 1)"
>
<ChevronLeft :size="16" aria-hidden="true" />
</button>
<button
type="button"
data-page="next"
:aria-label="t('backend::lang.list.next_page')"
:class="pagerButton"
:disabled="loading || page >= lastPage"
@click="goTo(page + 1)"
>
<ChevronRight :size="16" aria-hidden="true" />
</button>
</nav>
<div class="flex gap-2.5">
<DialogClose
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
type="button"
data-action="confirm-link"
:disabled="busy || 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="onConfirm"
>
{{ t('backend::lang.relation.add_count', { count }) }}
</button>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

View File

@@ -10,6 +10,7 @@ import { mapWinterUrl } from '../app/winterUrl'
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
import FormGrid from '../components/form/FormGrid.vue'
import FormTabs from '../components/form/FormTabs.vue'
import { needsRecord } from '../components/form/registry'
import {
DEFAULT_TAB,
contextAllows,
@@ -62,7 +63,13 @@ const confirm = useConfirm()
// delete, so the dirty guard does not ask.
let leaving = false
const fields = computed(() => (schema.value?.fields ?? []).filter((field) => contextAllows(field, mode)))
// 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).
const fields = computed(() =>
(schema.value?.fields ?? []).filter(
(field) => contextAllows(field, mode) && (mode === 'update' || !needsRecord(field.type)),
),
)
const fieldNames = computed(() => fields.value.map((field) => field.name))
const tabs = computed<TabItem[]>(() => {
@@ -334,6 +341,7 @@ void load()
:errors="errors"
:labels="labels"
:source="path"
:record-id="recordId"
:id-prefix="ID_PREFIX"
@update="update"
/>