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

@@ -1,28 +1,52 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { ArrowDown, ArrowUp, Check, Minus } from '@lucide/vue'
import type { AdminRecord, ListColumn } from '../../api/types'
import { t } from '../../app/i18n'
import type { RowId } from '../../app/listQuery'
import CellValue from './CellValue.vue'
// Schema-driven table: columns come from the list schema and each record is
// read through its column keys (D-16). With rowLink a row opens its record
// route; the first cell carries the link for keyboard users.
const props = defineProps<{
columns: ListColumn[]
rows: AdminRecord[]
loading?: boolean
emptyText?: string
rowLink?: (row: AdminRecord) => string | null
}>()
// Schema-driven table (design screen 3, D-12, D-16): exactly the server's
// columns, each record read through its column keys. Optional checkbox
// column with a tri-state header over the current page, sortable headers
// (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.
const props = withDefaults(
defineProps<{
columns: ListColumn[]
rows: AdminRecord[]
loading?: boolean
rowLink?: (row: AdminRecord) => string | null
selectable?: boolean
selected?: RowId[]
sortable?: boolean
sort?: { column: string; dir: 'asc' | 'desc' } | null
}>(),
{ loading: false, rowLink: undefined, selectable: false, selected: () => [], sortable: false, sort: null },
)
const emit = defineEmits<{ 'update:selected': [ids: RowId[]]; sort: [column: string] }>()
const router = useRouter()
function rowId(row: AdminRecord): RowId | null {
const id = row.id
return typeof id === 'number' || typeof id === 'string' ? id : null
}
function rowKey(row: AdminRecord, index: number): string {
const id = rowId(row)
return id === null ? `row-${index}` : String(id)
}
function linkOf(row: AdminRecord): string | null {
return props.rowLink ? props.rowLink(row) : null
}
function openRow(event: MouseEvent, row: AdminRecord): void {
const target = event.target as HTMLElement | null
if (target?.closest('a, button, input, label')) {
if (target?.closest('a, button, input, label, select')) {
return
}
const to = linkOf(row)
@@ -31,79 +55,152 @@ function openRow(event: MouseEvent, row: AdminRecord): void {
}
}
function cellText(row: AdminRecord, column: ListColumn): string {
return formatValue(row[column.key])
const pageIds = computed(() => props.rows.map(rowId).filter((id): id is RowId => id !== null))
const selectedSet = computed(() => new Set(props.selected.map(String)))
const headerState = computed<'true' | 'false' | 'mixed'>(() => {
const ids = pageIds.value
const chosen = ids.filter((id) => selectedSet.value.has(String(id))).length
if (chosen === 0) {
return 'false'
}
return chosen === ids.length ? 'true' : 'mixed'
})
function isSelected(row: AdminRecord): boolean {
const id = rowId(row)
return id !== null && selectedSet.value.has(String(id))
}
function formatValue(value: unknown): string {
if (value === null || value === undefined || value === '') {
return '—'
}
if (Array.isArray(value)) {
const parts = value.map(formatValue).filter((part) => part !== '—')
return parts.length === 0 ? '—' : parts.join(', ')
}
if (typeof value === 'boolean') {
return value ? t('backend::lang.list.column_switch_true') : t('backend::lang.list.column_switch_false')
}
if (typeof value === 'object') {
return '—'
}
return String(value)
function toggleAll(): void {
emit('update:selected', headerState.value === 'true' ? [] : [...pageIds.value])
}
function rowKey(row: AdminRecord, index: number): string {
const id = row.id
return typeof id === 'number' || typeof id === 'string' ? String(id) : `row-${index}`
function toggleRow(row: AdminRecord): void {
const id = rowId(row)
if (id === null) {
return
}
if (selectedSet.value.has(String(id))) {
emit(
'update:selected',
props.selected.filter((item) => String(item) !== String(id)),
)
} else {
emit('update:selected', [...props.selected, id])
}
}
function sortState(column: ListColumn): 'ascending' | 'descending' | undefined {
if (props.sort?.column !== column.key) {
return undefined
}
return props.sort.dir === 'desc' ? 'descending' : 'ascending'
}
function canSort(column: ListColumn): boolean {
return props.sortable && column.sortable
}
const span = computed(() => Math.max(props.columns.length + (props.selectable ? 1 : 0), 1))
// 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']
</script>
<template>
<div class="overflow-x-auto">
<table class="w-full min-w-[640px] border-collapse text-left">
<thead>
<thead class="sticky top-0 z-10">
<tr class="h-row-head border-y border-border bg-subtle">
<th v-if="selectable" scope="col" data-select class="w-[52px] pl-5">
<button
type="button"
role="checkbox"
:aria-checked="headerState"
:aria-label="t('backend::lang.list.select_all')"
:disabled="loading || pageIds.length === 0"
:class="headerState === 'false' ? 'border-border-strong bg-surface' : 'border-primary bg-primary text-on-primary'"
class="flex size-[18px] items-center justify-center rounded-checkbox border disabled:opacity-60"
@click="toggleAll"
>
<Minus v-if="headerState === 'mixed'" :size="14" aria-hidden="true" />
<Check v-else-if="headerState === 'true'" :size="14" aria-hidden="true" />
</button>
</th>
<th
v-for="column in columns"
:key="column.key"
scope="col"
class="px-3.5 text-[12px] font-semibold text-muted first:pl-5 last:pr-5"
:aria-sort="canSort(column) ? (sortState(column) ?? 'none') : undefined"
:class="sortState(column) ? 'text-text' : 'text-muted'"
class="px-3.5 text-[12px] font-semibold first:pl-5 last:pr-5"
>
{{ column.label }}
<button
v-if="canSort(column)"
type="button"
:data-sort="column.key"
class="inline-flex items-center gap-1 font-semibold"
@click="emit('sort', column.key)"
>
{{ column.label }}
<ArrowUp v-if="sortState(column) === 'ascending'" :size="14" aria-hidden="true" />
<ArrowDown v-else-if="sortState(column) === 'descending'" :size="14" aria-hidden="true" />
</button>
<template v-else>{{ column.label }}</template>
</th>
</tr>
</thead>
<tbody :aria-busy="loading ? 'true' : undefined">
<template v-if="loading">
<tr v-for="n in 8" :key="`skeleton-${n}`" class="h-row border-b border-border">
<tr v-for="n in 8" :key="`skeleton-${n}`" data-skeleton class="h-row border-b border-border">
<td v-if="selectable" class="pl-5">
<span class="block size-[18px] rounded-checkbox bg-skel" />
</td>
<td v-for="column in columns" :key="column.key" class="px-3.5 first:pl-5 last:pr-5">
<span class="block h-3 w-3/5 rounded-[6px] bg-skel" />
<span :class="widths[(n - 1) % widths.length]" class="block h-3 rounded-[6px] bg-skel" />
</td>
</tr>
</template>
<tr v-else-if="rows.length === 0">
<td :colspan="Math.max(columns.length, 1)" class="px-5 py-20 text-center text-muted">
{{ emptyText ?? t('backend::lang.list.no_records') }}
<td :colspan="span" class="px-5 py-20 text-center">
<slot name="empty">
<span class="text-muted">{{ t('backend::lang.list.no_records') }}</span>
</slot>
</td>
</tr>
<template v-else>
<tr
v-for="(row, index) in rows"
:key="rowKey(row, index)"
:class="linkOf(row) ? 'cursor-pointer hover:bg-hover' : ''"
class="h-row border-b border-border"
:class="[isSelected(row) ? 'bg-sel' : linkOf(row) ? 'hover:bg-hover' : '', linkOf(row) ? 'cursor-pointer' : '']"
:aria-selected="selectable ? (isSelected(row) ? 'true' : 'false') : undefined"
class="h-row border-b border-border transition-colors duration-150 ease-out"
@click="openRow($event, row)"
>
<td v-if="selectable" data-select class="pl-5">
<button
type="button"
role="checkbox"
:aria-checked="isSelected(row) ? 'true' : 'false'"
:aria-label="t('backend::lang.list.select_row')"
:class="isSelected(row) ? 'border-primary bg-primary text-on-primary' : 'border-border-strong bg-surface'"
class="flex size-[18px] items-center justify-center rounded-checkbox border"
@click="toggleRow(row)"
>
<Check v-if="isSelected(row)" :size="14" aria-hidden="true" />
</button>
</td>
<td
v-for="(column, columnIndex) in columns"
:key="column.key"
:class="columnIndex === 0 ? 'font-semibold' : 'text-muted'"
:class="columnIndex === 0 ? 'font-semibold' : column.relation ? 'text-muted' : ''"
class="px-3.5 first:pl-5 last:pr-5"
>
<RouterLink v-if="columnIndex === 0 && linkOf(row)" :to="linkOf(row)!" class="text-text no-underline">
{{ cellText(row, column) }}
<CellValue :column="column" :value="row[column.key]" />
</RouterLink>
<template v-else>{{ cellText(row, column) }}</template>
<CellValue v-else :column="column" :value="row[column.key]" />
</td>
</tr>
</template>