feat(12.2-04): edit related records in modals and defer relation work on new records
- RelationManager renders create/link/delete/unlink in declared order with one primary, opens the child modal (update or view form) or the pivot modal on row click, and deletes selected children behind a busy confirm - RelationChildModal creates and edits children with its own session key (X-Child-Session-Key) so uploads and dates work inside it - RelationPivotModal edits link details; the picker links one record with its pivot values when the relation has a pivot form - deferrable managers render on the create screen with owner id 0, the form's X-Session-Key and the pending note, and mark the form dirty - the registry resolves RelationManager lazily (child forms close an import cycle); DataTable gains openable rows and a trailing cell - relation lang keys in en and pl; rebuilt boardwalk dist
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
@@ -13,7 +13,11 @@ 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
|
||||
@@ -21,12 +25,19 @@ import { showToast } from '../../state/useToasts'
|
||||
// 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]
|
||||
@@ -45,6 +56,12 @@ 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
|
||||
|
||||
@@ -53,6 +70,15 @@ 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
|
||||
@@ -100,6 +126,7 @@ async function fetchPage(): Promise<void> {
|
||||
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) {
|
||||
@@ -131,6 +158,10 @@ function reset(): void {
|
||||
meta.value = null
|
||||
loading.value = false
|
||||
chosen.value = new Set()
|
||||
step.value = 1
|
||||
chosenRow.value = null
|
||||
pivotValues.value = {}
|
||||
pivotErrors.value = {}
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -176,6 +207,12 @@ function toggle(row: AdminRecord): void {
|
||||
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)
|
||||
@@ -185,12 +222,51 @@ function toggle(row: AdminRecord): void {
|
||||
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': {
|
||||
@@ -212,17 +288,42 @@ async function onConfirm(): Promise<void> {
|
||||
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 } },
|
||||
body: { ids },
|
||||
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')
|
||||
@@ -267,8 +368,16 @@ const pagerButton =
|
||||
>
|
||||
<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>
|
||||
<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"
|
||||
@@ -279,7 +388,24 @@ const pagerButton =
|
||||
</DialogClose>
|
||||
</div>
|
||||
|
||||
<label class="relative block">
|
||||
<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"
|
||||
@@ -299,9 +425,10 @@ const pagerButton =
|
||||
</label>
|
||||
|
||||
<ul
|
||||
v-show="step === 1"
|
||||
:id="listId"
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
: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"
|
||||
@@ -328,6 +455,16 @@ const pagerButton =
|
||||
<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"
|
||||
@@ -349,7 +486,7 @@ const pagerButton =
|
||||
</ul>
|
||||
|
||||
<nav
|
||||
v-if="meta && meta.total > 0"
|
||||
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"
|
||||
>
|
||||
@@ -377,20 +514,44 @@ const pagerButton =
|
||||
</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"
|
||||
>
|
||||
{{ t('backend::lang.relation.add_count', { count }) }}
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user