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