- 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
86 lines
2.6 KiB
TypeScript
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))
|
|
}
|