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>

View File

@@ -117,12 +117,27 @@
"backend::lang.list.column_updated": {
"other": "Zaktualizowano"
},
"backend::lang.list.delete_failed": {
"other": "Nie udało się usunąć zaznaczonych rekordów."
},
"backend::lang.list.delete_selected": {
"other": "Usuń zaznaczone"
},
"backend::lang.list.empty_value": {
"other": "—"
},
"backend::lang.list.filter_all": {
"other": "Wszystkie"
},
"backend::lang.list.filter_from": {
"other": "Od"
},
"backend::lang.list.filter_to": {
"other": "Do"
},
"backend::lang.list.filters": {
"other": "Filtry"
},
"backend::lang.list.load_failed": {
"other": "Nie udało się wczytać listy."
},
@@ -138,6 +153,9 @@
"backend::lang.list.no_results": {
"other": "0 wyników"
},
"backend::lang.list.pagination": {
"other": "Stronicowanie"
},
"backend::lang.list.pagination_range": {
"other": ":from–:to z :total"
},
@@ -153,6 +171,9 @@
"many": ":count wyników",
"other": ":count wyniku"
},
"backend::lang.list.search": {
"other": "Szukaj"
},
"backend::lang.list.search_prompt": {
"other": "Szukaj…"
},

View File

@@ -8,38 +8,35 @@
"showCheckboxes": true,
"showSorting": true,
"searchTerm": "",
"toolbarButtons": [
"create"
],
"recordUrl": "acme/demo/widgets/update/:id",
"toolbarButtons": ["create", "delete"],
"columns": [
{
"key": "name",
"label": "Name",
"searchable": true,
"sortable": true
},
{
"key": "code",
"label": "Code",
"searchable": true,
"sortable": true
},
{
"key": "tags",
"label": "Tags",
"searchable": false,
"sortable": false,
"type": "relation",
"relation": "tags",
"select": "name"
}
{ "key": "name", "label": "Name", "searchable": true, "sortable": true },
{ "key": "code", "label": "Code", "searchable": true, "sortable": true },
{ "key": "tags", "label": "Tags", "searchable": false, "sortable": false, "type": "relation", "relation": "tags", "select": "name" },
{ "key": "created_at", "label": "Created", "searchable": false, "sortable": true, "type": "datetime" },
{ "key": "active", "label": "Active", "searchable": false, "sortable": false, "type": "switch" }
],
"filters": [
{ "name": "active", "label": "Active", "type": "switch", "column": "active", "trueValue": true, "falseValue": false },
{ "name": "created", "label": "Created", "type": "daterange", "column": "created_at" },
{ "name": "maker", "label": "Maker", "type": "scope", "scope": "byMaker", "modelClass": "Acme\\Demo\\Models\\Maker", "nameFrom": "name" }
],
"filters": [],
"rowActions": [],
"bulkActions": [],
"recordUrl": "acme/demo/widgets/update/:id"
"messages": {
"create": { "other": "New widget" },
"deleteConfirm": { "one": "Delete :count widget?", "other": "Delete :count widgets?" },
"deleteSelected": { "other": "Delete selected" },
"deleted": { "one": "Deleted :count widget", "other": "Deleted :count widgets" },
"empty": { "other": "No widgets yet." },
"emptySearch": { "other": "Nothing found" },
"emptySearchHint": { "other": "No widget matches “:term”." },
"recordCount": { "one": ":count widget", "other": ":count widgets" },
"searchPrompt": { "other": "Search widgets…" },
"selected": { "other": "Selected :count" }
},
"meta": { "locale": "en" }
},
"meta": {
"locale": "en"
}
"meta": { "locale": "en" }
}

View File

@@ -1,7 +1,7 @@
{
"data": [
{ "id": 1, "name": "Blue widget", "code": "W-01", "tags": ["small", "round"] },
{ "id": 2, "name": "Green widget", "code": "W-02", "tags": [] }
{ "id": 1, "name": "Blue widget", "code": "W-01", "tags": ["small", "round"], "created_at": "2026-09-21T14:32:00", "active": true },
{ "id": 2, "name": "Green widget", "code": "W-02", "tags": [], "created_at": null, "active": false }
],
"meta": { "page": 1, "per_page": 20, "total": 2, "last_page": 1 }
}

View File

@@ -0,0 +1,9 @@
{
"maker": {
"data": [
{ "value": "1", "label": "Acme Works" },
{ "value": "2", "label": "Bolt & Co" }
],
"meta": { "locale": "en" }
}
}

View File

@@ -0,0 +1,349 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import { parseListQuery, toListQuery } from '../../src/app/listQuery'
import { clearUser } from '../../src/state/useAuth'
import { setNavigation } from '../../src/state/useNavigation'
import { clearToasts } from '../../src/state/useToasts'
import lang from '../fixtures/lang.json'
import listSchema from '../fixtures/widgets.list-schema.json'
import listRows from '../fixtures/widgets.list.json'
import options from '../fixtures/widgets.options.json'
import { API, mountApp, queryOf, requestsTo, type Route } from '../helpers'
const LIST = `${API}/acme/demo/widgets`
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${LIST}/schema/list`]: { body: listSchema },
[`GET ${LIST}`]: { body: listRows },
[`GET ${LIST}/filters/maker/options`]: { body: options.maker },
...overrides,
}
}
function schemaWith(patch: Record<string, unknown>) {
return { ...listSchema, data: { ...listSchema.data, ...patch } }
}
function listGets(calls: Request[]): Request[] {
return requestsTo(calls, 'GET', LIST)
}
function lastListQuery(calls: Request[]): URLSearchParams {
const gets = listGets(calls)
return queryOf(gets[gets.length - 1]!)
}
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
beforeEach(() => {
clearUser()
setNavigation([])
clearToasts()
setBundle(lang.data, 'pl')
})
afterEach(() => {
document.body.innerHTML = ''
})
describe('list query', () => {
it('round-trips search, sort, paging and filters and drops malformed values', () => {
const parsed = parseListQuery({
search: ' blue ',
sort: 'name',
dir: 'sideways',
page: '0',
per_page: 'x',
'filter[active]': 'true',
'filter[created]': '',
})
expect(parsed).toEqual({ search: 'blue', sort: 'name', dir: 'asc', filter: { active: 'true' } })
expect(toListQuery({ ...parsed, page: 3, per_page: 50 })).toEqual({
search: 'blue',
sort: 'name',
dir: 'asc',
page: '3',
per_page: '50',
'filter[active]': 'true',
})
expect(toListQuery({ page: 1 })).toEqual({})
})
})
describe('list screen', () => {
it('renders the heading, toolbar buttons in their places and typed cells', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets', routes())
expect(wrapper.find('h1').text()).toBe('Widgets')
expect(wrapper.find('[data-record-count]').text()).toBe('2 widgets')
const create = wrapper.find('header [data-action="create"]')
expect(create.text()).toBe('New widget')
expect(create.attributes('href')).toBe('/acme/demo/widgets/create')
expect(wrapper.find('[data-list-search]').attributes('placeholder')).toBe('Search widgets…')
const del = wrapper.find('[data-action="delete"]')
expect(del.text()).toBe('Delete selected')
expect(del.attributes('disabled')).toBeDefined()
const [blue, green] = wrapper.findAll('tbody tr')
const blueCells = blue!.findAll('td:not([data-select])')
expect(blueCells[3]!.text()).toBe('2026-09-21 14:32')
expect(blueCells[3]!.find('span').classes()).toContain('tabular-nums')
expect(blueCells[4]!.find('[data-switch="true"]').text()).toBe('Tak')
expect(blueCells[4]!.find('[data-switch="true"] svg').exists()).toBe(true)
const greenCells = green!.findAll('td:not([data-select])')
expect(greenCells[2]!.find('[data-empty]').text()).toBe('—')
expect(greenCells[3]!.find('[data-empty]').exists()).toBe(true)
expect(greenCells[4]!.find('[data-switch="false"]').text()).toBe('Nie')
})
it('cycles sorting asc, desc, none with aria-sort and the URL', async () => {
const { wrapper, router, calls } = await mountApp('/acme/demo/widgets', routes())
const nameHeader = () => wrapper.findAll('th:not([data-select])')[0]!
expect(nameHeader().attributes('aria-sort')).toBe('none')
expect(wrapper.findAll('th:not([data-select])')[2]!.attributes('aria-sort')).toBeUndefined()
await wrapper.find('[data-sort="name"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.query).toMatchObject({ sort: 'name', dir: 'asc' })
expect(nameHeader().attributes('aria-sort')).toBe('ascending')
expect(nameHeader().classes()).toContain('text-text')
expect(lastListQuery(calls).get('sort')).toBe('name')
expect(lastListQuery(calls).get('dir')).toBe('asc')
await wrapper.find('[data-sort="name"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.query).toMatchObject({ sort: 'name', dir: 'desc' })
expect(nameHeader().attributes('aria-sort')).toBe('descending')
await wrapper.find('[data-sort="name"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.query.sort).toBeUndefined()
expect(nameHeader().attributes('aria-sort')).toBe('none')
expect(lastListQuery(calls).has('sort')).toBe(false)
})
it('debounces search by 300 ms and resets the page', async () => {
const { wrapper, router, calls } = await mountApp('/acme/demo/widgets?page=3', routes())
expect(lastListQuery(calls).get('page')).toBe('3')
await wrapper.find('[data-list-search]').setValue('blue')
await wait(100)
expect(router.currentRoute.value.query.search).toBeUndefined()
await wait(300)
await flushPromises()
expect(router.currentRoute.value.query).toEqual({ search: 'blue' })
expect(lastListQuery(calls).get('search')).toBe('blue')
expect(lastListQuery(calls).has('page')).toBe(false)
})
it('selects the page with a tri-state header and clears the selection on change', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets', routes())
const header = () => wrapper.find('th[data-select] [role="checkbox"]')
const rowBoxes = () => wrapper.findAll('td[data-select] [role="checkbox"]')
expect(header().attributes('aria-checked')).toBe('false')
await rowBoxes()[0]!.trigger('click')
expect(header().attributes('aria-checked')).toBe('mixed')
expect(header().find('svg').exists()).toBe(true)
expect(wrapper.findAll('tbody tr')[0]!.classes()).toContain('bg-sel')
expect(wrapper.find('[data-selected-pill]').text()).toBe('Selected 1')
await header().trigger('click')
expect(header().attributes('aria-checked')).toBe('true')
expect(rowBoxes().map((box) => box.attributes('aria-checked'))).toEqual(['true', 'true'])
await header().trigger('click')
expect(header().attributes('aria-checked')).toBe('false')
await rowBoxes()[1]!.trigger('click')
await wrapper.find('[data-sort="code"]').trigger('click')
await flushPromises()
expect(header().attributes('aria-checked')).toBe('false')
expect(wrapper.find('[data-selected-pill]').exists()).toBe(false)
})
it('confirms bulk delete with the plural message and posts the ids', async () => {
const { wrapper, calls } = await mountApp(
'/acme/demo/widgets',
routes({ [`POST ${LIST}/bulk-delete`]: { body: { data: { deleted: 2 }, meta: {} } } }),
)
const gets = listGets(calls).length
await wrapper.find('th[data-select] [role="checkbox"]').trigger('click')
const del = wrapper.find('[data-action="delete"]')
expect(del.attributes('disabled')).toBeUndefined()
expect(del.classes()).toContain('text-danger')
await del.trigger('click')
await flushPromises()
const dialog = document.body.querySelector('[data-confirm-dialog]')!
expect(dialog.getAttribute('role')).toBe('alertdialog')
expect(dialog.textContent).toContain('Delete 2 widgets?')
;(dialog.querySelector('[data-action="confirm"]') as HTMLButtonElement).click()
await flushPromises()
const [post] = requestsTo(calls, 'POST', `${LIST}/bulk-delete`)
expect(post!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
expect(await post!.json()).toEqual({ ids: [1, 2] })
expect(wrapper.find('[role="status"]').text()).toContain('Deleted 2 widgets')
expect(listGets(calls).length).toBe(gets + 1)
expect(wrapper.find('[data-selected-pill]').exists()).toBe(false)
})
it('sends nothing when the delete confirmation is cancelled and toasts a 409', async () => {
const { wrapper, calls } = await mountApp(
'/acme/demo/widgets',
routes({
[`POST ${LIST}/bulk-delete`]: {
status: 409,
body: { error: { code: 'conflict', message: 'Widgets are still in use.', details: {} } },
},
}),
)
await wrapper.findAll('td[data-select] [role="checkbox"]')[0]!.trigger('click')
await wrapper.find('[data-action="delete"]').trigger('click')
await flushPromises()
expect(document.body.querySelector('[data-confirm-dialog]')!.textContent).toContain('Delete 1 widget?')
;(document.body.querySelector('[data-confirm-dialog] [data-action="cancel"]') as HTMLButtonElement).click()
await flushPromises()
expect(requestsTo(calls, 'POST', `${LIST}/bulk-delete`)).toHaveLength(0)
await wrapper.find('[data-action="delete"]').trigger('click')
await flushPromises()
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLButtonElement).click()
await flushPromises()
expect(requestsTo(calls, 'POST', `${LIST}/bulk-delete`)).toHaveLength(1)
const toast = wrapper.find('[data-tone="danger"]')
expect(toast.text()).toContain('Widgets are still in use.')
})
it('writes each filter shape to filter[name] and resets the page', async () => {
const { wrapper, router, calls } = await mountApp('/acme/demo/widgets?page=2', routes())
const bar = wrapper.find('[data-filter-bar]')
expect(bar.exists()).toBe(true)
const active = bar.find('select[data-filter="active"]')
expect(active.findAll('option').map((option) => option.text())).toEqual(['Wszystkie', 'Tak', 'Nie'])
await active.setValue('true')
await flushPromises()
expect(router.currentRoute.value.query).toEqual({ 'filter[active]': 'true' })
expect(lastListQuery(calls).get('filter[active]')).toBe('true')
await bar.find('[data-filter="created-from"]').setValue('2026-09-01')
await bar.find('[data-filter="created-from"]').trigger('change')
expect(router.currentRoute.value.query['filter[created]']).toBeUndefined()
await bar.find('[data-filter="created-to"]').setValue('2026-09-30')
await bar.find('[data-filter="created-to"]').trigger('change')
await flushPromises()
expect(router.currentRoute.value.query['filter[created]']).toBe('2026-09-01..2026-09-30')
const maker = wrapper.find('select[data-filter="maker"]')
expect(requestsTo(calls, 'GET', `${LIST}/filters/maker/options`)).toHaveLength(1)
expect(maker.findAll('option').map((option) => option.text())).toEqual(['Wszystkie', 'Acme Works', 'Bolt & Co'])
await maker.setValue('2')
await flushPromises()
expect(router.currentRoute.value.query).toEqual({
'filter[active]': 'true',
'filter[created]': '2026-09-01..2026-09-30',
'filter[maker]': '2',
})
const sent = lastListQuery(calls)
expect(sent.get('filter[maker]')).toBe('2')
expect(sent.get('filter[created]')).toBe('2026-09-01..2026-09-30')
await wrapper.find('select[data-filter="active"]').setValue('')
await flushPromises()
expect(router.currentRoute.value.query['filter[active]']).toBeUndefined()
})
it('hides the filter bar without filters and the per-page select with one option', async () => {
const { wrapper, router } = await mountApp(
'/acme/demo/widgets',
routes({ [`GET ${LIST}/schema/list`]: { body: schemaWith({ filters: [] }) } }),
)
expect(wrapper.find('[data-filter-bar]').exists()).toBe(false)
expect(wrapper.find('[data-per-page]').exists()).toBe(false)
expect(wrapper.find('[data-range]').text()).toBe('1–2 z 2')
expect(wrapper.find('[aria-current="page"][data-page="1"]').exists()).toBe(true)
expect(wrapper.find('[data-page="prev"]').attributes('disabled')).toBeDefined()
expect(wrapper.find('[data-page="next"]').attributes('disabled')).toBeDefined()
expect(router.currentRoute.value.query).toEqual({})
})
it('offers the schema per-page options and pages with ellipsis', async () => {
const { wrapper, router, calls } = await mountApp(
'/acme/demo/widgets?page=5',
routes({
[`GET ${LIST}/schema/list`]: { body: schemaWith({ perPageOptions: [10, 20, 50] }) },
[`GET ${LIST}`]: { body: { ...listRows, meta: { page: 5, per_page: 20, total: 400, last_page: 20 } } },
}),
)
const perPage = wrapper.find('[data-per-page]')
expect(perPage.findAll('option').map((option) => option.text())).toEqual(['10', '20', '50'])
expect(wrapper.find('[data-range]').text()).toBe('81–100 z 400')
const pager = wrapper.findAll('nav [data-page]').map((button) => button.attributes('data-page'))
expect(pager).toEqual(['prev', '1', '4', '5', '6', '20', 'next'])
expect(wrapper.find('[data-page="5"]').attributes('aria-current')).toBe('page')
await wrapper.find('[data-page="6"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.query).toEqual({ page: '6' })
await perPage.setValue('50')
await flushPromises()
expect(router.currentRoute.value.query).toEqual({ per_page: '50' })
expect(lastListQuery(calls).get('per_page')).toBe('50')
})
it('shows the empty message and the plural zero results without a pager', async () => {
const { wrapper } = await mountApp(
'/acme/demo/widgets',
routes({ [`GET ${LIST}`]: { body: { data: [], meta: { page: 1, per_page: 20, total: 0, last_page: 1 } } } }),
)
expect(wrapper.find('[data-empty-list]').text()).toBe('No widgets yet.')
expect(wrapper.find('[data-range]').text()).toBe('0 wyników')
expect(wrapper.find('nav [data-page]').exists()).toBe(false)
})
it('shows the empty-search state with the term and clears the search', async () => {
const { wrapper, router } = await mountApp(
'/acme/demo/widgets?search=zzz',
routes({ [`GET ${LIST}`]: { body: { data: [], meta: { page: 1, per_page: 20, total: 0, last_page: 1 } } } }),
)
const state = wrapper.find('[data-empty-search]')
expect(state.text()).toContain('Nothing found')
expect(state.text()).toContain('No widget matches “zzz”.')
expect(state.find('svg').exists()).toBe(true)
expect((wrapper.find('[data-list-search]').element as HTMLInputElement).value).toBe('zzz')
await state.find('[data-action="clear-search"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.query).toEqual({})
})
it('shows eight skeleton rows while loading and keeps the toolbar and footer', async () => {
let release: (() => void) | undefined
const { wrapper } = await mountApp(
'/acme/demo/widgets',
routes({
[`GET ${LIST}`]: () =>
new Promise((resolve) => {
release = () => resolve({ body: listRows })
}),
}),
)
expect(wrapper.findAll('[data-skeleton]')).toHaveLength(8)
expect(wrapper.find('[data-list-search]').exists()).toBe(true)
expect(wrapper.find('[data-range]').text()).toBe('Wczytywanie…')
expect(wrapper.find('tbody').attributes('aria-busy')).toBe('true')
release?.()
await flushPromises()
expect(wrapper.findAll('[data-skeleton]')).toHaveLength(0)
expect(wrapper.findAll('tbody tr')).toHaveLength(2)
})
})

View File

@@ -189,8 +189,11 @@ describe('navigation shell and list', () => {
}
expect(wrapper.find('h1').text()).toBe('Widgets')
expect(wrapper.findAll('th').map((th) => th.text())).toEqual(['Name', 'Code', 'Tags'])
const rows = wrapper.findAll('tbody tr').map((tr) => tr.findAll('td').map((td) => td.text()))
// The checkbox column (data-select) is not a schema column.
expect(wrapper.findAll('th:not([data-select])').map((th) => th.text())).toEqual(['Name', 'Code', 'Tags', 'Created', 'Active'])
const rows = wrapper
.findAll('tbody tr')
.map((tr) => tr.findAll('td:not([data-select])').map((td) => td.text()).slice(0, 3))
expect(rows).toEqual([
['Blue widget', 'W-01', 'small, round'],
['Green widget', 'W-02', '—'],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,8 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-BB795npz.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-VVVgyQoR.css">
<script type="module" crossorigin src="./assets/index-CAZobGnD.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-W7axD3vY.css">
</head>
<body>
<div id="app"></div>

View File

@@ -44,6 +44,13 @@ list:
select_row: Select row
sort_ascending: Sort ascending
sort_descending: Sort descending
search: Search
delete_failed: The selected records could not be deleted.
filters: Filters
filter_all: All
filter_from: From
filter_to: To
pagination: Pagination
form:
save: Save
save_and_close: Save and close

View File

@@ -48,6 +48,13 @@ list:
select_row: Zaznacz wiersz
sort_ascending: Sortuj rosnąco
sort_descending: Sortuj malejąco
search: Szukaj
delete_failed: Nie udało się usunąć zaznaczonych rekordów.
filters: Filtry
filter_all: Wszystkie
filter_from: Od
filter_to: Do
pagination: Stronicowanie
form:
save: Zapisz
save_and_close: Zapisz i zamknij