Files
summercms/admin/src/components/relation/RelationPickerModal.vue
Jakub Zych 424fe9ccc0 feat(14.2.1-05): cover mlmarkdown, child adopt, and regen admin artifacts
MLMarkdownField and relation-child forms share the enabled-locale
inject and adopt merge; OpenAPI, types, docs, and boardwalk dist match.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 15:41:46 +02:00

561 lines
19 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, 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 { SESSION_HEADER } from '../../app/sessionKey'
import { showToast } from '../../state/useToasts'
import FormErrorBanner from '../form/FormErrorBanner.vue'
import FormGrid from '../form/FormGrid.vue'
import { editablePayload, fieldErrors, focusField, initialValues } from '../form/formState'
// 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.
// Phase 12.2 (D-14): when the relation has a pivot form the picker selects
// one record, Next shows the pivot form in the same dialog, and Add link
// posts `{ids: [id], pivot: {...}}`; Back returns to the list with its
// search, page and selection. On an unsaved record (id 0) every call sends
// the form's session key.
const props = defineProps<{
open: boolean
source: ControllerParams
recordId: number
relation: string
schema: RelationSchema
/** The record form's session key; sent when the owner is unsaved. */
sessionKey?: string | null
}>()
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)
// Pivot link flow: step 1 picks one record, step 2 fills the pivot form.
const step = ref<1 | 2>(1)
const chosenRow = ref<AdminRecord | null>(null)
const pivotValues = ref<AdminRecord>({})
const pivotErrors = ref<Record<string, string[]>>({})
const stepTwo = ref<HTMLElement | 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 pivotFields = computed(() => props.schema.pivotForm ?? [])
const pivotFieldNames = computed(() => pivotFields.value.map((field) => field.name))
/** With a pivot form one record is linked at a time, with its pivot values. */
const single = computed(() => pivotFields.value.length > 0)
const pivotPrefix = computed(() => `link-${props.relation}`)
function header(): { 'X-Session-Key': string } | undefined {
return props.sessionKey ? ({ [SESSION_HEADER]: props.sessionKey } as { 'X-Session-Key': string }) : undefined
}
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 },
header: header(),
},
})
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()
step.value = 1
chosenRow.value = null
pivotValues.value = {}
pivotErrors.value = {}
}
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
}
if (single.value) {
// Radio behaviour: the chosen record is replaced, never added to.
chosen.value = new Set([id])
chosenRow.value = row
return
}
const next = new Set(chosen.value)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
chosen.value = next
}
/** Step 2 of the pivot flow: the chosen record and its pivot form. */
async function goNext(): Promise<void> {
if (!single.value || chosen.value.size === 0 || !chosenRow.value) {
return
}
step.value = 2
pivotValues.value = initialValues(pivotFields.value, [])
pivotErrors.value = {}
await nextTick()
stepTwo.value?.querySelector<HTMLElement>('input, select, textarea, button, [contenteditable="true"]')?.focus()
}
/** Back to the list, keeping its search, page and selection. */
async function goBack(): Promise<void> {
if (busy.value) {
return
}
step.value = 1
pivotErrors.value = {}
await nextTick()
const chosenOption = document.querySelector<HTMLElement>(`#${listId} [aria-selected="true"]`)
;(chosenOption ?? searchInput.value)?.focus()
}
function updatePivot(name: string, value: unknown): void {
pivotValues.value = { ...pivotValues.value, [name]: value }
if (pivotErrors.value[name]) {
const next = { ...pivotErrors.value }
delete next[name]
pivotErrors.value = next
}
}
function onOptionKeydown(event: KeyboardEvent, row: AdminRecord, index: number): void {
switch (event.key) {
case ' ':
event.preventDefault()
toggle(row)
break
case 'Enter':
event.preventDefault()
toggle(row)
if (single.value) {
void goNext()
}
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
}
const pivot = single.value
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 }, header: header() },
body: pivot ? { ids: ids.slice(0, 1), pivot: editablePayload(pivotFields.value, pivotValues.value) } : { ids },
})
if (result.data) {
setOpen(false)
emit('linked', result.data.data.linked ?? ids.length)
return
}
const status = result.response.status
if (pivot && status === 422) {
// Pivot errors stay in the dialog, under their fields.
pivotErrors.value = fieldErrors(result.error?.error.details)
const first = pivotFields.value.find((field) => pivotErrors.value[field.name])
if (first) {
await nextTick()
focusField(pivotPrefix.value, first.name)
}
return
}
if (pivot && status === 404) {
// The chosen record is gone: back to the list, reloaded.
showToast(t('backend::lang.relation.child_gone'), 'danger')
chosen.value = new Set()
chosenRow.value = null
step.value = 1
void fetchPage()
return
}
if (pivot) {
showToast(t('backend::lang.form.error_generic'), 'danger')
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]">{{
step === 2 ? message(schema.messages.pivotTitle) : message(schema.messages.link)
}}</DialogTitle>
<DialogDescription v-if="step === 1" class="text-muted">{{ message(schema.messages.linkHint) }}</DialogDescription>
<DialogDescription v-else class="flex min-w-0 flex-col" data-chosen-record>
<span class="truncate font-semibold text-text" :title="text(chosenRow ?? {}, primary)">{{
text(chosenRow ?? {}, primary)
}}</span>
<span v-if="secondary" class="truncate text-[13px] text-muted">{{ text(chosenRow ?? {}, secondary) }}</span>
</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>
<div v-if="step === 2" ref="stepTwo" data-pivot-step class="flex min-h-0 flex-col gap-4 overflow-y-auto">
<FormErrorBanner :errors="pivotErrors" :field-names="pivotFieldNames" />
<form novalidate @submit.prevent="onConfirm">
<fieldset :disabled="busy" class="m-0 min-w-0 border-0 p-0">
<FormGrid
:fields="pivotFields"
:values="pivotValues"
:errors="pivotErrors"
:source="source"
:record-id="null"
:id-prefix="pivotPrefix"
@update="updatePivot"
/>
</fieldset>
</form>
</div>
<label v-show="step === 1" 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
v-show="step === 1"
:id="listId"
role="listbox"
:aria-multiselectable="single ? 'false' : '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
v-if="single"
data-radio
:class="isChosen(row) ? 'border-primary' : 'border-border-strong bg-surface'"
class="flex size-[18px] shrink-0 items-center justify-center rounded-full border"
aria-hidden="true"
>
<span v-if="isChosen(row)" class="block size-2.5 rounded-full bg-primary" />
</span>
<span
v-else
: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="step === 1 && 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">
<button
v-if="step === 2"
type="button"
data-action="back"
:disabled="busy"
class="inline-flex h-button flex-1 items-center justify-center rounded-control border border-border-strong bg-surface font-semibold hover:bg-hover disabled:cursor-not-allowed disabled:opacity-60"
@click="goBack"
>
{{ t('backend::lang.relation.back') }}
</button>
<DialogClose
v-else
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
v-if="single && step === 1"
type="button"
data-action="next"
:disabled="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="goNext"
>
{{ t('backend::lang.relation.next') }}
</button>
<button
v-else
type="button"
data-action="confirm-link"
:disabled="busy || count === 0"
:aria-busy="busy ? 'true' : undefined"
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"
>
<template v-if="single">{{ busy ? t('backend::lang.form.saving') : message(schema.messages.linkSubmit) }}</template>
<template v-else>{{ t('backend::lang.relation.add_count', { count }) }}</template>
</button>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>