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

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

View File

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