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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user