Files
summercms/admin/src/app/listQuery.ts
Jakub Zych 8b5f85603f 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
2026-09-27 16:51:00 +02:00

86 lines
2.6 KiB
TypeScript

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