Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 26 additions & 1 deletion api/routers/masters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>(`/documents/${doctype}`, { method: "POST", body: JSON.stringify(data) }),

Expand Down
29 changes: 19 additions & 10 deletions frontend/src/components/document/doc-pager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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";
Expand All @@ -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}
>
<ChevronLeft className="h-4 w-4" />
Expand All @@ -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}
>
<ChevronRight className="h-4 w-4" />
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/pages/masters/master-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -216,8 +217,13 @@ export default function MasterFormPage() {

return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-end">
{/* Header: prev/next through the master list on the left, actions right */}
<div className="flex items-center justify-between">
{!isNew ? (
<DocPager kind="master" slug={type!} name={name!} onSave={handleSave} />
) : (
<div />
)}
<div className="flex gap-2">
<Button onClick={handleSave} disabled={saving || missingRequiredFields.length > 0}>
{saving ? t("common.saving") : t("common.save")}
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/pages/masters/master-list.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import { Link, useParams, useNavigate, useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useUrlState, useUrlPatch } from "@/hooks/use-url-state";
Expand All @@ -10,6 +10,7 @@ import {
type ColumnDef,
} from "@tanstack/react-table";
import { api } from "@/api/client";
import { setListContext } from "@/lib/doc-list-context";
import { Button } from "@/components/ui/button";

const TYPE_LABELS: Record<string, string> = {
Expand All @@ -29,6 +30,12 @@ export default function MasterListPage() {
const label = TYPE_LABELS[type ?? ""] ?? type ?? "";
const notice = (location.state as { notice?: string } | null)?.notice;

// Stash the list URL so the detail page's DocPager can come "back" to this
// exact page (Esc/u). Masters have no filter UI, so filters stays empty.
useEffect(() => {
if (type) setListContext(`master:${type}`, { filters: {}, search: location.search });
}, [type, location.search]);

const [pageSize] = useUrlState<number>("per_page", 50);
// URL page is 1-indexed; internal 0-indexed for offset math.
const [urlPage] = useUrlState<number>("page", 1);
Expand Down
28 changes: 27 additions & 1 deletion tests/test_adjacent.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,33 @@ def adj(name, **params):
# unknown filter field -> 400
assert client.get(f"/api/documents/widget/{names[2]}/adjacent?bogus=1").status_code == 400

print(f" [adjacent] prev/next + filter-following + ends OK on {backend}")
# --- Masters: /masters/{type}/{name}/adjacent, name ASC (list order).
cust = []
for label in ("Aster Trading AG", "Borea Logistik GmbH", "Cirrus Metall AG"):
r = client.post("/api/masters/customer", json={"customer_name": label})
assert r.status_code == 200, r.text
cust.append(r.json()["name"])
cust.sort()

def madj(name, **params):
qs = "&".join(f"{k}={v}" for k, v in params.items())
return client.get(f"/api/masters/customer/{name}/adjacent" + (f"?{qs}" if qs else "")).json()

assert madj(cust[1]) == {"prev": cust[0], "next": cust[2]}, madj(cust[1])
assert madj(cust[0])["prev"] is None
assert madj(cust[2])["next"] is None
# disabled records are included by default (list parity), excludable
client.put(f"/api/masters/customer/{cust[2]}", json={"disabled": 1})
assert madj(cust[1])["next"] == cust[2]
assert madj(cust[1], include_disabled="false")["next"] is None
# the list itself pages in the same deterministic order
rows = client.get("/api/masters/customer?include_disabled=1").json()["rows"]
listed = [r["name"] for r in rows if r["name"] in set(cust)]
assert listed == cust, f"list order != name ASC: {listed}"
# unknown master type -> 404
assert client.get(f"/api/masters/nope/{cust[0]}/adjacent").status_code == 404

print(f" [adjacent] documents + masters prev/next OK on {backend}")

if not url:
for suffix in ("", "-wal", "-shm"):
Expand Down