- 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
329 lines
9.8 KiB
Vue
329 lines
9.8 KiB
Vue
<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>
|