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

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

View File

@@ -1,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>