From 8b5f85603fe412ff25ee8fb3abd13774928271ac Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sun, 27 Sep 2026 16:51:00 +0200 Subject: [PATCH] feat(10-03): search, sort, filter, page and bulk-delete any list - List state (search, sort, dir, page, per_page, filter[]) 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 --- admin/src/app/listQuery.ts | 85 +++++ admin/src/components/list/CellValue.vue | 86 +++++ admin/src/components/list/DataTable.vue | 183 ++++++--- admin/src/components/list/FilterBar.vue | 161 ++++++++ admin/src/components/list/ListToolbar.vue | 58 +++ admin/src/components/list/Pagination.vue | 113 ++++++ admin/src/components/ui/ConfirmDialog.vue | 75 ++++ admin/src/components/ui/confirm.ts | 31 ++ admin/src/views/ListView.vue | 278 ++++++++++++-- admin/tests/fixtures/lang.json | 21 ++ admin/tests/fixtures/widgets.list-schema.json | 55 ++- admin/tests/fixtures/widgets.list.json | 4 +- admin/tests/fixtures/widgets.options.json | 9 + admin/tests/smoke/list.smoke.test.ts | 349 ++++++++++++++++++ admin/tests/smoke/tracer.smoke.test.ts | 7 +- boardwalk/dist/assets/index-BB795npz.js | 1 - boardwalk/dist/assets/index-CAZobGnD.js | 3 + boardwalk/dist/assets/index-VVVgyQoR.css | 1 - boardwalk/dist/assets/index-W7axD3vY.css | 1 + boardwalk/dist/index.html | 4 +- phrasebook/backend/lang/en/lang.yaml | 7 + phrasebook/backend/lang/pl/lang.yaml | 7 + 22 files changed, 1421 insertions(+), 118 deletions(-) create mode 100644 admin/src/app/listQuery.ts create mode 100644 admin/src/components/list/CellValue.vue create mode 100644 admin/src/components/list/FilterBar.vue create mode 100644 admin/src/components/list/ListToolbar.vue create mode 100644 admin/src/components/list/Pagination.vue create mode 100644 admin/src/components/ui/ConfirmDialog.vue create mode 100644 admin/src/components/ui/confirm.ts create mode 100644 admin/tests/fixtures/widgets.options.json create mode 100644 admin/tests/smoke/list.smoke.test.ts delete mode 100644 boardwalk/dist/assets/index-BB795npz.js create mode 100644 boardwalk/dist/assets/index-CAZobGnD.js delete mode 100644 boardwalk/dist/assets/index-VVVgyQoR.css create mode 100644 boardwalk/dist/assets/index-W7axD3vY.css diff --git a/admin/src/app/listQuery.ts b/admin/src/app/listQuery.ts new file mode 100644 index 0000000..c4a9312 --- /dev/null +++ b/admin/src/app/listQuery.ts @@ -0,0 +1,85 @@ +// List state lives in the URL query (design: Interactions, D-22): search, +// sort, dir, page, per_page and filter[]. 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 = {} + 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)) +} diff --git a/admin/src/components/list/CellValue.vue b/admin/src/components/list/CellValue.vue new file mode 100644 index 0000000..e8c0ca0 --- /dev/null +++ b/admin/src/components/list/CellValue.vue @@ -0,0 +1,86 @@ + + + diff --git a/admin/src/components/list/DataTable.vue b/admin/src/components/list/DataTable.vue index d85928b..008ffb7 100644 --- a/admin/src/components/list/DataTable.vue +++ b/admin/src/components/list/DataTable.vue @@ -1,28 +1,52 @@