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:
Jakub Zych
2026-10-02 19:56:10 +02:00
parent a0c182745e
commit 69a37456f6
20 changed files with 1698 additions and 112 deletions

View File

@@ -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>