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
Global Constraints
Section titled “Global Constraints”- 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
NUMERICin 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
sortis absent (no behaviour change for existing clients). - Every business query keeps its explicit
org_idpredicate; 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 1for any multi-package run (Docker contention); do not run the full suite per task. - Agents never run
git stash,git checkout -- <path>,git resetorgit cleanin the shared tree. - Convert one screen at a time and remove that screen’s
searchText/useLoadAllWhileSearchingwhen converting it.
Task 1: kernel/listquery
Section titled “Task 1: kernel/listquery”Files:
- Create:
internal/kernel/listquery/listquery.go,internal/kernel/listquery/listquery_test.go - Modify:
internal/platform/pettycash/spend.go,internal/platform/expenses/filter.go(uselistquery.Like, delete the locallikeEscapercopies)
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 stringwithText,Numeric,Date,Timestamptz.type Column[R any] struct { Key, Expr string; Type Type; Value func(R) *string }—Valuerenders 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 →ErrInvalidSortcarrying 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)(ErrInvalidCursorifc.Sortdiffers 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.gofirst:Likeon100%_a\b;SearchClausefor zero words, two words, argument numbering from$5;Parseof"","amount","-amount","bogus"(error names the keys);AfterSQL for asc/desc with null and non-null cursor; mismatched-sort cursor isErrInvalidCursor; cursor encode/decode round trip and a malformed string. -
Step 2: Run
go test ./internal/kernel/listquery/— expect build failure. -
Step 3: Implement
listquery.goto 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 usingOrderBy+After+NextCursor; assert the concatenation equals one unpagedORDER BY. Watch it fail by breaking the null branch, restore. -
Step 5: Switch the two
likeEscaperusers tolistquery.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) andmeta.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),qdebounced 300 ms intoparams,paramsis 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.
- Column meta:
-
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 expectedparams;Clear allempties them; state survives a re-mount from the URL; search is debounced (fake timers); in server mode the table rendersdataexactly as given. -
Step 2: Implement; keep client-mode
searchTextworking until Task 12 removes it. -
Step 3:
pnpm exec tsc --noEmit,eslint,vitest run src/components/ui(serially). Commit.
Task 3: Pilot — Suppliers (not paged)
Section titled “Task 3: Pilot — Suppliers (not paged)”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
viewmode; remove itssearchText; 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
-totalpaged with limit 2 across ≥3 pages equals the unpaged order (ties, a null); cursor fromsort=totalrejected undersort=-total; search + filter + sort together across a page boundary; search never returns another org’s row; nosortstill 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
sortis absent. - Step 4: Web:
viewmode on the page; deleteuseLoadAllWhileSearchinguse andsearchTexthere; 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 |
Task 12: guard, cleanup and docs
Section titled “Task 12: guard, cleanup and docs”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 declaresqandsort(assert the table found ≥ 20 operations first), and that every key in its documentedsortdescription is in the Go allowlist exported by its module (assert each allowlist non-empty). Watch it fail by removing onesortparameter, 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 1over the touched packages one at a time. Commit.