toga-ai 1.0.804 → 1.0.806
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/knowledge/2.0/apps/_underscore/features/calculated-sql-fields.md +19 -2
- package/knowledge/2.0/apps/api2/features/v2-rest-query-contract.md +101 -1
- package/knowledge/2.0/apps/toga-blox/features/primary-table-templates.md +14 -1
- package/knowledge/2.0/apps/toga25-supply/INDEX.md +1 -0
- package/knowledge/2.0/apps/toga25-supply/features/column-visibility.md +1 -0
- package/knowledge/2.0/apps/toga25-supply/features/meta-driven-table-data.md +8 -2
- package/knowledge/2.0/apps/toga25-supply/features/server-table-url-state.md +147 -0
- package/knowledge/2.0/apps/toga25-supply/features/transfer-orders-page.md +145 -4
- package/knowledge/2.0/apps/toga25-supply/workflows/cypress-testing.md +9 -5
- package/knowledge/2.0/standards/frontend.md +18 -2
- package/knowledge/INDEX.md +1 -1
- package/knowledge/clients/nychh/features/transfer-order-inventory-quantities.md +29 -1
- package/package.json +1 -1
|
@@ -6,8 +6,8 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
10
|
-
owners: [snaredla, jcardinal, bala]
|
|
9
|
+
updated: 2026-09-14
|
|
10
|
+
owners: [snaredla, jcardinal, bala, apeterson]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model.php
|
|
13
13
|
- _underscore/Model/Client/PurchaseOrder.php
|
|
@@ -218,6 +218,19 @@ characters — that is the standard of evidence for touching a field that runs o
|
|
|
218
218
|
> even for a client whose subclass overrides it. `_Model_Compass_SalesOrder` has exactly this in its
|
|
219
219
|
> ApprovalDecision notification query. Late static binding only happens with `static::`.
|
|
220
220
|
|
|
221
|
+
## Filtering on a calculated field through the V2 API — it works, bare name only
|
|
222
|
+
|
|
223
|
+
A calculated field **can** be used in a V2 `where`, and it is the cheapest way to cut a big list —
|
|
224
|
+
but **only with the BARE name** (`_qtyAvailable`), never table-prefixed (`Table._qtyAvailable`,
|
|
225
|
+
which is routed to a `HAVING` and returns 500 / `EO-1`). The full mechanism, the `ge`-not-`gte`
|
|
226
|
+
and string-value traps, and the measured cost numbers live in
|
|
227
|
+
[V2 REST query contract](../../api2/features/v2-rest-query-contract.md) — do not restate them here.
|
|
228
|
+
|
|
229
|
+
Two consequences for whoever **writes** a calculated field: its SQL expression is inlined into a
|
|
230
|
+
`WHERE`, so it must be a self-contained expression valid outside the `SELECT` list, and it is
|
|
231
|
+
evaluated **per row** — the measured cost is roughly **1 s per named `_qty*` field per 1,238 rows**.
|
|
232
|
+
Keep the expression cheap, or expect callers to pay for it on every list.
|
|
233
|
+
|
|
221
234
|
## Gotchas / known issues
|
|
222
235
|
|
|
223
236
|
- **⚠ Never add a parameter TYPE to an override whose parent declares the parameter untyped - it
|
|
@@ -255,6 +268,10 @@ characters — that is the standard of evidence for touching a field that runs o
|
|
|
255
268
|
[save-cascade stored-field deadlocks](./model-save-parent-cascade-stored-field-deadlock.md).
|
|
256
269
|
|
|
257
270
|
## Change history
|
|
271
|
+
- 2026-09-14 — Added a **Filtering** section: a calculated field IS usable in a V2 `where` with the
|
|
272
|
+
bare (non-table-prefixed) name, which contradicts a belief written into several frontend repos.
|
|
273
|
+
Mechanism and cost figures cross-referenced to the api2 query contract rather than duplicated.
|
|
274
|
+
(apeterson)
|
|
258
275
|
- 2026-09-10 — Added two NYCHH-only calc fields on `_Model_Nychh_Unit`
|
|
259
276
|
(`_underscore/Model/Nychh/Unit.php`) for the rebuilt `units-for-items-for-purchase-orders` grid,
|
|
260
277
|
both plain `FIELD_SQL` (text — no `FIELDOPT_SQL_TYPE`): **(a)** an **override** of
|
|
@@ -6,7 +6,7 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-14
|
|
10
10
|
owners: [tcox, bala, apeterson, jcardinal, rgirish]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
@@ -111,6 +111,29 @@ The same route's **list GET was ~1s**, so this is the **write read-back**, not t
|
|
|
111
111
|
`api2`/`_underscore` is an **open follow-up** (needs a `cto` review before touching the shared
|
|
112
112
|
serializer). Lowering caller depth is a mitigation, not the fix.
|
|
113
113
|
|
|
114
|
+
### Measured cost of a list GET — it is per ROW × per NAMED CALCULATED FIELD × per FK hop
|
|
115
|
+
|
|
116
|
+
One `GET /v2/purchase-order-items` over the **same 1,238 rows** (NYCHH, `sandbox-client`), one curl
|
|
117
|
+
per line, 2026-09-14:
|
|
118
|
+
|
|
119
|
+
| Request | Time |
|
|
120
|
+
|---|---|
|
|
121
|
+
| full field set as the app sent it, `calcDepth: 3` | **13.3 s** (785 KB) |
|
|
122
|
+
| identical but `calcDepth: 1` | 13.3 s (byte-identical response) |
|
|
123
|
+
| minus `_qtyOnHand` / `_qtyReceived` / `_qtyFulfilled` | 10.2 s |
|
|
124
|
+
| minus `_qtyAvailable` as well | 6.1 s |
|
|
125
|
+
| minus the `vendorItem → item` FK chain | 4.0 s |
|
|
126
|
+
| `uuid`/`lineNumber`/`quantity` only — no FK, no calculated | 1.3 s |
|
|
127
|
+
| full field set at `recordsPerPage: 100` | 1.7 s |
|
|
128
|
+
| full field set **+ `where=(_qtyAvailable:ge:1)`** | **0.35 s** |
|
|
129
|
+
|
|
130
|
+
Read: ~**11 ms per row** at the full field set. Three named `_qty*` calculated fields cost **3.1 s**
|
|
131
|
+
across 1,238 rows; the `vendorItem → item` FK expansion another **~2 s**.
|
|
132
|
+
|
|
133
|
+
**Therefore, in order of payoff:** (1) filter server-side so fewer rows are built at all — a
|
|
134
|
+
`where` cut 13.3 s to 0.35 s, ~38×; (2) drop calculated fields nothing renders; (3) drop FK hops
|
|
135
|
+
nothing renders; (4) page. Raising/lowering `calcDepth` changed nothing.
|
|
136
|
+
|
|
114
137
|
### ⚠ `calcDepth` defaults to **1** — a calculated field one FK hop out comes back NULL, silently
|
|
115
138
|
|
|
116
139
|
`calcDepth` bounds how deep `FIELD_SQL` **calculated** fields are evaluated, independently of
|
|
@@ -127,6 +150,22 @@ calculated field is null on a nested object but correct when that object is fetc
|
|
|
127
150
|
Definition of the fields themselves:
|
|
128
151
|
[FIELD_SQL calculated fields](../../_underscore/features/calculated-sql-fields.md).
|
|
129
152
|
|
|
153
|
+
#### ⚠ …but `calcDepth` does NOT gate a calculated field you NAME EXPLICITLY in `fields`
|
|
154
|
+
|
|
155
|
+
The rule above holds for the **no-explicit-`fields`** path only. A calculated field written as a
|
|
156
|
+
**dotted path inside `fields=`** — e.g. `vendorItem.item._thumbnailImageUrl`, two FK hops out — is
|
|
157
|
+
classified as calculated at `V2.php` ~L3338, **before** the `calcDepth` gate is reached, so it
|
|
158
|
+
resolves at the default `calcDepth: 1`.
|
|
159
|
+
|
|
160
|
+
Verified 2026-09-14 on `GET /purchase-order-items` (1,238 NYCHH lines): the response is
|
|
161
|
+
**byte-identical** at `calcDepth: 1` and `calcDepth: 3`, with the same 473 non-null thumbnails. So
|
|
162
|
+
raising `calcDepth` "to cover the deepest hop" for a field you already named is **pure cost with no
|
|
163
|
+
effect** — it does not make the request slower on its own, but it hides the real cost driver (the
|
|
164
|
+
number of calculated fields, below).
|
|
165
|
+
|
|
166
|
+
**Decide which path you are on first:** naming the field ⇒ ignore `calcDepth`; letting
|
|
167
|
+
`getFullModelData()` serialize everything ⇒ the one-per-FK-hop rule applies.
|
|
168
|
+
|
|
130
169
|
### ⚠ A non-null FK object at the MAX requested `depth` is OMITTED from the response
|
|
131
170
|
|
|
132
171
|
A GET serialized to `depth: N` renders FK objects down to level N, but a **non-null FK object that
|
|
@@ -276,6 +315,55 @@ where=(field:op:value,LOGIC,field:op:value,(nested,OR,nested))
|
|
|
276
315
|
- **NULL has no operator.** Use the literal value `null`: `field:eq:null` → `IS NULL`,
|
|
277
316
|
`field:ne:null` → `IS NOT NULL`.
|
|
278
317
|
|
|
318
|
+
### ✅ You CAN `where` on a calculated (`FIELD_SQL`) field — but ONLY with the BARE name
|
|
319
|
+
|
|
320
|
+
This corrects a widespread belief that a `where` on a calculated field is impossible. It works, and
|
|
321
|
+
it is the single biggest performance lever on a large list (13.3 s → 0.35 s above). The **only**
|
|
322
|
+
thing that has to be right is the shape of the field name:
|
|
323
|
+
|
|
324
|
+
```
|
|
325
|
+
where=(_qtyAvailable:ge:1) ✅ plain WHERE — HTTP 200, 0.35 s
|
|
326
|
+
where=(PurchaseOrderItems._qtyAvailable:ge:1) ❌ becomes a HAVING — HTTP 500 / EO-1
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Mechanism, read from `api2/Component/Api/V2/V2.php`:
|
|
330
|
+
|
|
331
|
+
1. `buildWhereExpressionsFromOptions` (~L6953) **deliberately does not prepend the default table**
|
|
332
|
+
to a field starting with `_` — its own comment says *"add the default table if not specified and
|
|
333
|
+
is not a calculated field."*
|
|
334
|
+
2. `buildWhereClauseFromWhereExpressions` (~L7069) sees the leading `_` and **inlines the field's own
|
|
335
|
+
SQL expression** straight into the `WHERE` clause. That is the working path.
|
|
336
|
+
3. `splitWhereExpressionsIntoWhereHaving` (~L8622) routes to `HAVING` **only when the field contains
|
|
337
|
+
`._`** — i.e. only the table-**prefixed** form. That form targets a joined-table select alias
|
|
338
|
+
(`PurchaseOrderItems__qtyAvailable`, built ~L3984) which **does not exist for the main table**,
|
|
339
|
+
and ~L4116 also appends the `HAVING` to the `$sqlProhibited` COUNT query, which has no such alias
|
|
340
|
+
either. Hence the 500.
|
|
341
|
+
|
|
342
|
+
**The COUNT query is fine on the bare form.** `meta.totalRecordCount` comes back correct (`4`), and a
|
|
343
|
+
filter that matches nothing returns a clean **200 with `totalRecordCount: 0`** — verified, it does
|
|
344
|
+
**not** error on the empty case. So pagination is not broken by this, contrary to what several
|
|
345
|
+
in-repo comments claim.
|
|
346
|
+
|
|
347
|
+
Three things that fail **silently or misleadingly** and are worth locking with a test:
|
|
348
|
+
|
|
349
|
+
- **The name must be bare** — `_qtyAvailable`, never `Table._qtyAvailable`.
|
|
350
|
+
- **The operator is `ge`, not `gte`** — `gte` returns 500 / `EO-1` (same as every other op).
|
|
351
|
+
- **The value must be a STRING** when you go through `@agilant/toga-blox` — `urlEncode` calls
|
|
352
|
+
`.replace()` on it, so a numeric value throws inside `buildUrl` and **no HTTP request is ever
|
|
353
|
+
sent** (see [blox api-client](../../toga-blox/features/api-client.md)).
|
|
354
|
+
|
|
355
|
+
Corroboration that this is long-standing, not new: bare calculated fields in `where` already ship in
|
|
356
|
+
production — `toga2-supply/src/api/toga.ts` L906-919 (`_status` OR group) and L886-893 (NYCHH
|
|
357
|
+
`_total:ne:0`), `toga2-supply/src/pages/Orders/api/OrdersApi.ts:1900` (`_name`),
|
|
358
|
+
`toga2-commerce/src/pages/Cart/api/CartApi.ts` L48/L257/L349 (`_name`),
|
|
359
|
+
`toga25-supply/src/layout/RecordApprovalModal/view/ApprovalFlowDetailInputs.tsx:27` (`_name`).
|
|
360
|
+
**Zero** occurrences of the table-prefixed form exist in any repo — nobody has ever used it
|
|
361
|
+
successfully.
|
|
362
|
+
|
|
363
|
+
> ⚠ **Scope this to `where` only.** It says nothing about `group` or `sort` on a calculated field;
|
|
364
|
+
> `group=_status` is separately known to fail. And `where` still never prunes a nested child array
|
|
365
|
+
> (see below).
|
|
366
|
+
|
|
279
367
|
## Encoding rules (the query string is never urldecoded at parse time)
|
|
280
368
|
|
|
281
369
|
- Encode a literal `%` in a LIKE pattern as **`%25`**.
|
|
@@ -410,6 +498,11 @@ ACL gate for calculated fields lives at **:7216** in `getFullModelData()` — wh
|
|
|
410
498
|
- **A 500 (`EO-1`) on a filter ⇒ suspect a `where` reference to an unjoined table.**
|
|
411
499
|
- **A 500 (`EO-1`) reading `Invalid operator '' in WHERE conditions` ⇒ the `where` clause was URL-encoded.** The query string is never `urldecode`d — send it raw (see the encoding rules).
|
|
412
500
|
- **`gte`/`lte` silently are not operators** — use `ge`/`le`.
|
|
501
|
+
- **⚠ A 500 / `EO-1` on a `where` over a calculated field ⇒ you table-PREFIXED the field name.**
|
|
502
|
+
Drop the prefix: `_qtyAvailable`, not `PurchaseOrderItems._qtyAvailable`. The prefixed form is
|
|
503
|
+
routed to a `HAVING` against an alias that does not exist.
|
|
504
|
+
- **⚠ Do not raise `calcDepth` for a calculated field you named in `fields=`** — it is classified
|
|
505
|
+
before the gate and already resolves at the default `1`.
|
|
413
506
|
- **Do not trust a 200 with missing fields.** Without an explicit `fields=` list, ACL removal is
|
|
414
507
|
invisible; re-request with `fields=` to force `EZ-2` and see the denied list. **⚠ Plain columns
|
|
415
508
|
only** — an `_`-prefixed field named in `fields=` is never ACL-checked and returns `0`, not
|
|
@@ -428,6 +521,13 @@ ACL gate for calculated fields lives at **:7216** in `getFullModelData()` — wh
|
|
|
428
521
|
[V2 request logging](request-logging.md).
|
|
429
522
|
|
|
430
523
|
## Change history
|
|
524
|
+
- 2026-09-14 — **Corrected a wrong team-wide belief: a `where` on a calculated (`FIELD_SQL`) field
|
|
525
|
+
WORKS, with the BARE field name.** `_qtyAvailable:ge:1` is inlined into the `WHERE` and returns 200
|
|
526
|
+
with a correct `totalRecordCount`; only the table-prefixed `Table._qtyAvailable` form is routed to a
|
|
527
|
+
`HAVING` against a non-existent alias and 500s. Also recorded that **`calcDepth` does not gate a
|
|
528
|
+
calculated field named explicitly in `fields=`** (byte-identical response at `calcDepth` 1 vs 3),
|
|
529
|
+
and added a **measured per-row cost model** for a list GET (13.3 s → 0.35 s by filtering
|
|
530
|
+
server-side). Found while fixing the toga25-supply Create Transfer Order item picker. (apeterson)
|
|
431
531
|
- 2026-09-04 — Recorded the **URL-encoded `where` trap** hit while debugging a production Rate/AIG
|
|
432
532
|
incident: `parseOptionsWhere` (`V2.php` ~L325) reads `$_SERVER['QUERY_STRING']` with no
|
|
433
533
|
`urldecode()`, so an encoded clause (curl `--data-urlencode`, `requests` `params=`,
|
|
@@ -6,7 +6,7 @@ project: TOGa Blox
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-09-14
|
|
10
10
|
owners: [apeterson, tcox]
|
|
11
11
|
files:
|
|
12
12
|
- toga-blox/src/components/Table/themeConfig/toga.module.css
|
|
@@ -22,6 +22,7 @@ files:
|
|
|
22
22
|
related:
|
|
23
23
|
- ../architecture.md
|
|
24
24
|
- table.md
|
|
25
|
+
- ../../toga25-supply/features/server-table-url-state.md
|
|
25
26
|
---
|
|
26
27
|
|
|
27
28
|
## Summary
|
|
@@ -132,6 +133,15 @@ runs unconditionally — it no longer no-ops while `isExpanded`.
|
|
|
132
133
|
|
|
133
134
|
## Gotchas
|
|
134
135
|
|
|
136
|
+
- **`PrimaryTableServerTemplate` is FULLY controlled — its change handlers fire only on real user
|
|
137
|
+
interaction.** `state: { sorting, columnFilters }` is passed in and `manualSorting`,
|
|
138
|
+
`manualFiltering`, and `manualPagination` are all `true`, so `onSortingChange` /
|
|
139
|
+
`onColumnFiltersChange` **never fire on mount**. That is what makes it safe for the consumer to
|
|
140
|
+
mutate other URL state (e.g. reset `page` to 1) inside those handlers: no handler runs without
|
|
141
|
+
user intent, so a shared deep link like `?…_page=3` can never be clobbered on load. blox only
|
|
142
|
+
*reads* the view state; the app owns the URL. See
|
|
143
|
+
[toga25-supply server table URL state](../../toga25-supply/features/server-table-url-state.md).
|
|
144
|
+
|
|
135
145
|
- **Never gate a hover affordance on a JS `mouseleave` inside a virtualized table.** `mouseleave`
|
|
136
146
|
is not guaranteed to fire: a virtualized row can translate out from under a stationary cursor on
|
|
137
147
|
wheel-scroll, and any early-return in the clear handler strands the "hovered" flag set. The toggle
|
|
@@ -164,6 +174,9 @@ runs unconditionally — it no longer no-ops while `isExpanded`.
|
|
|
164
174
|
not the column set.
|
|
165
175
|
|
|
166
176
|
## Change history
|
|
177
|
+
- 2026-09-14 — Recorded that the server template is fully controlled (`manual*` + passed-in
|
|
178
|
+
`state`), so its sort/filter handlers never fire on mount — the property a consumer relies on
|
|
179
|
+
when resetting pagination from inside those handlers. No code change. (apeterson)
|
|
167
180
|
- 2026-08-31 — Host-tokenized the `supply`-skin table shadow: `.tableAndPaginationInner.supply` /
|
|
168
181
|
`.tableWrapper.supply` now use `box-shadow: var(--primaryTable-box-shadow, 0 1px 3px rgba(0,0,0,0.1))`,
|
|
169
182
|
adding `--primaryTable-box-shadow` to the host-token set (a design review needed the shadow gone and
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
| [Meta-Driven Page & Table Setup](features/meta-driven-table-data.md) | A page in this app is **meta-driven end to end**: the page view model fetches *page meta* (labels, sections, ACL) and *table meta* (the columns/fields + table s |
|
|
12
12
|
| [Persisted React Query cache (localStorage `supply-chain-query-cache`)](features/persisted-query-cache.md) | `localStorage["supply-chain-query-cache"]` is **not a hand-written cache**. |
|
|
13
13
|
| [Record Modals & Nested Tables](features/record-modals-and-nested-tables.md) | The repo's family of modal + nested-table patterns layered over toga-blox `TableRecordModal` and `PrimaryTable*Layout`. |
|
|
14
|
+
| [Server Table URL State (page, sort, filters) — `useServerTableUrlState`](features/server-table-url-state.md) | `src/hooks/useServerTableUrlState.ts` is the app's single source of truth for a server-paged table's **view state**: page, records-per-page, sorting, column fil |
|
|
14
15
|
| [Side navigation & default route — an empty nav renders a BLANK PAGE and gets reported as "cannot log in"](features/side-navigation-and-default-route.md) | The 2.5 side nav is **100% backend-driven** by the `navigation` surface bundle, and the same list also decides **which routes exist** and **where `/` lands**. |
|
|
15
16
|
| [SSO redirect & public-vs-user session gating (useAuthenticationFlow)](features/sso-redirect-and-session-gating.md) | How 2.5 Supply decides, on every navigation, whether an anonymous visitor should be bounced to their client's SSO IdP instead of the local `/login` form. |
|
|
16
17
|
| [Surface Frontend (DB-driven UI consumption, src/surface/)](features/surface-frontend.md) | The frontend consumer of the platform-wide Surface layer — DB-driven UI config fetched from `GET /v2/surfaces/meta?slug=<slug>` instead of statically-imported J |
|
|
@@ -6,7 +6,7 @@ project: TOGa 2.5 Supply
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-09-14
|
|
10
10
|
owners: [apeterson]
|
|
11
11
|
files:
|
|
12
12
|
- toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx
|
|
@@ -22,6 +22,7 @@ related:
|
|
|
22
22
|
- column-visibility.md
|
|
23
23
|
- record-modals-and-nested-tables.md
|
|
24
24
|
- transfer-orders-page.md
|
|
25
|
+
- server-table-url-state.md
|
|
25
26
|
- ../../_underscore/features/page-meta-context-field-settings.md
|
|
26
27
|
---
|
|
27
28
|
|
|
@@ -162,7 +163,10 @@ App-side glue (NOT in toga-blox):
|
|
|
162
163
|
- `useTablePageMeta` (`src/hooks/useTablePageMeta.ts`) — thin wrapper over `useFetchTablePageMeta`
|
|
163
164
|
that re-types `table`/`nestedTable` as `TableViewMeta | null` and re-exposes the rest unchanged.
|
|
164
165
|
- `useSalesOrdersTableState` → `useServerTableUrlState` (`src/hooks/`) — derives the **table slug**
|
|
165
|
-
from the route pathname and owns URL-backed sorting/filters/pagination/active-row state.
|
|
166
|
+
from the route pathname and owns URL-backed sorting/filters/pagination/active-row state. It is the
|
|
167
|
+
app's only writer of `<slug>_page`, and it resets the page to 1 on any filter/sort/page-size
|
|
168
|
+
change — the full param grammar and that rule live in
|
|
169
|
+
[server-table-url-state](./server-table-url-state.md).
|
|
166
170
|
|
|
167
171
|
## Wiring a new page (recipe)
|
|
168
172
|
|
|
@@ -198,6 +202,8 @@ App-side glue (NOT in toga-blox):
|
|
|
198
202
|
raw camelCase slugs. Plan the label source before building the screen (see above).
|
|
199
203
|
|
|
200
204
|
## Change history
|
|
205
|
+
- 2026-09-14 — Pointed the table-state bullet at the new `server-table-url-state` doc, which now
|
|
206
|
+
owns the `<slug>_*` param grammar and the reset-to-page-1 rule. No code change. (apeterson)
|
|
201
207
|
- 2026-08-28 — Recorded that `_Model_Client_TableView::meta()` emits **no `label`** for a column, so
|
|
202
208
|
every header comes from the page meta and `useAssignTableFieldLabels` **bails on a null `pageMeta`**
|
|
203
209
|
(`if (!pageFields) return table`): a new list screen without a legacy `<slug>-listing` page-meta
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Server Table URL State (page, sort, filters) — `useServerTableUrlState`
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: toga25-supply
|
|
5
|
+
project: TOGa 2.5 Supply
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-09-14
|
|
10
|
+
owners: [apeterson]
|
|
11
|
+
files:
|
|
12
|
+
- toga25-supply/src/hooks/useServerTableUrlState.ts
|
|
13
|
+
- toga25-supply/src/hooks/useServerTableUrlState.test.ts
|
|
14
|
+
- toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableState.ts
|
|
15
|
+
related:
|
|
16
|
+
- ../architecture.md
|
|
17
|
+
- meta-driven-table-data.md
|
|
18
|
+
- column-visibility.md
|
|
19
|
+
- record-modals-and-nested-tables.md
|
|
20
|
+
- ../../toga-blox/features/primary-table-templates.md
|
|
21
|
+
- ../../../standards/frontend.md
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## What it is
|
|
25
|
+
|
|
26
|
+
`src/hooks/useServerTableUrlState.ts` is the app's single source of truth for a server-paged
|
|
27
|
+
table's **view state**: page, records-per-page, sorting, column filters, filter/sort ordering,
|
|
28
|
+
and the active (clicked) row. It reads that state out of the query string and gives the page
|
|
29
|
+
back handlers that write it. Every list page goes through it — Sales Orders via
|
|
30
|
+
`useSalesOrdersTableState`, and the same shape for the other pages.
|
|
31
|
+
|
|
32
|
+
All params are **namespaced by the table slug**, so two tables on one URL never collide.
|
|
33
|
+
|
|
34
|
+
> It is the **only writer of `<slug>_page` in the whole app** (verified by grep across `src/`,
|
|
35
|
+
> 2026-09-14). There is no second pagination code path to keep in sync. `useEntitySearch` is for
|
|
36
|
+
> `advancedSelect` dropdown pickers and holds no pagination URL state; the only "search" on a
|
|
37
|
+
> main table view is blox's per-column `HeaderFilterSearch`, which routes into
|
|
38
|
+
> `handleColumnFiltersChange` here.
|
|
39
|
+
|
|
40
|
+
## The param grammar
|
|
41
|
+
|
|
42
|
+
| Param | Meaning |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `<slug>_page` | current page (default `1`) |
|
|
45
|
+
| `<slug>_recordsPerPage` | page size (default `15`) |
|
|
46
|
+
| `<slug>_sort[i]` | sort at position `i`, value prefixed `+` asc / `-` desc |
|
|
47
|
+
| `<slug>_<field>` | bare form = `includes` (substring match) |
|
|
48
|
+
| `<slug>_<field>_starts` / `_ends` / `_eq` / `_excludes` | `startsWith` / `endsWith` / `exactly` / `excludes` |
|
|
49
|
+
| `<slug>_<field>_in` | multi-select over a closed option set — **exact** values, one SQL `IN` |
|
|
50
|
+
| `<slug>_<field>_between` / `_before` / `_after` / `_min` / `_max` | range params, collected into one `{ params }` filter value |
|
|
51
|
+
| `<slug>_<field>_filter` | multi-chip text filter: comma-separated `mode:value` chips, values URL-encoded, all AND'd |
|
|
52
|
+
| `<slug>_<field>_order` | the filter's display order (default `999`) |
|
|
53
|
+
| `<slug>_actionOrder` | shared order of applied filter/sort chips |
|
|
54
|
+
| `<slug>` (bare) | `activeRowUuid` — the open record modal |
|
|
55
|
+
|
|
56
|
+
The reader loops `searchParams`, matches by suffix, and falls through to `includes` for the bare
|
|
57
|
+
form — which is why the fall-through explicitly **excludes** `_order`, `_sort`, `_page`,
|
|
58
|
+
`_recordsPerPage`, and `_actionOrder` so a control param is never mistaken for a filter. Add a new
|
|
59
|
+
control param and you must add it to that exclusion list.
|
|
60
|
+
|
|
61
|
+
### `_in` is exact, not `includes`
|
|
62
|
+
|
|
63
|
+
`_in` used to alias the bare `includes` form, so picking `fulfilled` in a multi-select also matched
|
|
64
|
+
`partiallyFulfilled`. It is now its own `mode = "in"` on the read side with a matching
|
|
65
|
+
`case "in":` on the write side: exact values, serialized downstream as one SQL `IN`.
|
|
66
|
+
|
|
67
|
+
## How it works — any result-changing param resets to page 1
|
|
68
|
+
|
|
69
|
+
🚨 **This is the load-bearing rule of the hook.** A change to *which records the server returns* —
|
|
70
|
+
a filter/search, a sort, or a page size — must send the user back to page 1.
|
|
71
|
+
|
|
72
|
+
One helper, one home:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
const FIRST_PAGE = "1";
|
|
76
|
+
|
|
77
|
+
const resetToFirstPage = useCallback(
|
|
78
|
+
(prev: URLSearchParams) => { prev.set(`${slug}_page`, FIRST_PAGE); },
|
|
79
|
+
[slug],
|
|
80
|
+
);
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Called by all three result-changing handlers — `handleColumnFiltersChange`,
|
|
84
|
+
`handleSortingChange`, `handleRecordsPerPageChange`.
|
|
85
|
+
|
|
86
|
+
Two details that matter:
|
|
87
|
+
|
|
88
|
+
1. **The reset is written inside the SAME `setSearchParams(prev => …)` updater** as the
|
|
89
|
+
filter/sort write. One history entry, one React Query refetch — so the back button undoes the
|
|
90
|
+
filter and the page together instead of leaving a half state.
|
|
91
|
+
2. **`handleRowClick` deliberately does NOT reset.** Opening a record modal must not throw the
|
|
92
|
+
user back to page 1. Neither does `handlePageChange`, obviously.
|
|
93
|
+
|
|
94
|
+
This is the concrete implementation of the rule in
|
|
95
|
+
[`standards/frontend.md` §5](../../../standards/frontend.md) — a param that changes the query
|
|
96
|
+
result belongs in the React Query key *and* resets to page 1.
|
|
97
|
+
|
|
98
|
+
### Why an app-side reset is safe (it does not clobber deep links)
|
|
99
|
+
|
|
100
|
+
blox's `PrimaryTableServerTemplate` builds the table **fully controlled**: `state: { sorting,
|
|
101
|
+
columnFilters }` with `manualSorting` / `manualFiltering` / `manualPagination: true`. So
|
|
102
|
+
`onColumnFiltersChange` / `onSortingChange` fire **only on real user interaction, never on mount**.
|
|
103
|
+
Nothing runs a reset on load, which is what lets a shared `?…_page=3` URL survive. The app owns the
|
|
104
|
+
URL state; blox only reads it. The fix therefore needed **no blox change, no publish, no pin bump**
|
|
105
|
+
— confirmed by the install being a real registry copy (no symlink), pinned exactly.
|
|
106
|
+
|
|
107
|
+
## Gotchas
|
|
108
|
+
|
|
109
|
+
- **A "backend search returns nothing" report is usually this bug.** Symptom: user on page 2
|
|
110
|
+
searches for a PO number that lives on page 1 and gets an empty table. The front end sent the
|
|
111
|
+
new filter with `<slug>_page=2` still set, api2 paged the *new* (filtered) result set from page
|
|
112
|
+
2, and the single match — now on page 1 — fell outside the slice. It reads exactly like an api2
|
|
113
|
+
search defect and is not one. Check the query string before you open the API.
|
|
114
|
+
- **Page size worked while search did not.** `handleRecordsPerPageChange` already reset the page;
|
|
115
|
+
the filter and sort handlers never did. If only *some* controls behave, suspect an incomplete
|
|
116
|
+
reset rather than a data-layer problem.
|
|
117
|
+
- **Clearing all filters also resets to page 1** — by decision, see below. Do not re-add an
|
|
118
|
+
`if (next.length > 0)` guard around the reset.
|
|
119
|
+
- **The hook's filter read/write block is a merge hot spot but not a conflict magnet.** Two
|
|
120
|
+
independent fixes landed in it within days (the page reset, and the `_in` exact-match change a
|
|
121
|
+
few lines away) and `git merge` resolved them clean with both surviving. Verify both markers by
|
|
122
|
+
grep after such a merge rather than assuming a loss.
|
|
123
|
+
|
|
124
|
+
## Tests
|
|
125
|
+
|
|
126
|
+
Co-located `src/hooks/useServerTableUrlState.test.ts` (Vitest). The reset rule is covered by 8
|
|
127
|
+
cases, every one **seen to fail before the fix** (5 red on the original code): filter applied,
|
|
128
|
+
multi-chip text filter applied, filters cleared, one of two filters removed, sort applied, sort
|
|
129
|
+
cleared, records-per-page changed, and the negative control — **row click must NOT reset**.
|
|
130
|
+
|
|
131
|
+
Type-check with `npx tsc --noEmit -p tsconfig.app.json`. Bare `npx tsc --noEmit` is a silent no-op
|
|
132
|
+
in this repo (references-only root config — see `standards/frontend.md` §2).
|
|
133
|
+
|
|
134
|
+
## Decisions
|
|
135
|
+
|
|
136
|
+
- **2026-09-14 — clearing filters DOES reset to page 1. The opposite was tried and reversed.**
|
|
137
|
+
Mid-session the reverse was implemented (keep the page on a filter clear, on the reasoning that
|
|
138
|
+
clearing only widens the result set so page 3 still exists), verified with a mutation test, then
|
|
139
|
+
deliberately undone. Final behavior: **every** filter change resets, including a full clear.
|
|
140
|
+
Recorded so nobody re-litigates it or re-adds the guard thinking it was an oversight.
|
|
141
|
+
|
|
142
|
+
## Change history
|
|
143
|
+
- 2026-09-14 — Documented the hook as its own subject. Added `resetToFirstPage` so filters, sorts,
|
|
144
|
+
and page-size changes all return to page 1 (fixes "search on page 2 finds nothing", TRUE-81877);
|
|
145
|
+
recorded the reversed keep-the-page-on-clear decision; noted `_in` is now exact-match not
|
|
146
|
+
`includes`; deleted ~104 lines of commented-out dead `activeRowUuid`/handler variants from the
|
|
147
|
+
bottom of the file (503 → 399 lines). (apeterson)
|
|
@@ -6,7 +6,7 @@ project: TOGa 2.5 Supply
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-14
|
|
10
10
|
owners: [apeterson, bala]
|
|
11
11
|
files:
|
|
12
12
|
- toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useCreateTransferOrder.ts
|
|
@@ -30,6 +30,7 @@ files:
|
|
|
30
30
|
- toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/CreateTransferOrderModal.tsx
|
|
31
31
|
- toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/index.ts
|
|
32
32
|
- toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/usePurchaseOrderItemRows.ts
|
|
33
|
+
- toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/usePurchaseOrderItemRows.test.ts
|
|
33
34
|
- toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useTargetLocationOptions.ts
|
|
34
35
|
- toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useTransferSourceLocation.ts
|
|
35
36
|
- toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useLocationContacts.ts
|
|
@@ -457,10 +458,14 @@ New hooks/components: `hooks/usePurchaseOrderItemRows.ts`, `hooks/useTargetLocat
|
|
|
457
458
|
No parent FK exists; the design's drill-down was dropped.
|
|
458
459
|
3. **Commit flips unit dispositions to In Transit** — still open (API vs. worker ownership).
|
|
459
460
|
|
|
460
|
-
### 🚨 OPEN BLOCKER —
|
|
461
|
+
### 🚨 OPEN BLOCKER — NYCHH availability is structurally 0, and it is not a frontend fix
|
|
461
462
|
|
|
462
|
-
The picker keeps only lines with availability >= 1.
|
|
463
|
-
|
|
463
|
+
The picker keeps only lines with availability >= 1.
|
|
464
|
+
|
|
465
|
+
> **State as of 2026-09-14 (`sandbox-client`): 4 of 1,238 lines now qualify** — availability equal to
|
|
466
|
+
> the full ordered quantity on each. It is no longer literally zero there, so a tester does get rows.
|
|
467
|
+
> **Production not re-checked.** The blocker itself is unchanged: 1,234 of 1,238 still report 0.
|
|
468
|
+
> Uuids and figures: [NYCHH transfer-order inventory quantities](../../../../clients/nychh/features/transfer-order-inventory-quantities.md).
|
|
464
469
|
|
|
465
470
|
`_Model_Nychh_PurchaseOrderItem` (deployed on `_production` and `_sandbox-client`, absent on
|
|
466
471
|
`_beta`/`_sandbox-dev`) sources "stock in" from inventory adjustments and `INNER JOIN`s
|
|
@@ -547,6 +552,82 @@ push) — see
|
|
|
547
552
|
`contact: { uuid }`, `createdByUser: { uuid }`. Each is omitted entirely when absent rather than sent
|
|
548
553
|
as `null`: a location can have no contact, and an SSO session can lack a user uuid.
|
|
549
554
|
|
|
555
|
+
### The picker fetch — 13.3 s → 0.39 s by filtering SERVER-side (2026-09-14)
|
|
556
|
+
|
|
557
|
+
`hooks/usePurchaseOrderItemRows.ts` used to fetch **every** NYCHH purchase-order line and then drop
|
|
558
|
+
the unavailable ones with a client-side `.filter(r => r.available >= 1)`. That cost **13.3 s** to
|
|
559
|
+
open the modal and built 1,238 rows to keep 4.
|
|
560
|
+
|
|
561
|
+
The availability filter now goes in the request:
|
|
562
|
+
|
|
563
|
+
```ts
|
|
564
|
+
where: { and: [{ _qtyAvailable: { ">=": "1" } }] }
|
|
565
|
+
// blox serializes this to where=(_qtyAvailable:ge:1)
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
Measured end to end: **0.39 s**, same 4 rows, every row with its linked item uuid, PO number and
|
|
569
|
+
thumbnail key. Four changes made it, in order of payoff:
|
|
570
|
+
|
|
571
|
+
1. **The `where` above** — 13.3 s → 0.35 s on the wire. This is the whole win.
|
|
572
|
+
2. **Dropped `calcDepth: 3`.** It was sent to "reach" `vendorItem.item._thumbnailImageUrl`, two FK
|
|
573
|
+
hops out. `calcDepth` does **not** gate a calculated field you name explicitly in `fields` — the
|
|
574
|
+
response is byte-identical at 1 and 3.
|
|
575
|
+
3. **Dropped `_qtyOnHand` / `_qtyReceived` / `_qtyFulfilled` from the main fetch** — 3.1 s, and
|
|
576
|
+
nothing renders them; they only fed a diagnostic string (see the probe below).
|
|
577
|
+
4. **Removed the `onHand` field from `PurchaseOrderItemRow`** and the `noAvailabilityCount`
|
|
578
|
+
diagnostic, both unrendered.
|
|
579
|
+
|
|
580
|
+
Three traps, all of which fail **silently**, so the wire form is now locked by a regression test
|
|
581
|
+
(`hooks/usePurchaseOrderItemRows.test.ts`, 6 tests) and the request options are built by a pure
|
|
582
|
+
exported `buildRowRequestOptions(ignoreAvailability)` so they are testable:
|
|
583
|
+
|
|
584
|
+
- **The field name must be BARE.** `PurchaseOrderItems._qtyAvailable` becomes a `HAVING` and 500s.
|
|
585
|
+
- **The operator is `ge`, not `gte`.**
|
|
586
|
+
- **The value must be the STRING `"1"`.** A number throws inside blox `buildUrl` and no request is
|
|
587
|
+
sent at all.
|
|
588
|
+
|
|
589
|
+
Full mechanism and the measured cost table:
|
|
590
|
+
[V2 REST query contract](../../api2/features/v2-rest-query-contract.md).
|
|
591
|
+
|
|
592
|
+
> 🧹 **Stale comments to correct when you are next in these files.** The opposite belief — *"a WHERE
|
|
593
|
+
> on a calculated field becomes a HAVING, and a HAVING breaks V2's count query and therefore
|
|
594
|
+
> pagination"* — is written into six places and is why nobody tried it. Corrected in
|
|
595
|
+
> `usePurchaseOrderItemRows.ts` this session; **still wrong** in `src/hooks/useEntitySearch.ts:15-19`,
|
|
596
|
+
> `src/layout/ItemRecordModalLayout/viewModel/useItemRecordEditViewModel.tsx:44-45`,
|
|
597
|
+
> `src/layout/VendorItemRecordModalLayout/viewModel/useVendorItemRecordEditViewModel.tsx:69-70`,
|
|
598
|
+
> `src/layout/BundleRecordModalLayout/viewModel/useBundleRecordCreateViewModel.tsx:9-10`,
|
|
599
|
+
> `src/layout/BundleRecordModalLayout/viewModel/useBundleRecordEditViewModel.tsx:10`,
|
|
600
|
+
> `src/layout/BundleItemRecordModalLayout/viewModel/useBundleItemSelectsViewModel.tsx:9`.
|
|
601
|
+
|
|
602
|
+
#### ⚠ Moving a filter server-side DESTROYS your empty-state evidence — pair it with a lazy probe
|
|
603
|
+
|
|
604
|
+
This is the reusable part, not a detail of this screen. Once the filter is on the server, **"no rows"
|
|
605
|
+
means two completely different things at once**: *nothing is in stock*, or *the field never came back
|
|
606
|
+
at all* — an ungranted or misnamed calculated field is dropped from the SELECT silently and reads as
|
|
607
|
+
a real `0`. The old code could tell them apart only because it fetched everything.
|
|
608
|
+
|
|
609
|
+
The pattern adopted here: a **second `useQuery`, `enabled` only when the main query succeeds with
|
|
610
|
+
zero rows**, fetching an **unfiltered sample** — 5 rows, `depth: 1`, the full
|
|
611
|
+
`_qtyReceived`/`_qtyFulfilled`/`_qtyOnHand`/`_qtyAvailable` chain. It costs **0.27 s and only on the
|
|
612
|
+
failure path**; the happy path pays nothing. It restores the `sampleBreakdown` string and the
|
|
613
|
+
"field absent vs. real zero" count, and its `meta.totalRecordCount` gives the tenant's true line
|
|
614
|
+
count (1,238) — which the filtered main request no longer reports.
|
|
615
|
+
|
|
616
|
+
`CreateTransferOrderModal.tsx` gained a `diagnostics.isPending` branch so the empty state reads
|
|
617
|
+
**"Checking purchase-order lines…"** rather than accusing the API of returning 0 lines while the
|
|
618
|
+
probe is still in flight.
|
|
619
|
+
|
|
620
|
+
#### ⚠ A dev flag that overrides a value CLIENT-side must also drop the SERVER-side filter on it
|
|
621
|
+
|
|
622
|
+
`VITE_TRANSFER_IGNORE_AVAILABILITY=true` substitutes the PO line's ordered `quantity` for
|
|
623
|
+
`_qtyAvailable` **client-side**. With the filter moved to the server, the API would return only the
|
|
624
|
+
already-available lines and there would be nothing left to substitute — the escape hatch would have
|
|
625
|
+
died silently. The flag therefore now **also omits the `where` clause entirely**.
|
|
626
|
+
|
|
627
|
+
That is the general rule, and it is why the client-side `available >= 1` filter was **kept**: it is a
|
|
628
|
+
no-op on the normal path, and it is what actually filters under the escape hatch, which sends no
|
|
629
|
+
`where`.
|
|
630
|
+
|
|
550
631
|
### The picker is VIRTUALIZED — spacer rows inside a real `<table>` (2026-09-03)
|
|
551
632
|
|
|
552
633
|
`view/TransferItemPickerTable.tsx` renders through **`@tanstack/react-virtual`**. The rendering
|
|
@@ -563,6 +644,50 @@ Measured on a 1,232-row list: median keystroke latency **248 ms → 24 ms**, DOM
|
|
|
563
644
|
> whatever the user is actually typing in. Replaced with an explicit `ref` + `useEffect` that focuses
|
|
564
645
|
> **once**, on the row that was just checked.
|
|
565
646
|
|
|
647
|
+
### Skeleton loading in the picker — real rows under the table's own `<colgroup>` (2026-09-14)
|
|
648
|
+
|
|
649
|
+
`view/TransferItemPickerTable.tsx` replaced the single "Loading items…" text cell with **8 placeholder
|
|
650
|
+
rows**. They are real `.row` / `.td` elements rendered **under the table's own `<colgroup>`**, so
|
|
651
|
+
column widths, row height and dividers match the loaded table exactly and **nothing shifts sideways**
|
|
652
|
+
when data arrives — which a single spanning text cell cannot do.
|
|
653
|
+
|
|
654
|
+
- Reuses the module's existing `.skBlock` / `toTransferPulse` treatment (the one the Transfer Order
|
|
655
|
+
**record** modal already uses) rather than introducing a second skeleton style.
|
|
656
|
+
- Rows are `aria-hidden`, with `aria-busy` on the scroll region; the existing
|
|
657
|
+
`prefers-reduced-motion` block now covers them too.
|
|
658
|
+
|
|
659
|
+
Two gotchas:
|
|
660
|
+
|
|
661
|
+
- **`.skRow .skBlock` is `display: block`, so `.selCell`'s `text-align: center` does NOT centre the
|
|
662
|
+
checkbox placeholder** — it needs `margin: 0 auto`. The real control is an inline `<input>`, which
|
|
663
|
+
is why the loaded row looks right and only the skeleton looked wrong. Same reason
|
|
664
|
+
`.alignRight .skBlock` needs `margin-left: auto`.
|
|
665
|
+
- **Keep the skeleton branch separate from the virtualizer branch**, or the two spacer `<tr>`s render
|
|
666
|
+
while loading.
|
|
667
|
+
|
|
668
|
+
> ⚠ **Not in the Claude Design source** — `demo/transfer-modal.jsx` has no loading state. This follows
|
|
669
|
+
> the in-repo record-modal treatment instead, and was flagged to the developer as a deviation.
|
|
670
|
+
|
|
671
|
+
### Testing the picker hook — mock the blox barrel or the suite dies on `global.css`
|
|
672
|
+
|
|
673
|
+
A test importing a module that imports `apiGet` from the `@agilant/toga-blox` barrel fails with:
|
|
674
|
+
|
|
675
|
+
```
|
|
676
|
+
TypeError: Unknown file extension ".css" for node_modules/@agilant/toga-blox/dist/global.css
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
House fix, matching `src/components/TableStateBlock/TableStateBlock.test.tsx`:
|
|
680
|
+
|
|
681
|
+
```ts
|
|
682
|
+
vi.mock("@agilant/toga-blox", () => ({ apiGet: () => undefined }));
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
The 6 picker tests were **seen to fail before passing**: reintroducing the table-prefixed field name
|
|
686
|
+
turned 3 red, making the value numeric turned 2 red, green again after restore.
|
|
687
|
+
|
|
688
|
+
> `src/hooks/useTalosSurface.test.ts` fails with the **same** `global.css` error and is
|
|
689
|
+
> **pre-existing** — it fails identically with this work stashed. Do not chase it as a regression.
|
|
690
|
+
|
|
566
691
|
### A new transfer POSTs an initial stage — and the stage is sent as `{ id }`, NOT `{ uuid }`
|
|
567
692
|
|
|
568
693
|
`hooks/useCreateTransferOrder.ts` now sends an opening stage. **`TransferOrderStages` has no `uuid`
|
|
@@ -922,6 +1047,11 @@ deliberate, separate exception — see [surface-frontend](./surface-frontend.md)
|
|
|
922
1047
|
NOT deployed as of 2026-09-10**, so the id still shows in production.
|
|
923
1048
|
- **⚠ An empty dropdown with no request in the network tab is a numeric `where` value or a
|
|
924
1049
|
non-zero `staleTime`** — see the section above. Neither logs anything.
|
|
1050
|
+
- **⚠ A `where` on a calculated field works — but BARE only.** `_qtyAvailable:ge:1` is fine;
|
|
1051
|
+
`PurchaseOrderItems._qtyAvailable:ge:1` becomes a `HAVING` and returns 500 / `EO-1`. Several
|
|
1052
|
+
in-repo comments still claim the whole thing is impossible; they are wrong (list above).
|
|
1053
|
+
- **⚠ An empty list is ambiguous once the filter is server-side** — it could be "nothing in stock"
|
|
1054
|
+
or "the field never came back". Run the unfiltered diagnostic probe before blaming either.
|
|
925
1055
|
- **⚠ An ungranted `uuid` field 403s the WHOLE request** — `uuid` is V2's `IDENTIFIER_FIELD` and is
|
|
926
1056
|
force-added to every record read, so a record whose role lacks an `AclFieldPermissions` grant on
|
|
927
1057
|
its `uuid` RecordField returns **403 `EZ-2`** listing `"fields": ["uuid"]`. Ordinary ungranted
|
|
@@ -993,6 +1123,17 @@ deliberate, separate exception — see [surface-frontend](./surface-frontend.md)
|
|
|
993
1123
|
earlier migration without names, so a name-based lookup on them finds nothing.
|
|
994
1124
|
|
|
995
1125
|
## Change history
|
|
1126
|
+
- 2026-09-14 — **Create Transfer Order item picker: 13.3 s → 0.39 s.** Moved the availability filter
|
|
1127
|
+
into the request as `where=(_qtyAvailable:ge:1)` (bare calculated-field name — the table-prefixed
|
|
1128
|
+
form 500s), dropped the pointless `calcDepth: 3` and three unrendered `_qty*` fields, and locked the
|
|
1129
|
+
wire form with 6 regression tests. Added a **lazy unfiltered diagnostic probe** that runs only when
|
|
1130
|
+
the main query returns zero rows, because a server-side filter otherwise makes "nothing in stock"
|
|
1131
|
+
and "the field never came back" indistinguishable. `VITE_TRANSFER_IGNORE_AVAILABILITY` now also
|
|
1132
|
+
omits the `where`, or the escape hatch would have silently died. Added **skeleton loading rows** to
|
|
1133
|
+
the picker (real rows under the table's `<colgroup>`, reusing the record modal's `.skBlock`
|
|
1134
|
+
treatment). Also noted that the NYCHH blocker has partially cleared on `sandbox-client` — 4 of
|
|
1135
|
+
1,238 lines now selectable, blocker still open. Nothing committed; work sits on `TRUE-80852`.
|
|
1136
|
+
(apeterson)
|
|
996
1137
|
- 2026-09-10 — **Target Location dropdown now lists only shipping locations.**
|
|
997
1138
|
`useTargetLocationOptions.ts` gained a second `where` condition,
|
|
998
1139
|
`{ "Locations.locationTypeId": { "=": "1" } }`, so the serialized query is
|
|
@@ -6,7 +6,7 @@ project: TOGa 2.5 Supply
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: workflow
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-14
|
|
10
10
|
owners: [apeterson]
|
|
11
11
|
files:
|
|
12
12
|
- toga25-supply/cypress.config.ts
|
|
@@ -253,10 +253,11 @@ Every `cy:*` script goes through **`node scripts/cypress.mjs`** — see the firs
|
|
|
253
253
|
|
|
254
254
|
- **Vitest is the other half.** `npm test` (`vitest run`) currently has **one failing test file**:
|
|
255
255
|
`src/hooks/useTalosSurface.test.ts` fails with
|
|
256
|
-
`TypeError: Unknown file extension ".css"` on `@agilant/toga-blox/dist/global.css
|
|
257
|
-
|
|
258
|
-
the Cypress work changed neither it nor `vitest.config.ts`.
|
|
259
|
-
|
|
256
|
+
`TypeError: Unknown file extension ".css"` on `@agilant/toga-blox/dist/global.css` — the suite
|
|
257
|
+
fails to **load**, so it is silently skipped in full. **Pre-existing** — the file was last
|
|
258
|
+
touched in commit `604a779` and the Cypress work changed neither it nor `vitest.config.ts`.
|
|
259
|
+
Re-confirmed on a clean tree 2026-09-14, with every other file green (148/148 on
|
|
260
|
+
`_sandbox-client`). Needs a separate fix (Vitest CSS handling for blox imports).
|
|
260
261
|
- **No other mechanical guardrails exist in this repo:** no `.github/workflows`, no husky, no
|
|
261
262
|
lint-staged. The only `.git/hooks/pre-commit` is a machine-local `code-review-graph` shim that
|
|
262
263
|
always exits 0 and is not checked in.
|
|
@@ -264,6 +265,9 @@ Every `cy:*` script goes through **`node scripts/cypress.mjs`** — see the firs
|
|
|
264
265
|
`SurfaceOverrides IS_VISIBLE` row in `dbchanges2`; the fixture only simulates the grant.
|
|
265
266
|
|
|
266
267
|
## Change history
|
|
268
|
+
- 2026-09-14 — Re-confirmed the `useTalosSurface.test.ts` / blox `dist/global.css` Vitest load
|
|
269
|
+
failure on a clean tree and refreshed the pass counts (148/148 on the rest). Still unfixed; it
|
|
270
|
+
silently costs one whole suite. No code change. (apeterson)
|
|
267
271
|
- 2026-09-03 — **Rebuilt the harness from scratch on `TRUE-81735`** (the `TRUE-79813` harness this
|
|
268
272
|
doc used to describe was never merged and is stranded there). New shape: all `/v2` calls stubbed,
|
|
269
273
|
four tenants (COMPASS/COMPASSCANADA/NYCHH/QUAD), the three-axes model (client = baseUrl, role =
|
|
@@ -5,12 +5,13 @@ project: _Underscore
|
|
|
5
5
|
client: shared
|
|
6
6
|
type: standard
|
|
7
7
|
status: active
|
|
8
|
-
updated: 2026-09-
|
|
8
|
+
updated: 2026-09-14
|
|
9
9
|
owners: [jcardinal, apeterson]
|
|
10
10
|
files: []
|
|
11
11
|
related:
|
|
12
12
|
- ../apps/toga25-supply/architecture.md
|
|
13
13
|
- ../apps/toga-blox/architecture.md
|
|
14
|
+
- ../apps/toga25-supply/features/server-table-url-state.md
|
|
14
15
|
- frontend-deploy.md
|
|
15
16
|
- backend-php.md
|
|
16
17
|
---
|
|
@@ -220,7 +221,18 @@ you are in.
|
|
|
220
221
|
explicitly cleared" are different states. Use `searchParams.has(key)`, not a truthiness check on
|
|
221
222
|
the value.
|
|
222
223
|
- **A param that changes the query result MUST be part of the React Query key.** Page, sort, and
|
|
223
|
-
filters belong in the key so a change refetches
|
|
224
|
+
filters belong in the key so a change refetches.
|
|
225
|
+
- **A param that changes WHICH RECORDS the server returns MUST also reset the page to 1** — in the
|
|
226
|
+
**same** `setSearchParams(prev => …)` updater as the change itself, so the two land in one
|
|
227
|
+
history entry and one refetch. This covers filters/search, sort, and page size. It does **not**
|
|
228
|
+
cover opening a record (a row click must not throw the user back to page 1) or a presentation-only
|
|
229
|
+
param. Miss it and the app sends the new filter with the old page: the server pages the *new*
|
|
230
|
+
result set from page 2, the single match now on page 1 falls outside the slice, and the report
|
|
231
|
+
arrives as "the backend search returns nothing." Verified 2026-09-14 in `toga25-supply` — the
|
|
232
|
+
rule above was read as query-key-only, so `handleRecordsPerPageChange` reset the page while
|
|
233
|
+
`handleColumnFiltersChange` and `handleSortingChange` never did. Keep the reset in **one** helper
|
|
234
|
+
called by every result-changing handler, not inlined per handler. (See
|
|
235
|
+
`../apps/toga25-supply/features/server-table-url-state.md`.)
|
|
224
236
|
- **A presentation-only param MUST be excluded from the key.** Column visibility changes what you
|
|
225
237
|
see, not what the server returns — putting it in the key forces a pointless refetch. (See
|
|
226
238
|
`../apps/toga25-supply/features/column-visibility.md`.)
|
|
@@ -785,6 +797,10 @@ Copy from there rather than reinventing.
|
|
|
785
797
|
|
|
786
798
|
## Change history
|
|
787
799
|
|
|
800
|
+
- 2026-09-14 — Split §5's query-key bullet: reset-to-page-1 is now its own MUST (same
|
|
801
|
+
`setSearchParams` updater, one helper, row click excluded). It was previously a parenthetical
|
|
802
|
+
inside the query-key rule and went unimplemented for filters and sorts in toga25-supply for
|
|
803
|
+
months. (apeterson)
|
|
788
804
|
- 2026-09-03 — §23 correction: replaced the two "seen to fail" worked examples. The first pass
|
|
789
805
|
listed a guardrail check plus an **unverified** component-config mutation; both are now the two
|
|
790
806
|
mutations that were actually run and observed red (a registry/fixture nav mismatch, and forcing
|
package/knowledge/INDEX.md
CHANGED
|
@@ -31,7 +31,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
31
31
|
- **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
|
|
32
32
|
- **ai-bdr** (AI-BDR) — 13 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
|
|
33
33
|
- **toga2-commerce** (TOGa Commerce) — 21 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
|
|
34
|
-
- **toga25-supply** (TOGa 2.5 Supply) —
|
|
34
|
+
- **toga25-supply** (TOGa 2.5 Supply) — 21 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
|
|
35
35
|
- **toga-blox** (TOGa Blox) — 14 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
|
|
36
36
|
- **bdr** (BDR) — 0 doc(s) → [2.0/apps/bdr/INDEX.md](2.0/apps/bdr/INDEX.md)
|
|
37
37
|
|
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: nychh
|
|
7
7
|
type: client-feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-14
|
|
10
10
|
owners: [jcardinal, apeterson]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model/Nychh/Item.php
|
|
@@ -279,6 +279,30 @@ is exactly the shape of option **(b)** above. Left **UNRESOLVED and handed to Je
|
|
|
279
279
|
same integration/data-owner decision, and the evidence above narrows it to the override's stock-in
|
|
280
280
|
source rather than anything in the request path.
|
|
281
281
|
|
|
282
|
+
#### 2026-09-14 — PARTIALLY cleared on sandbox: 4 lines now report availability (blocker still OPEN)
|
|
283
|
+
|
|
284
|
+
Re-measured on `sandbox-client`: of **1,238** NYCHH purchase-order lines, **4** now report
|
|
285
|
+
`_qtyAvailable >= 1`. The other **1,234 still report 0.**
|
|
286
|
+
|
|
287
|
+
| `PurchaseOrderItems.uuid` | `quantity` | `_qtyAvailable` |
|
|
288
|
+
|---|---|---|
|
|
289
|
+
| `31c5871e-97da-7838-b3aa-ac8b9a21cf01` | 29 | 29 |
|
|
290
|
+
| `38e83217-759d-bf7d-8c27-940501603473` | 7 | 7 |
|
|
291
|
+
| `67039882-8aed-d68d-dcd5-cbf089b4ab6b` | 4 | 4 |
|
|
292
|
+
| `f574526e-3de1-f182-e8ba-3945ecd36765` | 100 | 100 |
|
|
293
|
+
|
|
294
|
+
On all four, availability equals the **full ordered quantity** — i.e. these are newly linked lines
|
|
295
|
+
with nothing dispatched yet, not partial recoveries. **Not verified on production.**
|
|
296
|
+
|
|
297
|
+
What this changes and what it does not:
|
|
298
|
+
|
|
299
|
+
- **Changed:** "the picker shows nothing for NYCHH" is now stale for `sandbox-client` — it shows 4.
|
|
300
|
+
Anyone testing the Create Transfer Order flow there now has real selectable rows.
|
|
301
|
+
- **NOT changed:** the underlying blocker stands. The unbackfilled
|
|
302
|
+
`InventoryAdjustmentItems.itemFulfillmentItemId` link is still NULL on ~all rows, and options
|
|
303
|
+
**(a)** and **(b)** above still need the integration / data-owner decision. Do not close this on
|
|
304
|
+
the strength of 4 rows.
|
|
305
|
+
|
|
282
306
|
### The role-3 grant migration is NOT the fix for the empty picker
|
|
283
307
|
|
|
284
308
|
`dbchanges2/Client/2026-09-01a - PurchaseOrderItemQtyFieldsApiRoleRead.sql` grants **role 3 (API)**
|
|
@@ -413,6 +437,10 @@ pattern (default a NOT NULL non-writable column in the model, never send it) is
|
|
|
413
437
|
session — and a *passing* drift check on the base `Model/Client/` file is what made it feel safe.
|
|
414
438
|
|
|
415
439
|
## Change history
|
|
440
|
+
- 2026-09-14 — **Blocker partially cleared, not resolved.** On `sandbox-client`, 4 of 1,238 NYCHH
|
|
441
|
+
PO lines now report `_qtyAvailable >= 1` (each equal to the full ordered quantity); 1,234 still
|
|
442
|
+
report 0. Production not re-checked. The `itemFulfillmentItemId` backfill decision is still open —
|
|
443
|
+
the state line changed, the blocker did not. (apeterson)
|
|
416
444
|
- 2026-09-02 — **Re-hit the `_qty*` = 0 blocker on a second line and narrowed it two steps further.**
|
|
417
445
|
Reference row `PurchaseOrderItems` uuid `715ef031-003d-6742-871d-0d20de2fa40f` (PO 80622, line 21):
|
|
418
446
|
the V2 API returns `quantity: 3270` **correctly** while `_qtyReceived`/`_qtyOnHand`/`_qtyAvailable`/
|
package/package.json
CHANGED