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