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,85 @@
// List state lives in the URL query (design: Interactions, D-22): search,
// sort, dir, page, per_page and filter[<name>]. These helpers convert between
// the route query and the typed list query the API client sends. Anything
// malformed (a non-numeric page, an unknown direction, an empty filter) is
// dropped, so a hand-edited URL never reaches the server half-valid.
import type { LocationQuery, LocationQueryRaw } from 'vue-router'
import type { ListQuery } from '../api/types'
/** A list row's id as the record carries it. */
export type RowId = number | string
const FILTER_KEY = /^filter\[(.+)\]$/
const POSITIVE = /^[1-9]\d*$/
function first(value: LocationQuery[string] | undefined): string {
const raw = Array.isArray(value) ? value[value.length - 1] : value
return typeof raw === 'string' ? raw : ''
}
function positive(value: string): number | undefined {
return POSITIVE.test(value) ? Number(value) : undefined
}
export function parseListQuery(query: LocationQuery): ListQuery {
const out: ListQuery = {}
const search = first(query.search).trim()
if (search !== '') {
out.search = search
}
const sort = first(query.sort).trim()
if (sort !== '') {
out.sort = sort
const dir = first(query.dir)
out.dir = dir === 'desc' ? 'desc' : 'asc'
}
const page = positive(first(query.page))
if (page !== undefined) {
out.page = page
}
const perPage = positive(first(query.per_page))
if (perPage !== undefined) {
out.per_page = perPage
}
const filter: Record<string, string> = {}
for (const key of Object.keys(query).sort()) {
const name = FILTER_KEY.exec(key)?.[1]
const value = first(query[key])
if (name && value !== '') {
filter[name] = value
}
}
if (Object.keys(filter).length > 0) {
out.filter = filter
}
return out
}
export function toListQuery(input: ListQuery): LocationQueryRaw {
const out: LocationQueryRaw = {}
if (input.search) {
out.search = input.search
}
if (input.sort) {
out.sort = input.sort
out.dir = input.dir === 'desc' ? 'desc' : 'asc'
}
if (input.page !== undefined && input.page > 1) {
out.page = String(input.page)
}
if (input.per_page !== undefined) {
out.per_page = String(input.per_page)
}
for (const name of Object.keys(input.filter ?? {}).sort()) {
const value = input.filter?.[name]
if (value !== undefined && value !== '') {
out[`filter[${name}]`] = value
}
}
return out
}
/** A stable key for comparing two list queries. */
export function listQueryKey(input: ListQuery): string {
return JSON.stringify(toListQuery(input))
}

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ArrowUpRight } from '@lucide/vue'
import type { ListColumn } from '../../api/types'
import { t } from '../../app/i18n'
// One cell rendered by column type (D-12): text by default (arrays
// comma-joined, a muted dash when empty), datetime as YYYY-MM-DD HH:mm with
// tabular numbers, switch as the Tak/Nie pills. No per-value icon pills.
const props = defineProps<{ column: ListColumn; value: unknown }>()
function isEmpty(value: unknown): boolean {
return value === null || value === undefined || value === ''
}
function text(value: unknown): string {
if (isEmpty(value)) {
return ''
}
if (Array.isArray(value)) {
return value.map(text).filter((part) => part !== '').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 pad(n: number): string {
return String(n).padStart(2, '0')
}
function datetime(value: unknown): string {
if (typeof value !== 'string' && typeof value !== 'number') {
return ''
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return String(value)
}
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function truthy(value: unknown): boolean {
if (typeof value === 'string') {
return value !== '' && value !== '0' && value.toLowerCase() !== 'false'
}
return Boolean(value)
}
const kind = computed(() => {
if (props.column.type === 'switch') {
return 'switch'
}
if (props.column.type === 'datetime') {
return 'datetime'
}
return 'text'
})
const display = computed(() => (kind.value === 'datetime' ? datetime(props.value) : text(props.value)))
</script>
<template>
<template v-if="kind === 'switch' && !isEmpty(value)">
<span
v-if="truthy(value)"
data-switch="true"
class="inline-flex h-6 items-center gap-1 rounded-pill bg-ok-bg px-2.5 text-[12px] font-semibold text-ok-text"
>
<ArrowUpRight :size="14" aria-hidden="true" />{{ t('backend::lang.list.column_switch_true') }}
</span>
<span
v-else
data-switch="false"
class="inline-flex h-6 items-center rounded-pill border border-border-strong px-2.5 text-[12px] font-semibold text-muted"
>{{ t('backend::lang.list.column_switch_false') }}</span
>
</template>
<span v-else-if="display === ''" class="text-muted" data-empty>—</span>
<span v-else-if="kind === 'datetime'" class="text-[13px] text-muted tabular-nums">{{ display }}</span>
<template v-else>{{ display }}</template>
</template>

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>

View File

@@ -0,0 +1,161 @@
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue'
import { api } from '../../api/client'
import type { ControllerParams, FilterOption, ListFilter } from '../../api/types'
import { t } from '../../app/i18n'
// Filter bar (D-22, D-27) for the three Phase 9 filter shapes. Each control
// writes filter[<name>] through the parent (which resets the page); the wire
// values follow cabana's list query:
// switch JSON of the chosen option value (JSON.stringify)
// daterange YYYY-MM-DD..YYYY-MM-DD
// scope the chosen value from GET filters/{scope}/options
const props = defineProps<{
filters: ListFilter[]
values: Record<string, string>
source: ControllerParams
}>()
const emit = defineEmits<{ update: [name: string, value: string | null] }>()
const ALL = ''
interface Choice {
value: string
label: string
}
function switchChoices(filter: ListFilter): Choice[] {
if (filter.options && filter.options.length > 0) {
return filter.options.map((option) => ({ value: JSON.stringify(option.value), label: option.label }))
}
const out: Choice[] = []
if (filter.trueValue !== undefined) {
out.push({ value: JSON.stringify(filter.trueValue), label: t('backend::lang.list.column_switch_true') })
}
if (filter.falseValue !== undefined) {
out.push({ value: JSON.stringify(filter.falseValue), label: t('backend::lang.list.column_switch_false') })
}
return out
}
// Scope choices come from the model's FilterOptions, loaded once per filter.
const scopeChoices = ref<Record<string, FilterOption[]>>({})
async function loadScope(filter: ListFilter): Promise<void> {
const { data } = await api.GET('/{vendor}/{plugin}/{controller}/filters/{scope}/options', {
params: { path: { ...props.source, scope: filter.name } },
})
scopeChoices.value = { ...scopeChoices.value, [filter.name]: data?.data ?? [] }
}
onMounted(() => {
for (const filter of props.filters) {
if (filter.type === 'scope') {
void loadScope(filter)
}
}
})
// Date ranges keep a half-filled pair locally; the filter is written only
// when both ends are set, and removed when both are cleared.
const ranges = reactive<Record<string, { from: string; to: string }>>({})
function syncRanges(): void {
for (const filter of props.filters) {
if (filter.type !== 'daterange') {
continue
}
const [from = '', to = ''] = (props.values[filter.name] ?? '').split('..')
const current = ranges[filter.name]
if (!current || props.values[filter.name] !== undefined || (current.from !== '' && current.to !== '')) {
ranges[filter.name] = { from, to }
}
}
}
watch(() => props.values, syncRanges, { immediate: true, deep: true })
function onRange(filter: ListFilter, end: 'from' | 'to', value: string): void {
const range = { ...(ranges[filter.name] ?? { from: '', to: '' }), [end]: value }
ranges[filter.name] = range
if (range.from !== '' && range.to !== '') {
emit('update', filter.name, `${range.from}..${range.to}`)
} else if (range.from === '' && range.to === '') {
emit('update', filter.name, null)
} else if (props.values[filter.name] !== undefined) {
emit('update', filter.name, null)
}
}
function onSelect(filter: ListFilter, value: string): void {
emit('update', filter.name, value === ALL ? null : value)
}
const selectClass = 'h-button rounded-control border border-border-strong bg-surface px-3 text-text'
</script>
<template>
<div
role="group"
:aria-label="t('backend::lang.list.filters')"
data-filter-bar
class="flex flex-wrap items-end gap-4 border-t border-border px-5 py-3.5"
>
<template v-for="filter in filters" :key="filter.name">
<label v-if="filter.type === 'switch'" class="flex flex-col gap-1 text-[13px]">
<span class="font-semibold text-muted">{{ filter.label || filter.name }}</span>
<select
:data-filter="filter.name"
:value="values[filter.name] ?? ALL"
:class="selectClass"
@change="onSelect(filter, ($event.target as HTMLSelectElement).value)"
>
<option :value="ALL">{{ t('backend::lang.list.filter_all') }}</option>
<option v-for="choice in switchChoices(filter)" :key="choice.value" :value="choice.value">
{{ choice.label }}
</option>
</select>
</label>
<fieldset v-else-if="filter.type === 'daterange'" class="flex flex-col gap-1 text-[13px]">
<legend class="mb-1 font-semibold text-muted">{{ filter.label || filter.name }}</legend>
<div class="flex items-center gap-2">
<input
type="date"
:data-filter="`${filter.name}-from`"
:aria-label="t('backend::lang.list.filter_from')"
:value="ranges[filter.name]?.from ?? ''"
:class="selectClass"
@change="onRange(filter, 'from', ($event.target as HTMLInputElement).value)"
/>
<span aria-hidden="true">–</span>
<input
type="date"
:data-filter="`${filter.name}-to`"
:aria-label="t('backend::lang.list.filter_to')"
:value="ranges[filter.name]?.to ?? ''"
:class="selectClass"
@change="onRange(filter, 'to', ($event.target as HTMLInputElement).value)"
/>
</div>
</fieldset>
<label v-else-if="filter.type === 'scope'" class="flex flex-col gap-1 text-[13px]">
<span class="font-semibold text-muted">{{ filter.label || filter.name }}</span>
<!-- Re-created when the choices arrive so the URL value selects. -->
<select
:key="`${filter.name}-${scopeChoices[filter.name]?.length ?? -1}`"
:data-filter="filter.name"
:value="values[filter.name] ?? ALL"
:class="selectClass"
@change="onSelect(filter, ($event.target as HTMLSelectElement).value)"
>
<option :value="ALL">{{ t('backend::lang.list.filter_all') }}</option>
<option v-for="choice in scopeChoices[filter.name] ?? []" :key="choice.value" :value="choice.value">
{{ choice.label }}
</option>
</select>
</label>
</template>
</div>
</template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import { Check, Search, Trash2 } from '@lucide/vue'
import { t } from '../../app/i18n'
import Button from '../ui/Button.vue'
// List toolbar (design screen 3, D-14): the search input and the toolbar
// buttons in declared order. Delete is a disabled outline button without a
// selection and a danger outline button next to the "selected" pill with one.
defineProps<{
showSearch: boolean
search: string
searchPrompt: string
buttons: string[]
selectedCount: number
selectedLabel: string
deleteLabel: string
}>()
const emit = defineEmits<{ 'update:search': [value: string]; delete: [] }>()
</script>
<template>
<div class="flex flex-wrap items-center justify-between gap-3 px-5 py-4">
<label v-if="showSearch" class="relative block w-[320px] max-w-full">
<span class="sr-only">{{ t('backend::lang.list.search') }}</span>
<Search :size="18" class="pointer-events-none absolute top-1/2 left-3.5 -translate-y-1/2 text-muted" aria-hidden="true" />
<input
type="search"
data-list-search
:value="search"
:placeholder="searchPrompt"
class="h-button w-full rounded-control border border-transparent bg-subtle pr-3.5 pl-10"
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
/>
</label>
<span v-else />
<div class="flex items-center gap-2.5">
<span
v-if="selectedCount > 0"
data-selected-pill
class="inline-flex h-8 items-center gap-1.5 rounded-pill bg-sel px-3 text-[13px] font-semibold"
>
<Check :size="14" aria-hidden="true" />{{ selectedLabel }}
</span>
<template v-for="button in buttons" :key="button">
<Button
v-if="button === 'delete'"
data-action="delete"
:variant="selectedCount > 0 ? 'danger' : 'outline'"
:icon="Trash2"
:disabled="selectedCount === 0"
@click="emit('delete')"
>
{{ deleteLabel }}
</Button>
</template>
</div>
</div>
</template>

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import type { ListMeta } from '../../api/types'
import { t, tc } from '../../app/i18n'
// List footer (design screen 3): the range ":from–:to z :total", the
// per-page select over the schema's options (hidden with one choice,
// research Gap 7) and a pager with ellipsis. Zero rows show the plural
// results message and no pager.
const props = defineProps<{
meta: ListMeta | null
loading: boolean
perPageOptions: number[]
perPage: number
}>()
const emit = defineEmits<{ page: [page: number]; perPage: [perPage: number] }>()
const total = computed(() => props.meta?.total ?? 0)
const page = computed(() => props.meta?.page ?? 1)
const lastPage = computed(() => Math.max(props.meta?.last_page ?? 1, 1))
const range = computed(() => {
const m = props.meta
if (!m || m.total === 0) {
return tc('backend::lang.list.results', 0)
}
const from = (m.page - 1) * m.per_page + 1
const to = Math.min(m.page * m.per_page, m.total)
return t('backend::lang.list.pagination_range', { from, to, total: m.total })
})
/** Page numbers with null for an ellipsis: 1 … p-1 p p+1 … last. */
const pages = computed<Array<number | null>>(() => {
const last = lastPage.value
const current = page.value
const wanted = new Set([1, last, current - 1, current, current + 1].filter((n) => n >= 1 && n <= last))
if (current <= 3) {
;[2, 3].forEach((n) => n <= last && wanted.add(n))
}
if (current >= last - 2) {
;[last - 1, last - 2].forEach((n) => n >= 1 && wanted.add(n))
}
const sorted = [...wanted].sort((a, b) => a - b)
const out: Array<number | null> = []
sorted.forEach((n, index) => {
const prev = sorted[index - 1]
if (prev !== undefined && n - prev > 1) {
out.push(null)
}
out.push(n)
})
return out
})
function onPerPage(event: Event): void {
const value = Number((event.target as HTMLSelectElement).value)
if (Number.isFinite(value) && value > 0) {
emit('perPage', value)
}
}
const pagerButton =
'inline-flex h-pager min-w-pager items-center justify-center gap-1 rounded-pager border border-border px-2.5 text-[13px] text-text disabled:cursor-not-allowed disabled:opacity-60 hover:bg-hover'
</script>
<template>
<footer class="flex flex-wrap items-center gap-x-6 gap-y-3 border-t border-border px-5 py-3.5 text-[13px] text-muted">
<span data-range>{{ loading ? t('backend::lang.list.loading') : range }}</span>
<label v-if="perPageOptions.length > 1" class="flex items-center gap-2">
<span>{{ t('backend::lang.list.records_per_page') }}</span>
<select
data-per-page
:value="String(perPage)"
class="h-pager rounded-pager border border-border-strong bg-surface px-2 text-text"
@change="onPerPage"
>
<option v-for="option in perPageOptions" :key="option" :value="String(option)">{{ option }}</option>
</select>
</label>
<nav
v-if="!loading && total > 0"
:aria-label="t('backend::lang.list.pagination')"
class="ml-auto flex items-center gap-1.5"
>
<button type="button" data-page="prev" :class="pagerButton" :disabled="page <= 1" @click="emit('page', page - 1)">
<ChevronLeft :size="14" aria-hidden="true" />{{ t('backend::lang.list.prev_page') }}
</button>
<template v-for="(n, index) in pages" :key="n ?? `gap-${index}`">
<span v-if="n === null" aria-hidden="true" class="px-1">…</span>
<button
v-else
type="button"
:data-page="n"
:aria-current="n === page ? 'page' : undefined"
:class="[pagerButton, n === page ? 'border-transparent bg-sel font-bold' : '']"
@click="n !== page && emit('page', n)"
>
{{ n }}
</button>
</template>
<button
type="button"
data-page="next"
:class="pagerButton"
:disabled="page >= lastPage"
@click="emit('page', page + 1)"
>
{{ t('backend::lang.list.next_page') }}<ChevronRight :size="14" aria-hidden="true" />
</button>
</nav>
</footer>
</template>

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

View File

@@ -1,46 +1,77 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Plus, SearchX } from '@lucide/vue'
import { api } from '../api/client'
import type { AdminRecord, ListMeta, ListSchema } from '../api/types'
import { controllerIdFromParams } from '../app/controllerRoutes'
import { t } from '../app/i18n'
import type { AdminRecord, ListMeta, ListQuery, ListSchema } from '../api/types'
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
import { message, t } from '../app/i18n'
import { listQueryKey, parseListQuery, toListQuery, type RowId } from '../app/listQuery'
import { mapWinterUrl } from '../app/winterUrl'
import DataTable from '../components/list/DataTable.vue'
import FilterBar from '../components/list/FilterBar.vue'
import ListToolbar from '../components/list/ListToolbar.vue'
import Pagination from '../components/list/Pagination.vue'
import Button from '../components/ui/Button.vue'
import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
import { useConfirm } from '../components/ui/confirm'
import { activeEntry } from '../state/useNavigation'
import { showToast } from '../state/useToasts'
// Schema-driven list (design screen 3, D-12, D-13, D-14, D-22). Search, sort,
// page, per_page and filters live in the URL query and every change is a
// router replace; any change clears the selection. The view is keyed by
// route path, so it serves one controller.
const SEARCH_DEBOUNCE = 300
const route = useRoute()
const router = useRouter()
const path = computed(() => ({
const path = {
vendor: String(route.params.vendor ?? ''),
plugin: String(route.params.plugin ?? ''),
controller: String(route.params.controller ?? ''),
}))
}
const controllerId = controllerIdFromParams(path)
const basePath = controllerPath(controllerId) ?? '/'
const schema = ref<ListSchema | null>(null)
const rows = ref<AdminRecord[]>([])
const meta = ref<ListMeta | null>(null)
const loading = ref(true)
const failed = ref(false)
const selected = ref<RowId[]>([])
const deleting = ref(false)
const confirm = useConfirm()
const query = computed(() => parseListQuery(route.query))
const searchText = ref(query.value.search ?? '')
const messages = computed(() => schema.value?.messages)
const title = computed(() => {
if (schema.value?.title) {
return schema.value.title
}
const id = controllerIdFromParams(path.value)
const entry = activeEntry(path.value.vendor, path.value.plugin)
return entry?.sideMenu.find((item) => item.controller === id)?.label ?? id
const entry = activeEntry(path.vendor, path.plugin)
return entry?.sideMenu.find((item) => item.controller === controllerId)?.label ?? controllerId
})
const range = computed(() => {
const m = meta.value
if (!m || m.total === 0) {
return t('backend::lang.list.no_results')
}
const from = (m.page - 1) * m.per_page + 1
const to = Math.min(m.page * m.per_page, m.total)
return t('backend::lang.list.pagination_range', { from, to, total: m.total })
const subtitle = computed(() => (meta.value ? message(messages.value?.recordCount, meta.value.total) : ''))
const buttons = computed(() => schema.value?.toolbarButtons ?? [])
const headingButtons = computed(() => buttons.value.filter((button) => button === 'create'))
const toolbarButtons = computed(() => buttons.value.filter((button) => button === 'delete'))
const perPageOptions = computed(() => {
const options = schema.value?.perPageOptions ?? []
return options.length > 0 ? options : [schema.value?.recordsPerPage || 20]
})
const perPage = computed(() => query.value.per_page ?? meta.value?.per_page ?? schema.value?.recordsPerPage ?? 20)
const sort = computed(() =>
query.value.sort ? { column: query.value.sort, dir: query.value.dir === 'desc' ? ('desc' as const) : ('asc' as const) } : null,
)
/** Record route of a row when the list declares a recordUrl (D-10). */
function rowLink(row: AdminRecord): string | null {
@@ -49,53 +80,224 @@ function rowLink(row: AdminRecord): string | null {
if (!recordUrl || (typeof id !== 'number' && typeof id !== 'string')) {
return null
}
return mapWinterUrl(recordUrl, controllerIdFromParams(path.value), id)
return mapWinterUrl(recordUrl, controllerId, id)
}
function replaceQuery(next: ListQuery): void {
void router.replace({ path: route.path, query: toListQuery(next) })
}
let generation = 0
async function load(): Promise<void> {
async function loadList(): Promise<void> {
const current = ++generation
loading.value = true
failed.value = false
const params = { path: path.value }
const [schemaResult, listResult] = await Promise.all([
api.GET('/{vendor}/{plugin}/{controller}/schema/list', { params }),
api.GET('/{vendor}/{plugin}/{controller}', { params }),
])
const result = await api.GET('/{vendor}/{plugin}/{controller}', { params: { path, query: query.value } })
if (current !== generation) {
return
}
schema.value = schemaResult.data?.data ?? null
rows.value = listResult.data?.data ?? []
meta.value = listResult.data?.meta ?? null
failed.value = !schemaResult.data || !listResult.data
rows.value = result.data?.data ?? []
meta.value = result.data?.meta ?? null
failed.value = !result.data
loading.value = false
}
watch(path, load, { immediate: true, deep: true })
async function load(): Promise<void> {
const result = await api.GET('/{vendor}/{plugin}/{controller}/schema/list', { params: { path } })
schema.value = result.data?.data ?? null
if (!schema.value) {
failed.value = true
loading.value = false
}
}
void load()
watch(
() => listQueryKey(query.value),
() => {
selected.value = []
searchText.value = query.value.search ?? ''
void loadList()
},
{ immediate: true },
)
let searchTimer: ReturnType<typeof setTimeout> | null = null
function onSearch(value: string): void {
searchText.value = value
if (searchTimer !== null) {
clearTimeout(searchTimer)
}
searchTimer = setTimeout(() => {
searchTimer = null
const search = value.trim()
replaceQuery({ ...query.value, search: search === '' ? undefined : search, page: undefined })
}, SEARCH_DEBOUNCE)
}
onBeforeUnmount(() => {
if (searchTimer !== null) {
clearTimeout(searchTimer)
}
})
function clearSearch(): void {
searchText.value = ''
replaceQuery({ ...query.value, search: undefined, page: undefined })
}
/** asc -> desc -> none for the clicked column; another column starts at asc. */
function onSort(column: string): void {
const current = sort.value
if (current?.column !== column) {
replaceQuery({ ...query.value, sort: column, dir: 'asc', page: undefined })
} else if (current.dir === 'asc') {
replaceQuery({ ...query.value, sort: column, dir: 'desc', page: undefined })
} else {
replaceQuery({ ...query.value, sort: undefined, dir: undefined, page: undefined })
}
}
function onPage(page: number): void {
replaceQuery({ ...query.value, page })
}
function onPerPage(value: number): void {
replaceQuery({ ...query.value, per_page: value, page: undefined })
}
function onFilter(name: string, value: string | null): void {
const filter = { ...(query.value.filter ?? {}) }
if (value === null) {
delete filter[name]
} else {
filter[name] = value
}
replaceQuery({ ...query.value, filter, page: undefined })
}
async function onDelete(): Promise<void> {
const ids = selected.value.map(Number).filter((id) => Number.isInteger(id) && id > 0)
if (ids.length === 0 || deleting.value) {
return
}
const ok = await confirm.ask({
message: message(messages.value?.deleteConfirm, ids.length),
confirmLabel: t('backend::lang.form.delete'),
danger: true,
})
if (!ok) {
return
}
deleting.value = true
try {
const result = await api.POST('/{vendor}/{plugin}/{controller}/bulk-delete', { params: { path }, body: { ids } })
if (result.data) {
showToast(message(messages.value?.deleted, result.data.data.deleted))
selected.value = []
await loadList()
return
}
showToast(result.error?.error.message || t('backend::lang.list.delete_failed'), 'danger')
} catch {
showToast(t('backend::lang.list.delete_failed'), 'danger')
} finally {
deleting.value = false
}
}
</script>
<template>
<section class="flex flex-col gap-5">
<header>
<h1 class="text-[26px] font-bold tracking-[-0.02em]">{{ title }}</h1>
<header class="flex flex-wrap items-end justify-between gap-4">
<div class="flex flex-col">
<h1 class="text-[26px] font-bold tracking-[-0.02em]">{{ title }}</h1>
<p v-if="subtitle" data-record-count class="text-muted">{{ subtitle }}</p>
</div>
<div v-if="headingButtons.length > 0" class="flex items-center gap-2.5">
<Button
v-for="button in headingButtons"
:key="button"
data-action="create"
variant="primary"
:icon="Plus"
:to="`${basePath}/create`"
>
{{ message(messages?.create) }}
</Button>
</div>
</header>
<div class="overflow-hidden rounded-card border border-border bg-surface shadow-card">
<p v-if="failed && !loading" role="alert" class="px-5 py-4 text-danger">
<ListToolbar
v-if="schema"
:show-search="schema.showSearch"
:search="searchText"
:search-prompt="message(messages?.searchPrompt)"
:buttons="toolbarButtons"
:selected-count="selected.length"
:selected-label="message(messages?.selected, selected.length)"
:delete-label="message(messages?.deleteSelected, selected.length)"
@update:search="onSearch"
@delete="onDelete"
/>
<FilterBar
v-if="schema && schema.filters.length > 0"
:filters="schema.filters"
:values="query.filter ?? {}"
:source="path"
@update="onFilter"
/>
<p v-if="failed && !loading" role="alert" class="border-t border-border px-5 py-4 text-danger">
{{ t('backend::lang.list.load_failed') }}
</p>
<DataTable
v-else
:columns="schema?.columns ?? []"
:rows="rows"
:loading="loading"
:empty-text="schema?.noRecordsMessage"
:loading="loading || !schema"
:row-link="rowLink"
:selectable="schema?.showCheckboxes ?? false"
:selected="selected"
:sortable="schema?.showSorting ?? false"
:sort="sort"
@update:selected="(ids: RowId[]) => (selected = ids)"
@sort="onSort"
>
<template #empty>
<div v-if="query.search" data-empty-search class="flex flex-col items-center gap-3">
<span class="flex size-14 items-center justify-center rounded-full bg-subtle text-muted">
<SearchX :size="24" aria-hidden="true" />
</span>
<p class="text-[17px] font-bold">{{ message(messages?.emptySearch, undefined, { term: query.search }) }}</p>
<p class="max-w-[420px] text-muted">
{{ message(messages?.emptySearchHint, undefined, { term: query.search }) }}
</p>
<Button data-action="clear-search" @click="clearSearch">{{ t('backend::lang.list.clear_search') }}</Button>
</div>
<p v-else data-empty-list class="text-muted">
{{ message(messages?.empty) || schema?.noRecordsMessage || t('backend::lang.list.no_records') }}
</p>
</template>
</DataTable>
<Pagination
:meta="meta"
:loading="loading || !schema"
:per-page-options="perPageOptions"
:per-page="perPage"
@page="onPage"
@per-page="onPerPage"
/>
<footer class="border-t border-border px-5 py-3.5 text-[13px] text-muted">
{{ loading ? t('backend::lang.list.loading') : range }}
</footer>
</div>
<ConfirmDialog
:open="confirm.request.value !== null"
:message="confirm.request.value?.message ?? ''"
:confirm-label="confirm.request.value?.confirmLabel"
:danger="confirm.request.value?.danger"
@confirm="confirm.confirm"
@cancel="confirm.cancel"
/>
</section>
</template>