Skip to content

List search, sort and filter (server-side) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Search, sort and combinable filters, computed on the server, on the 27 list screens.

Architecture: One kernel helper (internal/kernel/listquery) builds the search predicate, the allowlisted sort and the sort-aware keyset cursor; each module’s list function takes Q, Sort and its filters; DataTable gets a server mode whose state lives in the URL. Pilot two endpoints first (Task 3 Suppliers, Task 4 Purchase invoices), review, then convert module by module.

Tech Stack: Go (pgx, sqlc is not used by these lists: hand-written SQL), oapi-codegen, Postgres, React/TanStack Table, react-query, openapi-typescript client.

Spec: docs/superpowers/specs/2026-10-06-list-search-sort-filter-design.md

  • The contract is api/openapi.yaml; generated Go and TypeScript are regenerated, never hand-edited; the generated-code drift test must pass.
  • A float never touches money: amounts are NUMERIC in SQL, strings on the wire and in the web.
  • Search expressions and sort expressions are trusted SQL written in the module; nothing from a request is ever spliced into SQL (values are always bind parameters).
  • Every list keeps today’s order and cursor when sort is absent (no behaviour change for existing clients).
  • Every business query keeps its explicit org_id predicate; RLS tests stay green.
  • Every user-facing string is a translation (en, ms, zh); no new hard-coded English.
  • Tests assert presence before absence (AGENTS.md); each new guard is watched failing once.
  • DB tests: run only touched packages while iterating; use -p 1 for any multi-package run (Docker contention); do not run the full suite per task.
  • Agents never run git stash, git checkout -- <path>, git reset or git clean in the shared tree.
  • Convert one screen at a time and remove that screen’s searchText / useLoadAllWhileSearching when converting it.

Files:

  • Create: internal/kernel/listquery/listquery.go, internal/kernel/listquery/listquery_test.go
  • Modify: internal/platform/pettycash/spend.go, internal/platform/expenses/filter.go (use listquery.Like, delete the local likeEscaper copies)

Interfaces:

  • Produces:

    • func Like(term string) string — escapes \, %, _.
    • func SearchClause(exprs []string, q string, firstArg int) (sql string, args []any) — "" for no words; otherwise " AND (e1 ILIKE $n ESCAPE '\' OR …) AND …", one group per word, args %word% escaped.
    • type Type string with Text, Numeric, Date, Timestamptz.
    • type Column[R any] struct { Key, Expr string; Type Type; Value func(R) *string } — Value renders the row’s sort value as the text Postgres would cast back (nil = NULL).
    • type Spec[R any] struct { Columns []Column[R]; IDExpr string; ID func(R) uuid.UUID }.
    • func (s Spec[R]) Parse(v string) (Sort[R], error) — "" → zero Sort (default order); unknown → ErrInvalidSort carrying the allowed keys.
    • func (s Sort[R]) Set() bool, OrderBy() string (expr ASC|DESC NULLS LAST, id ASC|DESC), After(c *Cursor, firstArg int) (sql string, args []any, err error) (ErrInvalidCursor if c.Sort differs from the raw sort string), NextCursor(last R) Cursor.
    • type Cursor struct { Sort string; Value *string; ID uuid.UUID }, Encode() string, DecodeCursor(string) (Cursor, error).
  • Keyset predicate (ascending): value non-null → (e > $v::T OR (e = $v::T AND id > $i::uuid) OR e IS NULL); null → (e IS NULL AND id > $i::uuid); descending mirrors </< with the same null handling.

  • Step 1: Write listquery_test.go first: Like on 100%_a\b; SearchClause for zero words, two words, argument numbering from $5; Parse of "", "amount", "-amount", "bogus" (error names the keys); After SQL for asc/desc with null and non-null cursor; mismatched-sort cursor is ErrInvalidCursor; cursor encode/decode round trip and a malformed string.

  • Step 2: Run go test ./internal/kernel/listquery/ — expect build failure.

  • Step 3: Implement listquery.go to the interface above.

  • Step 4: Add a Postgres-backed test (testdb.Start): a throwaway table with a nullable numeric and text column and 7 rows including ties and nulls; page through in each of the four orderings with limit 2 using OrderBy + After + NextCursor; assert the concatenation equals one unpaged ORDER BY. Watch it fail by breaking the null branch, restore.

  • Step 5: Switch the two likeEscaper users to listquery.Like; run their package tests alone.

  • Step 6: Commit git commit -m "kernel/listquery: search clause, allowlisted sort, sort-aware keyset cursor".

Task 2: DataTable server mode and useListView

Section titled “Task 2: DataTable server mode and useListView”

Files:

  • Modify: web/src/components/ui/DataTable.tsx, web/src/components/ui/DataTable.test.tsx
  • Create: web/src/components/ui/useListView.ts, web/src/components/ui/useListView.test.ts, web/src/components/ui/ListFilters.tsx (header filter popovers + chip row + mobile sheet), tests beside them
  • Modify: web/src/lib/i18n/locales/{en,ms,zh}/ui.json (strings: sort ascending/descending/off, filter, clear all, from, to, any)

Interfaces:

  • Produces:

    • Column meta: meta.sort?: string (the API sort key) and meta.filter?: { param: string; kind: 'pick'; options: {value,label}[] } | { param: string; kind: 'range'; type: 'date' | 'amount' } | { param: string; kind: 'party'; … }.
    • useListView(tableId): { q, sort, filters, params, setQ, setSort, setFilter, clear, active } — state in the URL search params (replace), q debounced 300 ms into params, params is the plain object to spread into the query ({ q, sort, state: [...], date_from, … }).
    • <DataTable view={useListView(...)} …/> server mode: renders the search box, sortable headers (aria-sort), filter popovers, chips, “Clear all”; never filters or sorts rows itself.
  • Consumes: Task 1’s contract only through the generated client.

  • Step 1: Tests first: header click cycles asc → desc → off and sets aria-sort; a pick filter with two values and a range produce the expected params; Clear all empties them; state survives a re-mount from the URL; search is debounced (fake timers); in server mode the table renders data exactly as given.

  • Step 2: Implement; keep client-mode searchText working until Task 12 removes it.

  • Step 3: pnpm exec tsc --noEmit, eslint, vitest run src/components/ui (serially). Commit.

Files: api/openapi.yaml (GET /payables/suppliers), internal/platform/payables/supplier.go, internal/modules/payables/ handler, generated internal/gen/api/* and web/src/lib/api/*, internal/platform/payables/supplier_list_test.go, web/src/features/payables/SuppliersPage.tsx, its test.

Interfaces: Search fields: code, legal name, TIN, currency. Sort keys: code, name, currency, terms (payment terms days, numeric). Filters: currency (repeat), active (bool), bank (none|unverified|verified).

  • Step 1: Add the three groups of parameters to the operation; regenerate; commit the generated change with the spec change.
  • Step 2: Go tests first (presence, then absence): search by name fragment and by two words; each filter alone and combined; sort asc/desc with a null TIN; unknown sort is 400; another org’s supplier is never returned.
  • Step 3: Implement with listquery; wire the handler.
  • Step 4: Web: convert the page to view mode; remove its searchText; test that the request carries the params.
  • Step 5: Run go test ./internal/platform/payables/ ./internal/modules/payables/ -p 1 (alone), go test ./internal/gen/... drift tests, pnpm exec vitest run src/features/payables; commit.

Task 4: Pilot — Purchase invoices (paged)

Section titled “Task 4: Pilot — Purchase invoices (paged)”

Files: api/openapi.yaml (GET /payables/invoices), internal/platform/payables/invoice.go (ListInvoices, InvoiceFilter), handler, generated code, invoice_list_test.go, web/src/features/payables/InvoicesPage.tsx + api.ts, tests.

Interfaces: Search fields: reference, supplier name, supplier invoice no, invoice date, due date, net total, state. Sort keys: reference, supplier (name), invoice_date, due_on, total, state. Filters (added to the existing kind, supplier_id, state, due_before, currency, open): state becomes repeatable; invoice_date_from/to, due_from/to, total_from/to.

  • Step 1: Parameters + regenerate.
  • Step 2: Go tests first: sort -total paged with limit 2 across ≥3 pages equals the unpaged order (ties, a null); cursor from sort=total rejected under sort=-total; search + filter + sort together across a page boundary; search never returns another org’s row; no sort still uses the (created_at,id) cursor and returns today’s order (compare to a snapshot taken before the change).
  • Step 3: Implement; keep the old cursor path untouched when sort is absent.
  • Step 4: Web: view mode on the page; delete useLoadAllWhileSearching use and searchText here; test params reach the request and that changing sort drops the cursor.
  • Step 5: Verify as Task 3; STOP for the owner’s review of Tasks 1–4 before Task 5. Record rulings in the ledger.

Tasks 5–11: module conversions (one task per row, same recipe as Tasks 3/4)

Section titled “Tasks 5–11: module conversions (one task per row, same recipe as Tasks 3/4)”

Recipe for each endpoint: (1) declare q, sort, filters in api/openapi.yaml and regenerate; (2) Go tests first — presence then absence for search, each filter, combined filters, sort both directions with ties and nulls, unknown sort 400, org isolation, and (paged only) cross-page order and mismatched cursor; (3) implement with listquery in the module’s list function and handler; (4) convert the web page to view mode, delete its searchText and useLoadAllWhileSearching; (5) run that module’s Go package alone and its web feature tests; commit per endpoint. The search fields, sort keys and filters are exactly the screen’s visible columns (the implementer reads the page’s columns); a column that is a looked-up name searches and sorts on the joined name.

Task Endpoints (screens) Notes
5 /payables/payments, /payables/batches, /payables/remittances all paged; remittance section is a pick filter
6 /receivables/customers, /receivables/receipts customers already has q; receipts paged
7 /accounting/entries paged; journal code and date range filters
8 /procurement/{requisitions,orders,receipts,returns}, /sales/{quotations,orders,deliveries,returns}, procurement/sales report endpoints one platform list function (internal/platform/trade); kind-specific columns; the three reports each their own sort keys
9 /inventory/items, /inventory/locations, /assets, /assets/classes
10 /hr/employees, /hr/vacancies, /org/members members directory is read by other screens; add parameters without changing its default response; employees q keeps its meaning
11 /banking/accounts, /einvoice/submissions, /pettycash/floats e-invoice list currently limit 50 with no cursor: keep the limit, add paging only if the endpoint already pages

Files: internal/gen/api/listquery_contract_test.go (or beside the existing openapi drift tests), web/src/components/ui/DataTable.tsx (delete searchText, onSearchChange), delete useLoadAllWhileSearching.ts, docs/ROADMAP.md, docs/DEPLOY_NOTES.md.

  • Step 1: Drift test: parse api/openapi.yaml; for each operation in the spec’s table, assert it declares q and sort (assert the table found ≥ 20 operations first), and that every key in its documented sort description is in the Go allowlist exported by its module (assert each allowlist non-empty). Watch it fail by removing one sort parameter, restore.
  • Step 2: Delete the client-mode search code once no screen uses it (grep first, assert zero users).
  • Step 3: Roadmap: tick the item with what was verified and what was not (no browser pass unless done). DEPLOY_NOTES: no migration; note the new query parameters.
  • Step 4: pnpm exec vitest run, tsc, eslint; go test -p 1 over the touched packages one at a time. Commit.