feat(10-03): search, sort, filter, page and bulk-delete any list

- List state (search, sort, dir, page, per_page, filter[<name>]) lives in
  the URL query through parseListQuery/toListQuery; every change is a
  router replace and clears the selection; search is debounced 300 ms and
  resets the page
- DataTable renders the schema columns with a tri-state page checkbox,
  asc/desc/none sorting with aria-sort, selected rows, a sticky header,
  eight skeleton rows while loading and an empty slot
- CellValue renders text (muted dash when empty, arrays comma-joined),
  datetime as YYYY-MM-DD HH:mm and switch as the Tak/Nie pills
- The heading shows the plural recordCount and the create button;
  delete sits in the toolbar, disabled without a selection, and confirms
  with the plural deleteConfirm in a Reka alert dialog before POSTing
  bulk-delete; a 409 shows a danger toast
- FilterBar renders switch (JSON of the option value), daterange
  (from..to) and scope filters (choices from filters/{scope}/options)
- Pagination shows the range, the per-page select over perPageOptions
  (hidden with one choice) and a pager with ellipsis
- The tracer smoke test skips the new checkbox column; new backend::lang
  list keys; boardwalk/dist rebuilt
This commit is contained in:
Jakub Zych
2026-09-27 16:51:00 +02:00
parent 126ca5b8ed
commit 8b5f85603f
22 changed files with 1421 additions and 118 deletions

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import {
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
} from 'reka-ui'
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(
defineProps<{
open: boolean
message: string
title?: string
confirmLabel?: string
cancelLabel?: string
danger?: boolean
}>(),
{ title: undefined, confirmLabel: undefined, cancelLabel: undefined, danger: 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 {
emit('update:open', open)
if (!open) {
emit('cancel')
}
}
function onConfirm(): void {
emit('confirm')
emit('update:open', false)
}
</script>
<template>
<AlertDialogRoot :open="open" @update:open="onOpenChange">
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-40 bg-overlay" />
<AlertDialogContent
data-confirm-dialog
class="fixed top-1/2 left-1/2 z-50 flex w-[calc(100%-32px)] max-w-[440px] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-modal bg-surface p-6 text-text shadow-pop"
>
<AlertDialogTitle class="text-[20px] font-bold tracking-[-0.01em]">
{{ title ?? t('backend::lang.form.confirm') }}
</AlertDialogTitle>
<AlertDialogDescription class="text-muted">{{ message }}</AlertDialogDescription>
<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"
>
{{ cancelLabel ?? t('backend::lang.form.cancel') }}
</AlertDialogCancel>
<button
type="button"
data-action="confirm"
: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"
@click="onConfirm"
>
{{ confirmLabel ?? t('backend::lang.form.confirm') }}
</button>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</template>

View File

@@ -0,0 +1,31 @@
// Promise-based confirmation for ConfirmDialog: `await ask(text)` resolves
// true on confirm and false on Anuluj, Esc or a second request.
import { ref } from 'vue'
export interface ConfirmRequest {
message: string
confirmLabel?: string
danger?: boolean
}
export function useConfirm() {
const request = ref<ConfirmRequest | null>(null)
let resolver: ((ok: boolean) => void) | null = null
function settle(ok: boolean): void {
const resolve = resolver
resolver = null
request.value = null
resolve?.(ok)
}
function ask(next: ConfirmRequest): Promise<boolean> {
settle(false)
request.value = next
return new Promise<boolean>((resolve) => {
resolver = resolve
})
}
return { request, ask, confirm: () => settle(true), cancel: () => settle(false) }
}