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:
399
admin/src/components/relation/RelationPickerModal.vue
Normal file
399
admin/src/components/relation/RelationPickerModal.vue
Normal 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>
|
||||
Reference in New Issue
Block a user