diff --git a/CHANGELOG.md b/CHANGELOG.md index 9239ea6..4065289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,22 @@ semver-governed public surface — a breaking change to a seam is a major bump. ## [Unreleased] -## [0.6.5] - 2026-07-28 +## [0.6.5] - 2026-07-29 + +### Added +- **Prev/next navigation (`DocPager`) on master detail pages.** The same ‹ / › + buttons + keyboard shortcuts documents got in 0.6.4 now work on Customer / + Supplier / Item / Warehouse / Company (and any plugin-registered master): + `DocPager` takes `kind="master"`, backed by a new + `GET /masters/{type}/{name}/adjacent` keyset endpoint (name ASC — the list's + order; `include_disabled` defaults true for 1:1 stepping with the list). + The master list stashes its URL (`master:{type}` context) so Esc/`u` returns + to the exact page you left. ### Fixed +- **Master lists are now deterministically ordered (`ORDER BY name`).** The + list endpoint had no ORDER BY, so Postgres returned pages in arbitrary + order — OFFSET pagination could repeat/skip rows. - **Postgres connections no longer sit "idle in transaction" after reads.** With `autocommit=False`, even a plain SELECT opens a transaction, and nothing ended it — so every pooled request thread (and the chat websocket) left an diff --git a/api/routers/masters.py b/api/routers/masters.py index 0f682b1..df5e5cc 100644 --- a/api/routers/masters.py +++ b/api/routers/masters.py @@ -299,7 +299,9 @@ def list_masters( query = f'SELECT * FROM "{doctype}"' if where_parts: query += " WHERE " + " AND ".join(where_parts) - query += f" LIMIT {int(limit)}" + # Deterministic order: without it Postgres returns pages in arbitrary order, + # which breaks OFFSET pagination and the /adjacent prev/next contract. + query += f" ORDER BY name LIMIT {int(limit)}" if offset: query += f" OFFSET {int(offset)}" rows = db.sql(query, params) @@ -325,6 +327,29 @@ def search_masters(master_type: str, q: str = "", _user: dict = _viewer): return rows +@router.get("/{master_type}/{name}/adjacent") +def adjacent_master(master_type: str, name: str, include_disabled: bool = True, + _user: dict = _viewer): + """The records immediately before/after `name` in the list's order (name + ASC) — {"prev": name|None, "next": name|None}. Keyset queries on the + primary key. include_disabled defaults True to step 1:1 with the master + list, which shows disabled records too.""" + doctype, _ = _get_table(master_type) + if not doctype: + raise HTTPException(status_code=404, detail=f"Unknown master type: {master_type}") + db = get_db() + active = ("" if include_disabled or "disabled" not in db._get_table_columns(doctype) + else "disabled = 0 AND ") + + def neighbor(cmp: str, direction: str): + rows = db.sql( + f'SELECT name FROM "{doctype}" WHERE {active}name {cmp} ? ' + f"ORDER BY name {direction} LIMIT 1", [name]) + return rows[0]["name"] if rows else None + + return {"prev": neighbor("<", "DESC"), "next": neighbor(">", "ASC")} + + @router.get("/{master_type}/{name}") def get_master(master_type: str, name: str, _user: dict = _viewer): doctype, _ = _get_table(master_type) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5448176..b8d41f7 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -129,6 +129,12 @@ export const api = { `/documents/${doctype}/${encodeURIComponent(name)}/adjacent${qs(params)}`, ), + // Prev/next master record around `name` in the master list's order (name ASC). + adjacentMaster: (masterType: string, name: string) => + request<{ prev: string | null; next: string | null }>( + `/masters/${masterType}/${encodeURIComponent(name)}/adjacent`, + ), + createDocument: (doctype: string, data: any) => request(`/documents/${doctype}`, { method: "POST", body: JSON.stringify(data) }), diff --git a/frontend/src/components/document/doc-pager.tsx b/frontend/src/components/document/doc-pager.tsx index 1c31c8c..a9c4410 100644 --- a/frontend/src/components/document/doc-pager.tsx +++ b/frontend/src/components/document/doc-pager.tsx @@ -13,26 +13,35 @@ import { ChevronLeft, ChevronRight } from "lucide-react"; import { api } from "@/api/client"; import { getListContext } from "@/lib/doc-list-context"; -export function DocPager({ slug, name, onSave }: { +export function DocPager({ slug, name, onSave, kind = "document" }: { slug: string; name: string; /** Called on Cmd/Ctrl+S. Omit if the page has nothing to save. */ onSave?: () => void; + /** "master" pages through /masters/{slug} (name ASC) instead of /app/{slug}. */ + kind?: "document" | "master"; }) { const navigate = useNavigate(); - const ctx = getListContext(slug); + const isMaster = kind === "master"; + // Masters get their own context namespace so e.g. a "lead" master and a + // "lead" doctype list don't clobber each other's stashed URL. + const ctx = getListContext(isMaster ? `master:${slug}` : slug); + const base = isMaster ? `/masters/${slug}` : `/app/${slug}`; const { data } = useQuery({ - queryKey: ["adjacent", slug, name, ctx?.filters ?? null], - queryFn: () => api.adjacentDocument(slug, name, ctx?.filters as any), + queryKey: ["adjacent", kind, slug, name, ctx?.filters ?? null], + queryFn: () => + isMaster + ? api.adjacentMaster(slug, name) + : api.adjacentDocument(slug, name, ctx?.filters as any), }); const prev = data?.prev ?? null; const next = data?.next ?? null; useEffect(() => { - const goPrev = () => prev && navigate(`/app/${slug}/${prev}`); - const goNext = () => next && navigate(`/app/${slug}/${next}`); - const back = () => navigate(`/app/${slug}${ctx?.search || ""}`); + const goPrev = () => prev && navigate(`${base}/${prev}`); + const goNext = () => next && navigate(`${base}/${next}`); + const back = () => navigate(`${base}${ctx?.search || ""}`); const onKey = (e: KeyboardEvent) => { // Save works even mid-edit. @@ -49,7 +58,7 @@ export function DocPager({ slug, name, onSave }: { }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [prev, next, slug, ctx?.search, onSave, navigate]); + }, [prev, next, base, ctx?.search, onSave, navigate]); const btn = "rounded-md border border-line bg-surface p-1.5 text-fg-muted transition hover:bg-surface-subtle hover:text-fg disabled:cursor-default disabled:opacity-40"; @@ -60,7 +69,7 @@ export function DocPager({ slug, name, onSave }: { type="button" disabled={!prev} title="Previous record (k / ←)" - onClick={() => prev && navigate(`/app/${slug}/${prev}`)} + onClick={() => prev && navigate(`${base}/${prev}`)} className={btn} > @@ -69,7 +78,7 @@ export function DocPager({ slug, name, onSave }: { type="button" disabled={!next} title="Next record (j / →)" - onClick={() => next && navigate(`/app/${slug}/${next}`)} + onClick={() => next && navigate(`${base}/${next}`)} className={btn} > diff --git a/frontend/src/pages/masters/master-form.tsx b/frontend/src/pages/masters/master-form.tsx index e82629c..8d12599 100644 --- a/frontend/src/pages/masters/master-form.tsx +++ b/frontend/src/pages/masters/master-form.tsx @@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "@/api/client"; import { useBaseCurrency } from "@/hooks/use-base-currency"; import { Button } from "@/components/ui/button"; +import { DocPager } from "@/components/document/doc-pager"; import { Card } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Select } from "@/components/ui/select"; @@ -216,8 +217,13 @@ export default function MasterFormPage() { return (
- {/* Header */} -
+ {/* Header: prev/next through the master list on the left, actions right */} +
+ {!isNew ? ( + + ) : ( +
+ )}