diff --git a/README.md b/README.md index ceff295..863e848 100644 --- a/README.md +++ b/README.md @@ -361,6 +361,7 @@ src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookie src/context.ts RequestContext handed to handlers + buildContext() src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs) +src/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize } src/nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model src/plugin.ts definePlugin() + the host's plugin discovery/router (planned) views/ Core EJS templates (index, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, menu/popover, theme switch, icon sprite) diff --git a/src/list-query.test.ts b/src/list-query.test.ts new file mode 100644 index 0000000..6cd9258 --- /dev/null +++ b/src/list-query.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { parseListQuery } from "./list-query.ts"; + +test("parseListQuery reads search, multi-value filters, sort and pagination from the URL", () => { + // q is trimmed; chips repeat a key; daterange is two keys; "-field" ⇒ desc sort. + assert.deepEqual( + parseListQuery("?q= ada &status=active&tag=oncall&tag=lead&joined_from=2026-01-01&sort=-last_active&page=3&pageSize=50"), + { + filters: { joined_from: ["2026-01-01"], status: ["active"], tag: ["oncall", "lead"] }, + page: 3, + pageSize: 50, + q: "ada", + sort: { dir: "desc", field: "last_active" }, + }, + ); +}); + +test("parseListQuery applies defaults, clamps, drops empties and accepts URL/URLSearchParams/string", () => { + // Empty query → all defaults, never throws. + assert.deepEqual(parseListQuery("?"), { filters: {}, page: 1, pageSize: 25, q: "", sort: null }); + + // Empty values dropped (status, q); page<1 → 1; oversized pageSize clamped to max; bare sort ⇒ asc. + assert.deepEqual( + parseListQuery("status=&q=&page=0&pageSize=99999&sort=name"), + { filters: {}, page: 1, pageSize: 100, q: "", sort: { dir: "asc", field: "name" } }, + ); + + // A URL works (searchParams), multi-value preserved. + assert.deepEqual( + parseListQuery(new URL("http://x/users?team=engineering&team=design")), + { filters: { team: ["engineering", "design"] }, page: 1, pageSize: 25, q: "", sort: null }, + ); + + // URLSearchParams works; non-integer page/pageSize fall back to defaults; lone "-" sort ⇒ null. + assert.deepEqual( + parseListQuery(new URLSearchParams("page=abc&pageSize=-5&sort=-")), + { filters: {}, page: 1, pageSize: 25, q: "", sort: null }, + ); +}); + +test("parseListQuery honours custom reserved names and page-size bounds", () => { + assert.deepEqual( + parseListQuery("?search=hi&p=2&n=10&order=-x&status=active", { + defaultPageSize: 20, maxPageSize: 50, pageParam: "p", pageSizeParam: "n", qParam: "search", sortParam: "order", + }), + { filters: { status: ["active"] }, page: 2, pageSize: 10, q: "hi", sort: { dir: "desc", field: "x" } }, + ); + + // Custom n clamps to the custom max; the now-unreserved default names become plain filters. + assert.equal(parseListQuery("?n=999", { maxPageSize: 50, pageSizeParam: "n" }).pageSize, 50); + assert.deepEqual(parseListQuery("?q=hi", { qParam: "search" }).filters, { q: ["hi"] }); +}); diff --git a/src/list-query.ts b/src/list-query.ts new file mode 100644 index 0000000..e472405 --- /dev/null +++ b/src/list-query.ts @@ -0,0 +1,72 @@ +// parseListQuery (todo §1): read a list-page URL into the state the building blocks render +// from — search, filters, sort, pagination. The URL is the only list state (README +// "Interactivity"), so this is the inverse of the filter-bar GET form, the sort links and the +// pagination links: bookmarkable, shareable, reproducible. Pure; never throws. + +export interface ListSort { + dir: "asc" | "desc"; + field: string; +} + +export interface ListQuery { + filters: Record; // every non-reserved param; multi-value kept, empties dropped + page: number; // ≥ 1 + pageSize: number; // clamped to [1, maxPageSize] + q: string; // trimmed search text, "" when absent + sort: ListSort | null; // "field" ⇒ asc, "-field" ⇒ desc +} + +export interface ListQueryOptions { + defaultPageSize?: number; // used when pageSize is absent/invalid (default 25) + maxPageSize?: number; // upper clamp (default 100) + pageParam?: string; // default "page" + pageSizeParam?: string; // default "pageSize" + qParam?: string; // default "q" + sortParam?: string; // default "sort" +} + +export function parseListQuery(url: URL | URLSearchParams | string, options: ListQueryOptions = {}): ListQuery { + const params = toParams(url); + const qParam = options.qParam ?? "q"; + const sortParam = options.sortParam ?? "sort"; + const pageParam = options.pageParam ?? "page"; + const pageSizeParam = options.pageSizeParam ?? "pageSize"; + const reserved = new Set([pageParam, pageSizeParam, qParam, sortParam]); + + const filters: Record = {}; + for (const key of new Set(params.keys())) { + if (reserved.has(key)) continue; + const values = params.getAll(key).filter((v) => v !== ""); + if (values.length) filters[key] = values; + } + + return { + filters, + page: positiveInt(params.get(pageParam)) ?? 1, + pageSize: Math.min(positiveInt(params.get(pageSizeParam)) ?? (options.defaultPageSize ?? 25), options.maxPageSize ?? 100), + q: (params.get(qParam) ?? "").trim(), + sort: parseSort(params.get(sortParam)), + }; +} + +function toParams(url: URL | URLSearchParams | string): URLSearchParams { + if (typeof url === "string") { + const i = url.indexOf("?"); + return new URLSearchParams(i >= 0 ? url.slice(i + 1) : url); + } + return url instanceof URL ? url.searchParams : url; +} + +// A strictly positive integer, else null so the caller falls back to a default. +function positiveInt(raw: string | null): number | null { + if (raw == null || raw.trim() === "") return null; + const n = Number(raw); + return Number.isInteger(n) && n >= 1 ? n : null; +} + +function parseSort(raw: string | null): ListSort | null { + if (raw == null) return null; + const desc = raw.startsWith("-"); + const field = (desc ? raw.slice(1) : raw).trim(); + return field ? { dir: desc ? "desc" : "asc", field } : null; +} diff --git a/todo.md b/todo.md index b186c0e..5496de7 100644 --- a/todo.md +++ b/todo.md @@ -35,7 +35,7 @@ everything via Docker. - [x] Form-field partials (input/label/hint/error) + auth-card partial. → `views/partials/field.ejs`: data-driven `.field` — label (+ inline `link`/`Optional`), optional icon input (`has-ico`), `hint`, server-driven `error` (string | {text} | {html}) wiring `aria-invalid` + `aria-describedby`; added one CSS rule `.field.has-error .field-error{display:flex}` so a rendered field shows its own error. `views/partials/auth-card.ejs`: the `
` shell — head (back/title/sub), optional `sso` providers (text logo or icon, link or button) + divider, `body` slot (fields + submit), `alt` footer. `field.test.ts`/`auth-card.test.ts` cover the matrix + escaping + defaults. - [x] Menu/popover + theme-switch partials (pure CSS `details`/`summary`). → `views/partials/menu.ejs`: data-driven `
` popover — `trigger` (icon/text/raw-html, `class:""` ⇒ bare kebab), `align`/`up` positioning, `width`; `items` = head · sep · link/button (icon, danger) · check-`group` (the columns/“more filters” menus filter-bar deferred here). `views/partials/theme-switch.ejs`: Light/Auto/Dark radiogroup with the fixed `theme-light/auto/dark` ids `styles.css` keys its `:has()` swaps off. Added `.menu-pop.up` (replaces the mockup's inline up-positioning); `shell.ejs` now reuses both partials. `menu.test.ts`/`theme-switch.test.ts` cover the matrix + escaping + defaults. - [x] Helper `composeNav(fragments, override, roles)` → merged, permission-filtered tree. → `src/nav.ts`: pure, I/O-free. Flattens plugin fragments, applies the central override (rename → group → order → hide, all keyed by node `id`), then role-filters — a node shows iff it has no `permission` or `roles` includes it; a gated header drops its whole subtree, an emptied pure header is dropped. Emits clean nodes (no `id`/`permission`, absent fields omitted) ready for `nav-tree.ejs`. Filter runs last so everything above is per-deployment. `NavNode`/`NavOverride`/`NavGroupSpec` types exported; `nav.test.ts` covers merge/filter/empties/override matrix. -- [ ] Helper `parseListQuery(url)` → `{ q, filters, sort, page, pageSize }`. +- [x] Helper `parseListQuery(url)` → `{ q, filters, sort, page, pageSize }`. → `src/list-query.ts`: pure, never throws; inverse of the filter-bar GET form + sort/pagination links. Accepts `URL`/`URLSearchParams`/string. `q` trimmed; `filters` = every non-reserved param as `string[]` (multi-value chips kept, empties dropped); `sort` = `{field,dir}` with `-field` ⇒ desc (lone `-`/empty ⇒ null); `page` a positive int (else 1); `pageSize` defaults 25, clamped to [1, max 100]. Reserved names + page-size bounds overridable via options. `list-query.test.ts` covers the full/default/clamp/custom-name matrix. - [ ] Helper `paginate(total, page, pageSize)` → page model. - [ ] Replace placeholder `index` with the app-shell dashboard. - [ ] Check the full system in Playwright and make screenshots and compare to the static original design in html-css-foundation to make sure we're showing the correct graphics.