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:
@@ -34,6 +34,12 @@ export type { FieldControlProps } from './control'
|
||||
/** The relation manager's field type (design screen 5). */
|
||||
export const RELATION_MANAGER = 'relation-manager'
|
||||
|
||||
// The relation manager renders child forms (FormGrid -> FormField -> this
|
||||
// registry), so it sits on an import cycle with this module. It is looked up
|
||||
// when a field renders, never while this module evaluates: whichever module
|
||||
// of the cycle loads first, the binding is complete by then.
|
||||
const lazyRenderers = new Map<string, () => Component>([[RELATION_MANAGER, () => RelationManager]])
|
||||
|
||||
const renderers = new Map<string, Component>([
|
||||
['text', TextField],
|
||||
['textarea', TextareaField],
|
||||
@@ -42,7 +48,6 @@ const renderers = new Map<string, Component>([
|
||||
['switch', SwitchField],
|
||||
['checkbox', CheckboxField],
|
||||
['relation', RelationField],
|
||||
[RELATION_MANAGER, RelationManager],
|
||||
['widget', WidgetField],
|
||||
['partial', PartialField],
|
||||
['fileupload', FileuploadField],
|
||||
@@ -54,7 +59,8 @@ const selfLabelled = new Set<string>(['switch', 'checkbox', RELATION_MANAGER])
|
||||
|
||||
/**
|
||||
* Types that need a saved record: never rendered on create (D-05, design
|
||||
* screen 5).
|
||||
* screen 5), except a relation manager whose field is deferrable (Phase
|
||||
* 12.2, D-03): FormView keeps it and it works on owner id 0.
|
||||
*/
|
||||
const recordBound = new Set<string>([RELATION_MANAGER])
|
||||
|
||||
@@ -68,12 +74,12 @@ const valueless = new Set<string>([RELATION_MANAGER, 'widget', 'partial', 'fileu
|
||||
const groupLabelledTypes = new Set<string>(['widget', 'partial', 'fileupload'])
|
||||
|
||||
export function rendererFor(type: string): Component {
|
||||
return renderers.get(type) ?? UnsupportedField
|
||||
return renderers.get(type) ?? lazyRenderers.get(type)?.() ?? UnsupportedField
|
||||
}
|
||||
|
||||
/** Whether the SPA can edit values of this field type. */
|
||||
export function isRegistered(type: string): boolean {
|
||||
return renderers.has(type) && !valueless.has(type)
|
||||
return (renderers.has(type) || lazyRenderers.has(type)) && !valueless.has(type)
|
||||
}
|
||||
|
||||
/** Whether a field type renders only on an existing record (relation manager). */
|
||||
|
||||
@@ -13,7 +13,10 @@ import CellValue from './CellValue.vue'
|
||||
// (asc -> desc -> none) with aria-sort, row links to the record route, eight
|
||||
// skeleton rows while loading and an `empty` slot for the empty states.
|
||||
// The relation variant (design screen 5) has 56px rows, an initials avatar
|
||||
// before the first column's text and the remaining columns muted.
|
||||
// before the first column's text and the remaining columns muted. Phase
|
||||
// 12.2: with `openable` its rows open (the first cell's text is a button and
|
||||
// a row click outside other controls does the same), and `trailing` adds a
|
||||
// 56px last cell filled by the `trailing` slot.
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -26,6 +29,10 @@ const props = withDefaults(
|
||||
sortable?: boolean
|
||||
sort?: { column: string; dir: 'asc' | 'desc' } | null
|
||||
variant?: 'list' | 'relation'
|
||||
/** Rows open on click (relation variant): emits `open`. */
|
||||
openable?: boolean
|
||||
/** Adds a trailing 56px cell per row, filled by the `trailing` slot. */
|
||||
trailing?: boolean
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
@@ -35,9 +42,15 @@ const props = withDefaults(
|
||||
sortable: false,
|
||||
sort: null,
|
||||
variant: 'list',
|
||||
openable: false,
|
||||
trailing: false,
|
||||
},
|
||||
)
|
||||
const emit = defineEmits<{ 'update:selected': [ids: RowId[]]; sort: [column: string] }>()
|
||||
const emit = defineEmits<{
|
||||
'update:selected': [ids: RowId[]]
|
||||
sort: [column: string]
|
||||
open: [row: AdminRecord, trigger: HTMLElement | null]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -60,6 +73,11 @@ function openRow(event: MouseEvent, row: AdminRecord): void {
|
||||
if (target?.closest('a, button, input, label, select')) {
|
||||
return
|
||||
}
|
||||
if (props.openable) {
|
||||
const opener = (event.currentTarget as HTMLElement | null)?.querySelector<HTMLElement>('[data-row-open]') ?? null
|
||||
emit('open', row, opener)
|
||||
return
|
||||
}
|
||||
const to = linkOf(row)
|
||||
if (to) {
|
||||
void router.push(to)
|
||||
@@ -135,7 +153,13 @@ function cellClass(column: ListColumn, columnIndex: number): string {
|
||||
return relation.value || column.relation ? 'text-muted' : ''
|
||||
}
|
||||
|
||||
const span = computed(() => Math.max(props.columns.length + (props.selectable ? 1 : 0), 1))
|
||||
const span = computed(() =>
|
||||
Math.max(props.columns.length + (props.selectable ? 1 : 0) + (props.trailing ? 1 : 0), 1),
|
||||
)
|
||||
|
||||
function openFrom(event: MouseEvent, row: AdminRecord): void {
|
||||
emit('open', row, event.currentTarget as HTMLElement | null)
|
||||
}
|
||||
|
||||
// Varied skeleton bar widths, as in the design.
|
||||
const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', 'w-2/5']
|
||||
@@ -182,6 +206,7 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
|
||||
</button>
|
||||
<template v-else>{{ column.label }}</template>
|
||||
</th>
|
||||
<td v-if="trailing" class="w-[56px] pr-5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody :aria-busy="loading ? 'true' : undefined">
|
||||
@@ -193,6 +218,7 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
|
||||
<td v-for="column in columns" :key="column.key" class="px-3.5 first:pl-5 last:pr-5">
|
||||
<span :class="widths[(n - 1) % widths.length]" class="block h-3 rounded-[6px] bg-skel" />
|
||||
</td>
|
||||
<td v-if="trailing" class="pr-5" />
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="rows.length === 0">
|
||||
@@ -208,8 +234,8 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
|
||||
:key="rowKey(row, index)"
|
||||
:class="[
|
||||
rowHeight,
|
||||
isSelected(row) ? 'bg-sel' : linkOf(row) ? 'hover:bg-hover' : '',
|
||||
linkOf(row) ? 'cursor-pointer' : '',
|
||||
isSelected(row) ? 'bg-sel' : linkOf(row) || openable ? 'hover:bg-hover' : '',
|
||||
linkOf(row) || openable ? 'cursor-pointer' : '',
|
||||
]"
|
||||
:aria-selected="selectable ? (isSelected(row) ? 'true' : 'false') : undefined"
|
||||
class="border-b border-border transition-colors duration-150 ease-out"
|
||||
@@ -241,13 +267,25 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
|
||||
aria-hidden="true"
|
||||
>{{ initials(row[column.key]) }}</span
|
||||
>
|
||||
<CellValue :column="column" :value="row[column.key]" />
|
||||
<button
|
||||
v-if="openable"
|
||||
type="button"
|
||||
data-row-open
|
||||
class="min-w-0 truncate text-left font-semibold text-text"
|
||||
@click="openFrom($event, row)"
|
||||
>
|
||||
<CellValue :column="column" :value="row[column.key]" />
|
||||
</button>
|
||||
<CellValue v-else :column="column" :value="row[column.key]" />
|
||||
</span>
|
||||
<RouterLink v-else-if="columnIndex === 0 && linkOf(row)" :to="linkOf(row)!" class="text-text no-underline">
|
||||
<CellValue :column="column" :value="row[column.key]" />
|
||||
</RouterLink>
|
||||
<CellValue v-else :column="column" :value="row[column.key]" />
|
||||
</td>
|
||||
<td v-if="trailing" data-trailing class="w-[56px] pr-5">
|
||||
<slot name="trailing" :row="row" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
|
||||
529
admin/src/components/relation/RelationChildModal.vue
Normal file
529
admin/src/components/relation/RelationChildModal.vue
Normal file
@@ -0,0 +1,529 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, provide, readonly, ref, watch } from 'vue'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
} from 'reka-ui'
|
||||
import { Trash2, X } from '@lucide/vue'
|
||||
import { api } from '../../api/client'
|
||||
import { childFileRoutes } from '../../api/files'
|
||||
import type { AdminRecord, ControllerParams, FormField, RecordMeta, RelationSchema } from '../../api/types'
|
||||
import { message, t } from '../../app/i18n'
|
||||
import { CHILD_SESSION_HEADER, SESSION_HEADER, newSessionKey } from '../../app/sessionKey'
|
||||
import FormErrorBanner from '../form/FormErrorBanner.vue'
|
||||
import FormGrid from '../form/FormGrid.vue'
|
||||
import FormTabs from '../form/FormTabs.vue'
|
||||
import { FORM_PATCH, FORM_SESSION, FORM_VALUES, type FormSession } from '../form/formContext'
|
||||
import {
|
||||
DEFAULT_TAB,
|
||||
contextAllows,
|
||||
editablePayload,
|
||||
fieldErrors,
|
||||
focusField,
|
||||
initialValues,
|
||||
panelDomId,
|
||||
snapshot,
|
||||
tabDomId,
|
||||
tabOf,
|
||||
type TabItem,
|
||||
} from '../form/formState'
|
||||
import Button from '../ui/Button.vue'
|
||||
import ConfirmDialog from '../ui/ConfirmDialog.vue'
|
||||
import { useConfirm } from '../ui/confirm'
|
||||
import { showToast } from '../../state/useToasts'
|
||||
|
||||
// Related record modal (Phase 12.2, D-11, D-17; UI-SPEC section 4). Creates
|
||||
// a child through POST .../relations/{name}/records, edits one through
|
||||
// GET/PUT .../records/{child}, or shows the view form read-only. The form is
|
||||
// the relation's manageForm (viewForm for a preview) rendered with the
|
||||
// record form's own FormGrid, tabs and error banner. The modal makes its own
|
||||
// session key when it opens: uploads inside it are held against that key
|
||||
// (X-Child-Session-Key) and committed by the child's save, which also sends
|
||||
// the record form's key so an unsaved parent is found (owner id 0).
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
source: ControllerParams
|
||||
/** Owner record id; 0 for the record being created in the form's session. */
|
||||
ownerId: number
|
||||
relation: string
|
||||
schema: RelationSchema
|
||||
mode: 'create' | 'update' | 'preview'
|
||||
/** Child id in update and preview mode. */
|
||||
childId: number | null
|
||||
/** The record form's session key (X-Session-Key). */
|
||||
sessionKey: string
|
||||
/** Whether the footer offers Delete (the view declares the delete button). */
|
||||
canDelete: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:open': [open: boolean]
|
||||
/** A child was created, updated or deleted. */
|
||||
changed: [kind: 'created' | 'updated' | 'deleted']
|
||||
/** The child no longer exists (404): the list must reload. */
|
||||
gone: []
|
||||
closed: []
|
||||
}>()
|
||||
|
||||
const values = ref<AdminRecord>({})
|
||||
const labels = ref<RecordMeta['labels']>({})
|
||||
const errors = ref<Record<string, string[]>>({})
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
const busy = ref(false)
|
||||
const saved = ref('')
|
||||
const activeTab = ref(DEFAULT_TAB)
|
||||
const childKey = ref(newSessionKey())
|
||||
const pendingChanges = ref(0)
|
||||
const revision = ref(0)
|
||||
const body = ref<HTMLElement | null>(null)
|
||||
const confirm = useConfirm()
|
||||
let generation = 0
|
||||
|
||||
const idPrefix = computed(() => `child-${props.relation}`)
|
||||
const preview = computed(() => props.mode === 'preview')
|
||||
const formMode = computed(() => (props.mode === 'create' ? 'create' : 'update'))
|
||||
|
||||
/** The form fields: manageForm, or viewForm read-only, in the form's context. */
|
||||
const fields = computed<FormField[]>(() => {
|
||||
const source = (preview.value ? props.schema.viewForm : props.schema.manageForm) ?? []
|
||||
const visible = source.filter((field) => contextAllows(field, formMode.value))
|
||||
if (!preview.value) {
|
||||
return visible
|
||||
}
|
||||
return visible.map((field) => ({
|
||||
...field,
|
||||
readOnly: true,
|
||||
attributes: { ...(field.attributes ?? {}), readonly: true },
|
||||
}))
|
||||
})
|
||||
const fieldNames = computed(() => fields.value.map((field) => field.name))
|
||||
|
||||
const tabs = computed<TabItem[]>(() => {
|
||||
if (!fields.value.some((field) => field.tab)) {
|
||||
return []
|
||||
}
|
||||
const out: TabItem[] = []
|
||||
for (const field of fields.value) {
|
||||
const key = tabOf(field)
|
||||
let tab = out.find((item) => item.key === key)
|
||||
if (!tab) {
|
||||
tab = { key, label: field.tab || t('backend::lang.form.tab_default'), errors: 0 }
|
||||
if (key === DEFAULT_TAB) {
|
||||
out.unshift(tab)
|
||||
} else {
|
||||
out.push(tab)
|
||||
}
|
||||
}
|
||||
if (errors.value[field.name]) {
|
||||
tab.errors += 1
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
const activeIndex = computed(() => Math.max(tabs.value.findIndex((tab) => tab.key === activeTab.value), 0))
|
||||
const panelFields = computed(() => {
|
||||
if (tabs.value.length === 0) {
|
||||
return fields.value
|
||||
}
|
||||
const key = tabs.value[activeIndex.value]?.key ?? DEFAULT_TAB
|
||||
return fields.value.filter((field) => tabOf(field) === key)
|
||||
})
|
||||
|
||||
const title = computed(() => {
|
||||
const messages = props.schema.messages
|
||||
if (props.mode === 'create') {
|
||||
return message(messages.createTitle)
|
||||
}
|
||||
return message(preview.value ? messages.previewTitle : messages.updateTitle)
|
||||
})
|
||||
const submitLabel = computed(() =>
|
||||
message(props.mode === 'create' ? props.schema.messages.createSubmit : props.schema.messages.updateSubmit),
|
||||
)
|
||||
const dirty = computed(
|
||||
() =>
|
||||
!preview.value &&
|
||||
!loading.value &&
|
||||
!failed.value &&
|
||||
(pendingChanges.value > 0 || snapshot(editablePayload(fields.value, values.value)) !== saved.value),
|
||||
)
|
||||
|
||||
function headers() {
|
||||
return { [SESSION_HEADER]: props.sessionKey, [CHILD_SESSION_HEADER]: childKey.value } as {
|
||||
'X-Session-Key': string
|
||||
'X-Child-Session-Key': string
|
||||
}
|
||||
}
|
||||
|
||||
function pathParams() {
|
||||
return { ...props.source, id: props.ownerId, name: props.relation }
|
||||
}
|
||||
|
||||
// Fields inside the modal (fileupload, datepicker) work against the child:
|
||||
// file routes of child id 0 on create, both keys on every call.
|
||||
const session: FormSession = {
|
||||
get key() {
|
||||
return props.sessionKey
|
||||
},
|
||||
get childKey() {
|
||||
return childKey.value
|
||||
},
|
||||
get recordId() {
|
||||
return props.childId ?? 0
|
||||
},
|
||||
routes: (field: string) =>
|
||||
childFileRoutes(
|
||||
props.source,
|
||||
props.ownerId,
|
||||
props.relation,
|
||||
props.childId ?? 0,
|
||||
field,
|
||||
props.sessionKey,
|
||||
childKey.value,
|
||||
),
|
||||
markDirty: () => {
|
||||
pendingChanges.value++
|
||||
},
|
||||
pendingChanges: readonly(pendingChanges),
|
||||
revision: readonly(revision),
|
||||
}
|
||||
provide(FORM_SESSION, session)
|
||||
provide(FORM_VALUES, computed(() => values.value))
|
||||
provide(FORM_PATCH, update)
|
||||
|
||||
function update(name: string, value: unknown): void {
|
||||
values.value = { ...values.value, [name]: value }
|
||||
if (errors.value[name]) {
|
||||
const next = { ...errors.value }
|
||||
delete next[name]
|
||||
errors.value = next
|
||||
}
|
||||
}
|
||||
|
||||
function adopt(record: AdminRecord, meta?: RecordMeta): void {
|
||||
values.value = { ...record }
|
||||
labels.value = meta?.labels ?? {}
|
||||
saved.value = snapshot(editablePayload(fields.value, values.value))
|
||||
}
|
||||
|
||||
/** The first focusable control of the body (a field, or the alert's Cancel). */
|
||||
async function focusFirst(): Promise<void> {
|
||||
await nextTick()
|
||||
const target = body.value?.querySelector<HTMLElement>(
|
||||
'input:not([type="hidden"]):not([tabindex="-1"]), select, textarea, button, [contenteditable="true"], [tabindex="0"]',
|
||||
)
|
||||
target?.focus()
|
||||
}
|
||||
|
||||
function gone(): void {
|
||||
emit('update:open', false)
|
||||
showToast(t('backend::lang.relation.child_gone'), 'danger')
|
||||
emit('gone')
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const current = ++generation
|
||||
errors.value = {}
|
||||
failed.value = false
|
||||
busy.value = false
|
||||
pendingChanges.value = 0
|
||||
childKey.value = newSessionKey()
|
||||
activeTab.value = DEFAULT_TAB
|
||||
if (props.mode === 'create' || props.childId === null) {
|
||||
loading.value = false
|
||||
adopt(initialValues(fields.value))
|
||||
activeTab.value = tabs.value[0]?.key ?? DEFAULT_TAB
|
||||
await focusFirst()
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await api.GET('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}', {
|
||||
params: { path: { ...pathParams(), child: props.childId }, header: headers() },
|
||||
})
|
||||
if (current !== generation) {
|
||||
return
|
||||
}
|
||||
if (result.data) {
|
||||
adopt(result.data.data, result.data.meta)
|
||||
activeTab.value = tabs.value[0]?.key ?? DEFAULT_TAB
|
||||
} else if (result.response.status === 404) {
|
||||
gone()
|
||||
return
|
||||
} else {
|
||||
failed.value = true
|
||||
}
|
||||
} catch {
|
||||
if (current === generation) {
|
||||
failed.value = true
|
||||
}
|
||||
} finally {
|
||||
if (current === generation) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
if (current === generation) {
|
||||
await focusFirst()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
void load()
|
||||
} else {
|
||||
generation++
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function setOpen(open: boolean): Promise<void> {
|
||||
if (open) {
|
||||
emit('update:open', true)
|
||||
return
|
||||
}
|
||||
if (busy.value) {
|
||||
return
|
||||
}
|
||||
if (dirty.value) {
|
||||
const ok = await confirm.ask({
|
||||
message: t('backend::lang.form.unsaved_confirm'),
|
||||
confirmLabel: t('backend::lang.form.discard'),
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) {
|
||||
return
|
||||
}
|
||||
}
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
/** 422: messages under fields, the first invalid field focused (tab switched). */
|
||||
async function showErrors(details: Record<string, unknown> | undefined): Promise<void> {
|
||||
errors.value = fieldErrors(details)
|
||||
const invalid = fields.value.filter((field) => errors.value[field.name])
|
||||
const first = invalid.find((field) => tabOf(field) === activeTab.value) ?? invalid[0]
|
||||
if (!first) {
|
||||
return
|
||||
}
|
||||
if (tabs.value.length > 0) {
|
||||
activeTab.value = tabOf(first)
|
||||
}
|
||||
await nextTick()
|
||||
focusField(idPrefix.value, first.name)
|
||||
}
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
if (busy.value || preview.value || loading.value || failed.value) {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
const payload = editablePayload(fields.value, values.value)
|
||||
const result =
|
||||
props.mode === 'create' || props.childId === null
|
||||
? await api.POST('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records', {
|
||||
params: { path: pathParams(), header: headers() },
|
||||
body: payload,
|
||||
})
|
||||
: await api.PUT('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}', {
|
||||
params: { path: { ...pathParams(), child: props.childId }, header: headers() },
|
||||
body: payload,
|
||||
})
|
||||
if (result.data) {
|
||||
const kind = props.mode === 'create' ? 'created' : 'updated'
|
||||
errors.value = {}
|
||||
pendingChanges.value = 0
|
||||
emit('update:open', false)
|
||||
showToast(message(kind === 'created' ? props.schema.messages.created : props.schema.messages.updated))
|
||||
emit('changed', kind)
|
||||
return
|
||||
}
|
||||
const status = result.response.status
|
||||
if (status === 422) {
|
||||
await showErrors(result.error?.error.details)
|
||||
} else if (status === 404) {
|
||||
gone()
|
||||
} else {
|
||||
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 onDelete(): Promise<void> {
|
||||
if (busy.value || props.childId === null) {
|
||||
return
|
||||
}
|
||||
const childId = props.childId
|
||||
const ok = await confirm.ask({
|
||||
message: message(props.schema.messages.deleteOneConfirm),
|
||||
confirmLabel: t('backend::lang.form.delete'),
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await api.POST('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete', {
|
||||
params: { path: pathParams(), header: { [SESSION_HEADER]: props.sessionKey } as { 'X-Session-Key': string } },
|
||||
body: { ids: [childId] },
|
||||
})
|
||||
if (result.data) {
|
||||
pendingChanges.value = 0
|
||||
emit('update:open', false)
|
||||
showToast(message(props.schema.messages.deleted, result.data.data.deleted || 1))
|
||||
emit('changed', 'deleted')
|
||||
return
|
||||
}
|
||||
if (result.response.status === 404) {
|
||||
gone()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/** Focus goes to the first field once it is there (see load). */
|
||||
function onOpenAutoFocus(event: Event): void {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
/** Focus goes back to the opener, which the relation manager owns. */
|
||||
function onCloseAutoFocus(event: Event): void {
|
||||
event.preventDefault()
|
||||
emit('closed')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot :open="open" @update:open="setOpen">
|
||||
<DialogPortal>
|
||||
<DialogOverlay data-dialog-overlay class="fixed inset-0 z-40 bg-overlay" />
|
||||
<DialogContent
|
||||
data-relation-child-modal
|
||||
data-dialog-content
|
||||
:data-mode="mode"
|
||||
aria-modal="true"
|
||||
:aria-describedby="undefined"
|
||||
class="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100vh-32px)] w-[calc(100%-32px)] max-w-[720px] -translate-x-1/2 -translate-y-1/2 flex-col rounded-modal bg-surface text-text shadow-pop"
|
||||
@open-auto-focus="onOpenAutoFocus"
|
||||
@close-auto-focus="onCloseAutoFocus"
|
||||
>
|
||||
<div class="flex items-start gap-3 p-6 pb-4">
|
||||
<DialogTitle class="min-w-0 flex-1 text-[20px] font-bold tracking-[-0.01em]">{{ title }}</DialogTitle>
|
||||
<DialogClose
|
||||
data-action="close"
|
||||
:aria-label="t('backend::lang.form.close')"
|
||||
:disabled="busy"
|
||||
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 ref="body" data-child-body class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-6 pb-6">
|
||||
<div
|
||||
v-if="loading"
|
||||
data-child-loading
|
||||
aria-hidden="true"
|
||||
class="grid grid-cols-1 gap-x-6 gap-y-[22px] min-[600px]:grid-cols-2"
|
||||
>
|
||||
<div v-for="n in 6" :key="n" class="flex flex-col gap-1.5">
|
||||
<span class="block h-3 w-24 rounded-[6px] bg-skel" />
|
||||
<span class="block h-input rounded-control bg-skel" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-else-if="failed"
|
||||
role="alert"
|
||||
data-child-load-failed
|
||||
class="rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger"
|
||||
>
|
||||
{{ t('backend::lang.relation.child_load_failed') }}
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<FormErrorBanner :errors="errors" :field-names="fieldNames" />
|
||||
<FormTabs v-if="tabs.length > 0" v-model="activeTab" :tabs="tabs" :id-prefix="idPrefix" class="self-start" />
|
||||
<form
|
||||
:id="tabs.length > 0 ? panelDomId(idPrefix, activeIndex) : undefined"
|
||||
:role="tabs.length > 0 ? 'tabpanel' : undefined"
|
||||
:aria-labelledby="tabs.length > 0 ? tabDomId(idPrefix, activeIndex) : undefined"
|
||||
novalidate
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<fieldset :disabled="busy || preview" class="m-0 min-w-0 border-0 p-0">
|
||||
<FormGrid
|
||||
:fields="panelFields"
|
||||
:values="values"
|
||||
:errors="errors"
|
||||
:labels="labels"
|
||||
:source="source"
|
||||
:record-id="childId"
|
||||
:id-prefix="idPrefix"
|
||||
@update="update"
|
||||
/>
|
||||
</fieldset>
|
||||
</form>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2.5 border-t border-border px-6 py-4">
|
||||
<Button
|
||||
v-if="mode === 'update' && canDelete && !loading && !failed"
|
||||
variant="danger"
|
||||
data-action="delete-child"
|
||||
:icon="Trash2"
|
||||
:disabled="busy"
|
||||
@click="onDelete"
|
||||
>
|
||||
{{ t('backend::lang.form.delete') }}
|
||||
</Button>
|
||||
<div class="ml-auto flex gap-2.5">
|
||||
<Button v-if="preview" variant="outline" data-action="cancel" @click="setOpen(false)">
|
||||
{{ t('backend::lang.form.close') }}
|
||||
</Button>
|
||||
<template v-else>
|
||||
<Button variant="ghost" data-action="cancel" :disabled="busy" @click="setOpen(false)">
|
||||
{{ t('backend::lang.form.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!failed"
|
||||
variant="primary"
|
||||
data-action="save-child"
|
||||
:disabled="busy || loading"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ busy ? t('backend::lang.form.saving') : submitLabel }}
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
|
||||
<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"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { Search, UserMinus, UserPlus } from '@lucide/vue'
|
||||
import { computed, inject, onBeforeUnmount, ref } from 'vue'
|
||||
import { Info, Plus, Search, SlidersHorizontal, Trash2, 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 { SESSION_HEADER, newSessionKey } from '../../app/sessionKey'
|
||||
import { showToast } from '../../state/useToasts'
|
||||
import DataTable from '../list/DataTable.vue'
|
||||
import Pagination from '../list/Pagination.vue'
|
||||
@@ -12,22 +13,46 @@ import Button from '../ui/Button.vue'
|
||||
import ConfirmDialog from '../ui/ConfirmDialog.vue'
|
||||
import { useConfirm } from '../ui/confirm'
|
||||
import type { FieldControlProps } from '../form/control'
|
||||
import { FORM_SESSION } from '../form/formContext'
|
||||
import RelationChildModal from './RelationChildModal.vue'
|
||||
import RelationPickerModal from './RelationPickerModal.vue'
|
||||
import RelationPivotModal from './RelationPivotModal.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
|
||||
// Relation manager field (D-05, design screen 5). 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: `create` opens the child modal, `link` the candidate
|
||||
// picker, `delete` deletes and `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.
|
||||
//
|
||||
// Phase 12.2 (D-03, D-11, D-12, D-14): a row opens the child modal (update,
|
||||
// or the view form read-only) or the pivot modal; a belongsToMany with a
|
||||
// pivot form and update gets a trailing pivot button. On the create screen
|
||||
// a deferrable manager works on owner id 0 with the form's session key: the
|
||||
// server holds every change until the record's first save, and each change
|
||||
// marks the form dirty. On a saved record changes are immediate.
|
||||
const props = defineProps<FieldControlProps>()
|
||||
|
||||
const SEARCH_DEBOUNCE = 300
|
||||
|
||||
const session = inject(FORM_SESSION, null)
|
||||
const relationName = computed(() => props.field.relation || props.field.name)
|
||||
const ready = computed(() => props.source != null && typeof props.recordId === 'number')
|
||||
/**
|
||||
* The owner id the routes use: the saved record's id, or 0 for the record
|
||||
* being created when the field is deferrable and the form has a session.
|
||||
*/
|
||||
const ownerId = computed<number | null>(() => {
|
||||
if (typeof props.recordId === 'number') {
|
||||
return props.recordId
|
||||
}
|
||||
return session && session.recordId === 0 && props.field.deferrable ? 0 : null
|
||||
})
|
||||
const deferred = computed(() => ownerId.value === 0)
|
||||
const ready = computed(() => props.source != null && ownerId.value !== null)
|
||||
// The record form's key; a stray manager without a form still gets one.
|
||||
const sessionKey = session?.key ?? newSessionKey()
|
||||
|
||||
const schema = ref<RelationSchema | null>(null)
|
||||
const rows = ref<AdminRecord[]>([])
|
||||
@@ -40,8 +65,17 @@ 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.
|
||||
const childOpen = ref(false)
|
||||
const childMode = ref<'create' | 'update' | 'preview'>('create')
|
||||
const childId = ref<number | null>(null)
|
||||
const pivotOpen = ref(false)
|
||||
const pivotChild = ref<number | null>(null)
|
||||
const pivotName = ref('')
|
||||
// The element that opened a dialog gets focus back when it closes. Function
|
||||
// refs: a template ref inside v-for would collect an array.
|
||||
let opener: HTMLElement | null = null
|
||||
let linkButton: HTMLElement | null = null
|
||||
let createButton: HTMLElement | null = null
|
||||
const confirm = useConfirm()
|
||||
let generation = 0
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -52,8 +86,41 @@ const messages = computed(() => schema.value?.messages)
|
||||
const headingId = computed(() => `${props.controlId}-heading`)
|
||||
const commentId = computed(() => `${props.controlId}-comment`)
|
||||
|
||||
const canCreate = computed(() => buttons.value.includes('create') && (schema.value?.manageForm?.length ?? 0) > 0)
|
||||
const canUpdate = computed(() => buttons.value.includes('update') && (schema.value?.manageForm?.length ?? 0) > 0)
|
||||
const hasPivot = computed(
|
||||
() =>
|
||||
schema.value?.kind === 'belongsToMany' &&
|
||||
(schema.value?.pivotForm?.length ?? 0) > 0 &&
|
||||
(buttons.value.includes('link') || buttons.value.includes('update')),
|
||||
)
|
||||
const canPreview = computed(() => (schema.value?.viewForm?.length ?? 0) > 0)
|
||||
/** What a row click opens, in the UI-SPEC order. */
|
||||
const rowAction = computed<'update' | 'pivot' | 'preview' | null>(() => {
|
||||
if (canUpdate.value) {
|
||||
return 'update'
|
||||
}
|
||||
if (hasPivot.value) {
|
||||
return 'pivot'
|
||||
}
|
||||
return canPreview.value ? 'preview' : null
|
||||
})
|
||||
const pivotButton = computed(() => canUpdate.value && hasPivot.value)
|
||||
const selectable = computed(() => buttons.value.includes('unlink') || buttons.value.includes('delete'))
|
||||
|
||||
function header(): { 'X-Session-Key': string } | undefined {
|
||||
return deferred.value ? ({ [SESSION_HEADER]: sessionKey } as { 'X-Session-Key': string }) : undefined
|
||||
}
|
||||
|
||||
function pathParams() {
|
||||
return { ...props.source!, id: props.recordId as number, name: relationName.value }
|
||||
return { ...props.source!, id: ownerId.value as number, name: relationName.value }
|
||||
}
|
||||
|
||||
/** On the create screen every change waits for the save: the form is dirty. */
|
||||
function changed(): void {
|
||||
if (deferred.value) {
|
||||
session?.markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSchema(): Promise<void> {
|
||||
@@ -78,7 +145,7 @@ async function loadRows(): Promise<void> {
|
||||
query.dir = sort.value.dir
|
||||
}
|
||||
const { data } = await api.GET('/{vendor}/{plugin}/{controller}/{id}/relations/{name}', {
|
||||
params: { path: pathParams(), query },
|
||||
params: { path: pathParams(), query, header: header() },
|
||||
})
|
||||
if (current !== generation) {
|
||||
return
|
||||
@@ -154,6 +221,23 @@ function selectedIds(): number[] {
|
||||
return selected.value.map(Number).filter((id) => Number.isInteger(id) && id > 0)
|
||||
}
|
||||
|
||||
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 nameOf(row: AdminRecord): string {
|
||||
const key = columns.value[0]?.key
|
||||
const value = key ? row[key] : undefined
|
||||
return typeof value === 'string' || typeof value === 'number' ? String(value) : ''
|
||||
}
|
||||
|
||||
async function onUnlink(): Promise<void> {
|
||||
const ids = selectedIds()
|
||||
if (busy.value || ids.length === 0 || !schema.value) {
|
||||
@@ -170,11 +254,12 @@ async function onUnlink(): Promise<void> {
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await api.POST('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink', {
|
||||
params: { path: pathParams() },
|
||||
params: { path: pathParams(), header: header() },
|
||||
body: { ids },
|
||||
})
|
||||
if (result.data) {
|
||||
selected.value = []
|
||||
changed()
|
||||
showToast(message(messages.value?.unlinked, result.data.data.removed ?? ids.length))
|
||||
await loadRows()
|
||||
return
|
||||
@@ -187,20 +272,125 @@ async function onUnlink(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs the confirmed delete; the confirm button stays busy meanwhile. */
|
||||
async function deleteSelected(ids: number[]): Promise<void> {
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await api.POST('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete', {
|
||||
params: { path: pathParams(), header: header() },
|
||||
body: { ids },
|
||||
})
|
||||
if (result.data) {
|
||||
selected.value = []
|
||||
changed()
|
||||
showToast(message(messages.value?.deleted, result.data.data.deleted || ids.length))
|
||||
await loadRows()
|
||||
return
|
||||
}
|
||||
// The selection is kept so the admin can try again.
|
||||
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 onDelete(): Promise<void> {
|
||||
const ids = selectedIds()
|
||||
if (busy.value || ids.length === 0 || !schema.value) {
|
||||
return
|
||||
}
|
||||
await confirm.ask(
|
||||
{
|
||||
message: message(messages.value?.deleteConfirm, ids.length),
|
||||
confirmLabel: message(messages.value?.deleteSelected),
|
||||
danger: true,
|
||||
},
|
||||
() => deleteSelected(ids),
|
||||
)
|
||||
}
|
||||
|
||||
async function onLinked(count: number): Promise<void> {
|
||||
changed()
|
||||
showToast(message(messages.value?.linked, count))
|
||||
selected.value = []
|
||||
await loadRows()
|
||||
}
|
||||
|
||||
/** Focus returns to the button that opened the picker. */
|
||||
function onCreate(event: MouseEvent): void {
|
||||
opener = (event.currentTarget as HTMLElement | null) ?? createButton
|
||||
childMode.value = 'create'
|
||||
childId.value = null
|
||||
childOpen.value = true
|
||||
}
|
||||
|
||||
function openPivot(row: AdminRecord, trigger: HTMLElement | null): void {
|
||||
const id = idOf(row)
|
||||
if (id === null) {
|
||||
return
|
||||
}
|
||||
opener = trigger
|
||||
pivotChild.value = id
|
||||
pivotName.value = nameOf(row)
|
||||
pivotOpen.value = true
|
||||
}
|
||||
|
||||
/** Row click: update, else pivot, else read-only preview (UI-SPEC order). */
|
||||
function onRowOpen(row: AdminRecord, trigger: HTMLElement | null): void {
|
||||
const action = rowAction.value
|
||||
const id = idOf(row)
|
||||
if (action === null || id === null) {
|
||||
return
|
||||
}
|
||||
if (action === 'pivot') {
|
||||
openPivot(row, trigger)
|
||||
return
|
||||
}
|
||||
opener = trigger
|
||||
childMode.value = action
|
||||
childId.value = id
|
||||
childOpen.value = true
|
||||
}
|
||||
|
||||
async function onChildChanged(): Promise<void> {
|
||||
changed()
|
||||
selected.value = []
|
||||
await loadRows()
|
||||
}
|
||||
|
||||
async function onPivotSaved(): Promise<void> {
|
||||
changed()
|
||||
await loadRows()
|
||||
}
|
||||
|
||||
async function onGone(): Promise<void> {
|
||||
selected.value = []
|
||||
await loadRows()
|
||||
}
|
||||
|
||||
/** Focus returns to the element that opened the dialog. */
|
||||
function returnFocus(): void {
|
||||
const target = opener && opener.isConnected ? opener : null
|
||||
opener = null
|
||||
target?.focus()
|
||||
}
|
||||
|
||||
function returnToLink(): void {
|
||||
linkButton?.focus()
|
||||
}
|
||||
|
||||
function setLinkButton(instance: unknown): void {
|
||||
function elementOf(instance: unknown): HTMLElement | null {
|
||||
const element = (instance as { $el?: unknown } | null)?.$el
|
||||
linkButton = element instanceof HTMLElement ? element : null
|
||||
return element instanceof HTMLElement ? element : null
|
||||
}
|
||||
|
||||
function setLinkButton(instance: unknown): void {
|
||||
linkButton = elementOf(instance)
|
||||
}
|
||||
|
||||
function setCreateButton(instance: unknown): void {
|
||||
createButton = elementOf(instance)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -218,62 +408,93 @@ void load()
|
||||
:id="controlId"
|
||||
data-relation-manager
|
||||
:data-relation-name="relationName"
|
||||
:data-deferred="deferred ? 'true' : undefined"
|
||||
: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>
|
||||
<div class="flex flex-col gap-2">
|
||||
<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 === 'create' && canCreate"
|
||||
:ref="setCreateButton"
|
||||
data-action="create"
|
||||
size="sm"
|
||||
variant="primary"
|
||||
:icon="Plus"
|
||||
:disabled="busy || !schema"
|
||||
aria-haspopup="dialog"
|
||||
@click="onCreate"
|
||||
>
|
||||
{{ message(messages?.create) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="button === 'link'"
|
||||
:ref="setLinkButton"
|
||||
data-action="link"
|
||||
size="sm"
|
||||
:variant="canCreate ? 'outline' : 'primary'"
|
||||
:icon="UserPlus"
|
||||
:disabled="busy || !schema"
|
||||
aria-haspopup="dialog"
|
||||
@click="pickerOpen = true"
|
||||
>
|
||||
{{ message(messages?.link) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="button === 'delete'"
|
||||
data-action="delete"
|
||||
size="sm"
|
||||
variant="danger"
|
||||
:icon="Trash2"
|
||||
:disabled="busy || selected.length === 0"
|
||||
@click="onDelete"
|
||||
>
|
||||
{{ message(messages?.deleteSelected) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="button === 'unlink'"
|
||||
data-action="unlink"
|
||||
size="sm"
|
||||
variant="danger"
|
||||
:icon="UserMinus"
|
||||
:disabled="busy || selected.length === 0"
|
||||
@click="onUnlink"
|
||||
>
|
||||
{{ message(messages?.unlinkSelected) }}
|
||||
</Button>
|
||||
</template>
|
||||
</header>
|
||||
<p v-if="deferred" data-relation-pending class="flex items-center gap-2 text-[13px] text-muted">
|
||||
<Info :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.relation.pending_note') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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') }}
|
||||
@@ -286,14 +507,28 @@ void load()
|
||||
:columns="columns"
|
||||
:rows="rows"
|
||||
:loading="loading"
|
||||
:selectable="buttons.includes('unlink')"
|
||||
:selectable="selectable"
|
||||
:sortable="true"
|
||||
:sort="sort"
|
||||
:openable="rowAction !== null"
|
||||
:trailing="pivotButton"
|
||||
@sort="onSort"
|
||||
@open="onRowOpen"
|
||||
>
|
||||
<template #empty>
|
||||
<span data-relation-empty class="text-muted">{{ message(messages?.empty) }}</span>
|
||||
</template>
|
||||
<template #trailing="{ row }">
|
||||
<button
|
||||
type="button"
|
||||
data-action="edit-pivot"
|
||||
:aria-label="message(messages?.editPivot, undefined, { name: nameOf(row) })"
|
||||
class="flex size-8 items-center justify-center rounded-pager text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text"
|
||||
@click="openPivot(row, $event.currentTarget as HTMLElement)"
|
||||
>
|
||||
<SlidersHorizontal :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
</template>
|
||||
</DataTable>
|
||||
<Pagination
|
||||
v-if="meta && meta.last_page > 1"
|
||||
@@ -309,10 +544,42 @@ void load()
|
||||
v-if="schema && buttons.includes('link')"
|
||||
v-model:open="pickerOpen"
|
||||
:source="source!"
|
||||
:record-id="recordId as number"
|
||||
:record-id="ownerId as number"
|
||||
:relation="relationName"
|
||||
:schema="schema"
|
||||
:session-key="deferred ? sessionKey : null"
|
||||
@linked="onLinked"
|
||||
@closed="returnToLink"
|
||||
/>
|
||||
|
||||
<RelationChildModal
|
||||
v-if="schema && (canCreate || rowAction === 'update' || rowAction === 'preview')"
|
||||
v-model:open="childOpen"
|
||||
:source="source!"
|
||||
:owner-id="ownerId as number"
|
||||
:relation="relationName"
|
||||
:schema="schema"
|
||||
:mode="childMode"
|
||||
:child-id="childId"
|
||||
:session-key="sessionKey"
|
||||
:can-delete="buttons.includes('delete')"
|
||||
@changed="onChildChanged"
|
||||
@gone="onGone"
|
||||
@closed="returnFocus"
|
||||
/>
|
||||
|
||||
<RelationPivotModal
|
||||
v-if="schema && hasPivot"
|
||||
v-model:open="pivotOpen"
|
||||
:source="source!"
|
||||
:owner-id="ownerId as number"
|
||||
:relation="relationName"
|
||||
:schema="schema"
|
||||
:child-id="pivotChild"
|
||||
:child-name="pivotName"
|
||||
:session-key="sessionKey"
|
||||
@saved="onPivotSaved"
|
||||
@gone="onGone"
|
||||
@closed="returnFocus"
|
||||
/>
|
||||
|
||||
@@ -321,6 +588,7 @@ void load()
|
||||
:message="confirm.request.value?.message ?? ''"
|
||||
:confirm-label="confirm.request.value?.confirmLabel"
|
||||
:danger="confirm.request.value?.danger"
|
||||
:busy="confirm.busy.value"
|
||||
@confirm="confirm.confirm"
|
||||
@cancel="confirm.cancel"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
288
admin/src/components/relation/RelationPivotModal.vue
Normal file
288
admin/src/components/relation/RelationPivotModal.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
} from 'reka-ui'
|
||||
import { X } from '@lucide/vue'
|
||||
import { api } from '../../api/client'
|
||||
import type { AdminRecord, ControllerParams, RelationSchema } from '../../api/types'
|
||||
import { message, t } from '../../app/i18n'
|
||||
import { SESSION_HEADER } from '../../app/sessionKey'
|
||||
import FormErrorBanner from '../form/FormErrorBanner.vue'
|
||||
import FormGrid from '../form/FormGrid.vue'
|
||||
import { editablePayload, fieldErrors, focusField, snapshot } from '../form/formState'
|
||||
import ConfirmDialog from '../ui/ConfirmDialog.vue'
|
||||
import { useConfirm } from '../ui/confirm'
|
||||
import { showToast } from '../../state/useToasts'
|
||||
|
||||
// Link details (Phase 12.2, D-14; UI-SPEC section 4, pivot modal). Edits
|
||||
// the pivot form (pivot.form) values of one belongsToMany link through
|
||||
// GET/PUT .../relations/{name}/pivot/{child}. Only the pivot form's fields
|
||||
// are sent; server-owned pivot columns never appear. A 422 stays in the
|
||||
// dialog, a 404 closes it and reloads the list, any other failure keeps the
|
||||
// input with a danger toast. Closing with changes asks first.
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
source: ControllerParams
|
||||
/** Owner record id; 0 for the record being created in the form's session. */
|
||||
ownerId: number
|
||||
relation: string
|
||||
schema: RelationSchema
|
||||
childId: number | null
|
||||
/** The related record's display name. */
|
||||
childName: string
|
||||
/** The record form's session key (X-Session-Key). */
|
||||
sessionKey: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:open': [open: boolean]
|
||||
saved: []
|
||||
gone: []
|
||||
closed: []
|
||||
}>()
|
||||
|
||||
const values = ref<AdminRecord>({})
|
||||
const errors = ref<Record<string, string[]>>({})
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
const busy = ref(false)
|
||||
const saved = ref('')
|
||||
const confirm = useConfirm()
|
||||
let generation = 0
|
||||
|
||||
const idPrefix = computed(() => `pivot-${props.relation}`)
|
||||
const fields = computed(() => props.schema.pivotForm ?? [])
|
||||
const fieldNames = computed(() => fields.value.map((field) => field.name))
|
||||
const dirty = computed(
|
||||
() => !loading.value && !failed.value && snapshot(editablePayload(fields.value, values.value)) !== saved.value,
|
||||
)
|
||||
|
||||
function header() {
|
||||
return { [SESSION_HEADER]: props.sessionKey } as { 'X-Session-Key': string }
|
||||
}
|
||||
|
||||
function path(child: number) {
|
||||
return { ...props.source, id: props.ownerId, name: props.relation, child }
|
||||
}
|
||||
|
||||
function update(name: string, value: unknown): void {
|
||||
values.value = { ...values.value, [name]: value }
|
||||
if (errors.value[name]) {
|
||||
const next = { ...errors.value }
|
||||
delete next[name]
|
||||
errors.value = next
|
||||
}
|
||||
}
|
||||
|
||||
function gone(): void {
|
||||
emit('update:open', false)
|
||||
showToast(t('backend::lang.relation.child_gone'), 'danger')
|
||||
emit('gone')
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const current = ++generation
|
||||
errors.value = {}
|
||||
failed.value = false
|
||||
busy.value = false
|
||||
values.value = {}
|
||||
saved.value = snapshot({})
|
||||
if (props.childId === null) {
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await api.GET('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}', {
|
||||
params: { path: path(props.childId), header: header() },
|
||||
})
|
||||
if (current !== generation) {
|
||||
return
|
||||
}
|
||||
if (result.data) {
|
||||
values.value = { ...result.data.data }
|
||||
saved.value = snapshot(editablePayload(fields.value, values.value))
|
||||
} else if (result.response.status === 404) {
|
||||
gone()
|
||||
} else {
|
||||
failed.value = true
|
||||
}
|
||||
} catch {
|
||||
if (current === generation) {
|
||||
failed.value = true
|
||||
}
|
||||
} finally {
|
||||
if (current === generation) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
void load()
|
||||
} else {
|
||||
generation++
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function setOpen(open: boolean): Promise<void> {
|
||||
if (open) {
|
||||
emit('update:open', true)
|
||||
return
|
||||
}
|
||||
if (busy.value) {
|
||||
return
|
||||
}
|
||||
if (dirty.value) {
|
||||
const ok = await confirm.ask({
|
||||
message: t('backend::lang.form.unsaved_confirm'),
|
||||
confirmLabel: t('backend::lang.form.discard'),
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) {
|
||||
return
|
||||
}
|
||||
}
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
if (busy.value || loading.value || failed.value || props.childId === null) {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await api.PUT('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}', {
|
||||
params: { path: path(props.childId), header: header() },
|
||||
body: editablePayload(fields.value, values.value),
|
||||
})
|
||||
if (result.data) {
|
||||
errors.value = {}
|
||||
saved.value = snapshot(editablePayload(fields.value, values.value))
|
||||
emit('update:open', false)
|
||||
showToast(message(props.schema.messages.pivotSaved))
|
||||
emit('saved')
|
||||
return
|
||||
}
|
||||
const status = result.response.status
|
||||
if (status === 422) {
|
||||
errors.value = fieldErrors(result.error?.error.details)
|
||||
const first = fields.value.find((field) => errors.value[field.name])
|
||||
if (first) {
|
||||
await nextTick()
|
||||
focusField(idPrefix.value, first.name)
|
||||
}
|
||||
} else if (status === 404) {
|
||||
gone()
|
||||
} else {
|
||||
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||
}
|
||||
} catch {
|
||||
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCloseAutoFocus(event: Event): void {
|
||||
event.preventDefault()
|
||||
emit('closed')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot :open="open" @update:open="setOpen">
|
||||
<DialogPortal>
|
||||
<DialogOverlay data-dialog-overlay class="fixed inset-0 z-40 bg-overlay" />
|
||||
<DialogContent
|
||||
data-relation-pivot-modal
|
||||
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 overflow-y-auto rounded-modal bg-surface p-6 text-text shadow-pop"
|
||||
@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.pivotTitle) }}</DialogTitle>
|
||||
<DialogDescription class="truncate text-muted" :title="childName">{{ childName }}</DialogDescription>
|
||||
</div>
|
||||
<DialogClose
|
||||
data-action="close"
|
||||
:aria-label="t('backend::lang.form.close')"
|
||||
:disabled="busy"
|
||||
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="loading"
|
||||
data-pivot-loading
|
||||
aria-hidden="true"
|
||||
class="flex flex-col gap-1.5"
|
||||
>
|
||||
<span class="block h-3 w-24 rounded-[6px] bg-skel" />
|
||||
<span class="block h-input rounded-control bg-skel" />
|
||||
</div>
|
||||
<p v-else-if="failed" role="alert" class="rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger">
|
||||
{{ t('backend::lang.relation.child_load_failed') }}
|
||||
</p>
|
||||
<form v-else class="flex flex-col gap-4" novalidate @submit.prevent="onSubmit">
|
||||
<FormErrorBanner :errors="errors" :field-names="fieldNames" />
|
||||
<fieldset :disabled="busy" class="m-0 min-w-0 border-0 p-0">
|
||||
<FormGrid
|
||||
:fields="fields"
|
||||
:values="values"
|
||||
:errors="errors"
|
||||
:source="source"
|
||||
:record-id="null"
|
||||
:id-prefix="idPrefix"
|
||||
@update="update"
|
||||
/>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<div class="flex gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
data-action="cancel"
|
||||
: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="setOpen(false)"
|
||||
>
|
||||
{{ t('backend::lang.form.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-action="save-pivot"
|
||||
:disabled="busy || loading || failed"
|
||||
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="onSubmit"
|
||||
>
|
||||
{{ busy ? t('backend::lang.form.saving') : message(schema.messages.pivotSubmit) }}
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
|
||||
<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"
|
||||
/>
|
||||
</template>
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
AlertDialogRoot,
|
||||
AlertDialogTitle,
|
||||
} from 'reka-ui'
|
||||
import { LoaderCircle } from '@lucide/vue'
|
||||
import { t } from '../../app/i18n'
|
||||
|
||||
// Confirmation (Reka AlertDialog: role alertdialog, focus trapped, Esc
|
||||
// cancels, focus returns to the trigger). Text only: messages come from the
|
||||
// server and are never rendered as HTML.
|
||||
withDefaults(
|
||||
// server and are never rendered as HTML. While `busy` (an action started by
|
||||
// the confirm is running) both buttons are disabled and the dialog stays.
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
open: boolean
|
||||
message: string
|
||||
@@ -21,13 +23,17 @@ withDefaults(
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
danger?: boolean
|
||||
busy?: boolean
|
||||
}>(),
|
||||
{ title: undefined, confirmLabel: undefined, cancelLabel: undefined, danger: false },
|
||||
{ title: undefined, confirmLabel: undefined, cancelLabel: undefined, danger: false, busy: false },
|
||||
)
|
||||
const emit = defineEmits<{ 'update:open': [open: boolean]; confirm: []; cancel: [] }>()
|
||||
|
||||
// Closing any other way than the confirm button (Esc, Anuluj) cancels.
|
||||
function onOpenChange(open: boolean): void {
|
||||
if (!open && props.busy) {
|
||||
return
|
||||
}
|
||||
emit('update:open', open)
|
||||
if (!open) {
|
||||
emit('cancel')
|
||||
@@ -35,6 +41,9 @@ function onOpenChange(open: boolean): void {
|
||||
}
|
||||
|
||||
function onConfirm(): void {
|
||||
if (props.busy) {
|
||||
return
|
||||
}
|
||||
emit('confirm')
|
||||
emit('update:open', false)
|
||||
}
|
||||
@@ -55,17 +64,21 @@ function onConfirm(): void {
|
||||
<div class="flex gap-2.5">
|
||||
<AlertDialogCancel
|
||||
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"
|
||||
: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"
|
||||
>
|
||||
{{ cancelLabel ?? t('backend::lang.form.cancel') }}
|
||||
</AlertDialogCancel>
|
||||
<button
|
||||
type="button"
|
||||
data-action="confirm"
|
||||
:disabled="busy"
|
||||
:aria-busy="busy ? 'true' : undefined"
|
||||
:class="danger ? 'bg-danger text-white' : 'bg-primary text-on-primary'"
|
||||
class="inline-flex h-button flex-1 items-center justify-center rounded-control font-semibold hover:opacity-90"
|
||||
class="inline-flex h-button flex-1 items-center justify-center gap-2 rounded-control font-semibold hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
@click="onConfirm"
|
||||
>
|
||||
<LoaderCircle v-if="busy" :size="16" class="animate-spin" aria-hidden="true" />
|
||||
{{ confirmLabel ?? t('backend::lang.form.confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// Promise-based confirmation for ConfirmDialog: `await ask(text)` resolves
|
||||
// true on confirm and false on Anuluj, Esc or a second request.
|
||||
// true on confirm and false on Anuluj, Esc or a second request. With an
|
||||
// action, the dialog stays open and busy while the action runs after the
|
||||
// confirm, and closes when it settles (a toolbar delete shows its progress
|
||||
// on the confirm button).
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface ConfirmRequest {
|
||||
@@ -10,22 +13,49 @@ export interface ConfirmRequest {
|
||||
|
||||
export function useConfirm() {
|
||||
const request = ref<ConfirmRequest | null>(null)
|
||||
const busy = ref(false)
|
||||
let resolver: ((ok: boolean) => void) | null = null
|
||||
let action: (() => Promise<unknown>) | null = null
|
||||
|
||||
function settle(ok: boolean): void {
|
||||
const resolve = resolver
|
||||
resolver = null
|
||||
action = null
|
||||
request.value = null
|
||||
resolve?.(ok)
|
||||
}
|
||||
|
||||
function ask(next: ConfirmRequest): Promise<boolean> {
|
||||
function ask(next: ConfirmRequest, run?: () => Promise<unknown>): Promise<boolean> {
|
||||
settle(false)
|
||||
request.value = next
|
||||
action = run ?? null
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolver = resolve
|
||||
})
|
||||
}
|
||||
|
||||
return { request, ask, confirm: () => settle(true), cancel: () => settle(false) }
|
||||
async function confirm(): Promise<void> {
|
||||
if (busy.value) {
|
||||
return
|
||||
}
|
||||
const run = action
|
||||
if (run) {
|
||||
busy.value = true
|
||||
try {
|
||||
await run()
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
settle(true)
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
if (busy.value) {
|
||||
return
|
||||
}
|
||||
settle(false)
|
||||
}
|
||||
|
||||
return { request, busy, ask, confirm, cancel }
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import FormErrorBanner from '../components/form/FormErrorBanner.vue'
|
||||
import FormGrid from '../components/form/FormGrid.vue'
|
||||
import FormTabs from '../components/form/FormTabs.vue'
|
||||
import { FORM_ASSETS, FORM_LOCALE, FORM_PATCH, FORM_SESSION, FORM_VALUES } from '../components/form/formContext'
|
||||
import { needsRecord } from '../components/form/registry'
|
||||
import { RELATION_MANAGER, needsRecord } from '../components/form/registry'
|
||||
import {
|
||||
DEFAULT_TAB,
|
||||
contextAllows,
|
||||
@@ -82,9 +82,13 @@ const revision = ref(0)
|
||||
|
||||
// A relation manager needs a saved record: on create it is dropped with its
|
||||
// tab even when the YAML forgets `context: update` (D-05, design screen 5).
|
||||
// A deferrable one (Phase 12.2, D-03) stays: it works on owner id 0 with the
|
||||
// form's session key and the save commits its changes.
|
||||
const fields = computed(() =>
|
||||
(schema.value?.fields ?? []).filter(
|
||||
(field) => contextAllows(field, mode) && (mode === 'update' || !needsRecord(field.type)),
|
||||
(field) =>
|
||||
contextAllows(field, mode) &&
|
||||
(mode === 'update' || !needsRecord(field.type) || (field.type === RELATION_MANAGER && !!field.deferrable)),
|
||||
),
|
||||
)
|
||||
const fieldNames = computed(() => fields.value.map((field) => field.name))
|
||||
|
||||
Reference in New Issue
Block a user