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
31 changes: 31 additions & 0 deletions .changeset/showcase-react-page-adapter-query-contract.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
---
"@objectstack/example-showcase": patch
---

Fix the showcase react pages' `useAdapter()` query contract, and pin it (#10288)

`renewals-pipeline` passed `top: 500` and `crm-workbench` passed `limit: 200` to
`adapter.find`. Neither is a query option: `QueryParams` declares only `$`-prefixed keys
and `ObjectStackAdapter.convertQueryParams` copies exactly those, so the key reached no
branch and was dropped with no error. The consequence is the opposite of a truncated
read — the GET list route has **no default page size**, so an absent `top` returns the
ENTIRE match set, and the cap the author wrote never happened.

The same effect then read its rows off `.records`. `find()` resolves to a normalized
`QueryResult` (`data` + `total`), never the REST envelope, so `pr.records` was
`undefined` on every call and the renewals KPI strip sat at `0 / 0 / 0` while the
`<ListView>` beside it showed the same rows correctly. Measured on a 640-row account with
the real page source driven against a contract-faithful adapter double: before,
`$top` arrives `undefined` and the strip reads `{projects: 0, invoices: 0, openInvoices: 0}`;
after, the cap is applied and it reads `{projects: 640, invoices: 640, openInvoices: 100,
capped: true}`.

Applying the cap is only half a fix, because `data.length` under a `$top` is exactly the
silently-capped count the card was filed about — so both pages now count the envelope's
`total` (the server's real count over the same `$filter` whenever a limit was applied).
The one number a cap genuinely bounds, "Open AR", is a per-row verdict over the fetched
window; it renders as `100+` rather than passing for a total.

`test/react-page-adapter-query-contract.test.ts` executes the page's real rollup effect
and then sweeps every `kind:'react'` page in the app for both contracts, with an
extraction control, a census control, and a positive control on the scanners.
14 changes: 12 additions & 2 deletions examples/app-showcase/src/ui/pages/crm-workbench.page.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,9 +36,19 @@ function Page() {
// a "records" array. Reading .records here always missed, so the KPI cards
// silently stuck at 0 even though the ListView beside them showed the same
// rows. Read .data first, with .records/array fallbacks for robustness.
const all = await adapter.find('showcase_project', { limit: 200 });
//
// The cap is $top, not 'limit': QueryParams declares only $-prefixed keys
// and the adapter copies only those, so a bare 'limit' is dropped without
// an error and the read runs unbounded. And once a cap IS applied, the
// headline count has to come from the envelope's 'total' (the server's
// real count over the same filter) — rows.length would report 200 for
// every workspace with more than 200 projects, and look right doing it.
// "Active" stays a per-row verdict over the 200 rows actually fetched;
// an exact one would need its own filtered count query.
const all = await adapter.find('showcase_project', { $top: 200 });
const rows = Array.isArray(all) ? all : (all && (all.data || all.records)) || [];
setStats({ total: rows.length, active: rows.filter((r) => r.status === 'active').length });
const total = typeof (all && all.total) === 'number' ? all.total : rows.length;
setStats({ total, active: rows.filter((r) => r.status === 'active').length });
} catch (e) { console.warn('[CRM Workbench] failed to refresh stats', e); }
}, [adapter]);
React.useEffect(() => { refreshStats(); }, [refreshStats, reloadKey]);
Expand Down
46 changes: 36 additions & 10 deletions examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,7 +13,9 @@ import { definePage } from '@objectstack/spec/ui';
*
* The 360 panel deliberately shows BOTH rollup styles side by side:
* • hand-rolled — a `useAdapter()` effect counts related projects/invoices
* into a KPI strip (full control, you own loading/refresh), vs
* into a KPI strip (full control, you own loading/refresh, and you own the
* adapter's `$`-prefixed option contract and its `QueryResult` shape — see
* the numbered note on the effect), vs
* • framework blocks — `<ObjectChart>`/`<ListView>` do the same cross-object
* reads declaratively (zero data code).
* (This comparison absorbed the former Account Cockpit page.)
Expand DownExpand Up@@ -56,19 +58,43 @@ function Page() {
const [editing, setEditing] = React.useState(false);
const [reload, setReload] = React.useState(0);
const [stage, setStage] = React.useState('active');
const [related, setRelated] = React.useState({ projects: 0, invoices: 0, openInvoices: 0 });
const [related, setRelated] = React.useState({ projects: 0, invoices: 0, openInvoices: 0, capped: false });

// Hand-rolled rollup: the imperative counterpart of the framework blocks
// below. You own the queries, loading, and refresh (reload bumps re-run it).
// below. You own the queries, loading, and refresh (reload bumps re-run it) --
// and, because you own them, you own the two adapter contracts the blocks hide:
//
// 1. QUERY OPTIONS ARE $-PREFIXED. QueryParams declares $select, $filter,
// $orderby, $skip, $top, $expand, $search, $count, and the adapter copies
// ONLY those. A bare 'top:' / 'limit:' reaches no branch and is dropped
// with no error, so the cap you wrote is never applied -- and the list
// route has no default page size, so the query comes back carrying EVERY
// matching row.
// 2. THE RESULT IS A QueryResult, NOT THE REST ENVELOPE. Rows arrive under
// 'data' (never 'records'), beside 'total' -- which, whenever $top was
// applied, is the server's real count over the same $filter rather than
// the page length. Counting data.length under a cap is how a KPI starts
// under-reporting in silence; count 'total' and the cap stays a fetch
// bound instead of a lie about the business.
React.useEffect(() => {
let alive = true;
(async () => {
if (!adapter || !sel) { setRelated({ projects: 0, invoices: 0, openInvoices: 0 }); return; }
const pr = await adapter.find('showcase_project', { $filter: ['account', '=', sel], top: 500 });
const iv = await adapter.find('showcase_invoice', { $filter: ['account', '=', sel], top: 500 });
const projects = Array.isArray(pr) ? pr : (pr && pr.records) || [];
const invoices = Array.isArray(iv) ? iv : (iv && iv.records) || [];
if (alive) setRelated({ projects: projects.length, invoices: invoices.length, openInvoices: invoices.filter((r) => r.status !== 'paid' && r.status !== 'void').length });
if (!adapter || !sel) { setRelated({ projects: 0, invoices: 0, openInvoices: 0, capped: false }); return; }
const pr = await adapter.find('showcase_project', { $filter: ['account', '=', sel], $top: 500 });
const iv = await adapter.find('showcase_invoice', { $filter: ['account', '=', sel], $top: 500 });
const rows = (res) => (Array.isArray(res) ? res : (res && res.data) || []);
const count = (res, list) => (typeof (res && res.total) === 'number' ? res.total : list.length);
const projects = rows(pr);
const invoices = rows(iv);
// Open AR is a per-row verdict, so it can only be read off the rows we
// actually fetched -- the one number here the cap genuinely bounds. The
// 'capped' flag says so on screen instead of letting it pass for a total.
if (alive) setRelated({
projects: count(pr, projects),
invoices: count(iv, invoices),
openInvoices: invoices.filter((r) => r.status !== 'paid' && r.status !== 'void').length,
capped: invoices.length < count(iv, invoices),
});
})();
return () => { alive = false; };
}, [adapter, sel, reload]);
Expand DownExpand Up@@ -132,7 +158,7 @@ function Page() {
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
<Stat label="Projects" value={related.projects} />
<Stat label="Invoices" value={related.invoices} />
<Stat label="Open AR" value={related.openInvoices} accent="hsl(38 92% 50%)" />
<Stat label="Open AR" value={related.capped ? related.openInvoices + '+' : related.openInvoices} accent="hsl(38 92% 50%)" />
</div>

<ObjectChart objectName="showcase_invoice" type="bar" aggregate={{ field: 'total', function: 'sum', groupBy: 'status' }} xAxis={{ field: 'status' }} yAxis={[{ field: 'total', format: '$0,0' }]} series={[{ name: 'total', label: 'Invoice value' }]} title="Invoice value by status" showLegend={true} />
Expand Down
Loading
Loading