toga-ai 1.0.496 → 1.0.498
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/error-reporting-issue-event.md +29 -1
- package/knowledge/2.0/apps/api2/architecture.md +8 -2
- package/knowledge/2.0/apps/api2/features/request-logging.md +21 -1
- package/knowledge/2.0/standards/backend-php.md +38 -11
- package/knowledge/clients/compass-usa/features/mits-po-to-so-item-linking.md +82 -2
- package/knowledge/sessions/2026-08-03-toga25-loading-states-tcox.md +88 -0
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-03
|
|
10
10
|
owners: ["dfranks", "jcardinal"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Error.php
|
|
@@ -87,6 +87,28 @@ produced a different hash on every request — dedup never actually worked.
|
|
|
87
87
|
cannot orphan a curated Issue: an admin repoints the new fingerprint at the existing Issue
|
|
88
88
|
from the Tools console to merge them.
|
|
89
89
|
|
|
90
|
+
### Where each exception class routes (the class you throw IS the routing decision)
|
|
91
|
+
|
|
92
|
+
| Thrown class | HTTP | Sentry | `Logs.Issue` | Fingerprint / routing |
|
|
93
|
+
|---|---|---|---|---|
|
|
94
|
+
| `_Exception_Validation` | **400** | no | **no** | n/a — bad client input. Still leaves a full `Logs.Api` row (see [api2 request logging](../../api2/features/request-logging.md)) |
|
|
95
|
+
| `_Exception_Business` | **500** | yes | **yes** | stable `issueKey` (refactor-immune) → business recipients configured in the Tools console |
|
|
96
|
+
| plain global `Exception` | **500** | yes | **yes** | **trace**-based fingerprint (moves when code moves) → developer ClickUp task |
|
|
97
|
+
| `_Exception` | — | — | — | **never throw it deliberately** (below) |
|
|
98
|
+
|
|
99
|
+
Verified against `api2/Controller/Index.php` (≈L205-245, L369-377) and `_Error.php`
|
|
100
|
+
(`captureException` ≈L222-265, `buildFingerprint` ≈L455-490) on **2026-08-03**, while TRUE-78188
|
|
101
|
+
was still in flight — **re-verify against `Controller/Index.php`** rather than trusting the table
|
|
102
|
+
blindly.
|
|
103
|
+
|
|
104
|
+
**`_Exception` must never appear at a deliberate throw site.** Its constructor takes **five**
|
|
105
|
+
required args (`errorNumber`, `errorString`, `errorFile`, `errorLine`, `trace`) because it is an
|
|
106
|
+
*error-handler shape* populated by `_Error` from `set_error_handler` context — not a general
|
|
107
|
+
exception. `throw new _Exception('msg')` raises an `ArgumentCountError` **before** the intended
|
|
108
|
+
exception is ever constructed, so the real signal is destroyed. Jeff states this rationale in the
|
|
109
|
+
`Exception/Business.php` docblock. Census in `_underscore`: 173 plain `throw new Exception` vs 14
|
|
110
|
+
`_Exception`.
|
|
111
|
+
|
|
90
112
|
### `_Exception_Business` — identity immune to refactoring
|
|
91
113
|
|
|
92
114
|
`_underscore/Exception/Business.php` carries a stable `issueKey` plus a `minimumUrgency` floor.
|
|
@@ -267,6 +289,12 @@ clientUserId). **Neither was built.** As built instead:
|
|
|
267
289
|
|
|
268
290
|
## Change history
|
|
269
291
|
|
|
292
|
+
- 2026-08-03 — Added the **exception-class routing table** (`_Exception_Validation` → 400/no
|
|
293
|
+
Issue; `_Exception_Business` → 500 + `issueKey`-fingerprinted Issue to business recipients;
|
|
294
|
+
plain `Exception` → 500 + trace-fingerprinted Issue to a developer task) and recorded that
|
|
295
|
+
**`_Exception` must never be thrown deliberately** — its 5-arg error-handler constructor raises
|
|
296
|
+
`ArgumentCountError` first. Verified against `Controller/Index.php` / `_Error.php` while
|
|
297
|
+
TRUE-78188 was still in flight. (dfranks)
|
|
270
298
|
- 2026-08-01 — **Decided: `Logs` cluster tables use SINGULAR names.** Renamed all six
|
|
271
299
|
post-deployment (`Issues`→`Issue`, `Events`→`Event`, `IssueFingerprints`→`IssueFingerprint`,
|
|
272
300
|
`IssueClickupTasks`→`IssueClickupTask`, `IssueEmailAddresses`→`IssueEmailAddress`,
|
|
@@ -6,8 +6,8 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: architecture
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
10
|
-
owners: [jcardinal, bala, mhammontree]
|
|
9
|
+
updated: 2026-08-03
|
|
10
|
+
owners: [jcardinal, bala, mhammontree, dfranks]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Controller/Index.php
|
|
13
13
|
- api2/Component/Api/V2/V2.php
|
|
@@ -102,6 +102,12 @@ One ~2,000-line `execute()` then `processRoutePairs()`:
|
|
|
102
102
|
`public`, `delegator`/`encrypted`. JWTs HS256, `sub` access/refresh (access 3600s,
|
|
103
103
|
refresh 30d); signing secret **rotated** with current+previous accepted across the
|
|
104
104
|
boundary. The JWT `id` claim carries the full identity used for ACL.
|
|
105
|
+
**`/v2/auth/oauth` credential transport:** OAuth 2.0 `client_credentials`. `client_id` +
|
|
106
|
+
`client_secret` may arrive as **HTTP Basic** *or* as POST body fields — **Basic takes
|
|
107
|
+
priority** when both are present. `scope` is **required**; `grant_type` is **optional** and
|
|
108
|
+
defaults to `client_credentials` (accepted: `client_credentials`, `password`,
|
|
109
|
+
`refresh_token`). The call mints a JWT; subsequent requests carry `Authorization: Bearer`.
|
|
110
|
+
(`api2/Component/Api/V2/V2.php` L414-460.)
|
|
105
111
|
4. **Response envelope** — `{transactionId, timestamp, authority, audience, isSuccess,
|
|
106
112
|
status, error, messages[], meta{}, data{}}`; `isSuccess` = 2xx status.
|
|
107
113
|
5. **Transaction logging** — every request logged (to client/core Logs DB, or as a JSONL
|
|
@@ -6,7 +6,7 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-08-03
|
|
10
10
|
owners: ["mhammontree", "dfranks"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
@@ -46,6 +46,21 @@ In `api2/Component/Api/V2/V2.php` (~L2168) every request is logged and routed:
|
|
|
46
46
|
- There is also a **config-gated branch** (~L2166): when `_Config::api('log_filepath')` is set,
|
|
47
47
|
the log entry is written to a **file** on the host instead of the DB.
|
|
48
48
|
|
|
49
|
+
### A rejected request (HTTP 400) is NOT unlogged
|
|
50
|
+
|
|
51
|
+
`api2/Controller/Index.php` (L369-377) handles a non-success response by **committing** the
|
|
52
|
+
`DB_LOGS` and `DB_CLIENT_LOGS` transactions and rolling back **only** the business-data
|
|
53
|
+
transaction. A rejection therefore still writes a **full `Client_Logs.Api` / `Core_Logs.Api`
|
|
54
|
+
row** — request payload, response payload, `apiId`, `sourceIp`, `transactionId` — while the
|
|
55
|
+
business row is discarded.
|
|
56
|
+
|
|
57
|
+
So a 400 **is** SQL-queryable and attributable to the calling credential. What a 400 lacks is a
|
|
58
|
+
`Logs.Issue` row: **no alerting, no aggregation, no owner** (see
|
|
59
|
+
[Error Reporting — Issue/Event Capture](../../_underscore/features/error-reporting-issue-event.md)).
|
|
60
|
+
That is the deciding factor when choosing between `_Exception_Validation` (400, logged as a
|
|
61
|
+
request, no Issue) and `_Exception_Business` (500, Sentry + a routed Issue). Do **not** reach for
|
|
62
|
+
`_Exception_Business` merely to "make sure the rejection is recorded" — it already is.
|
|
63
|
+
|
|
49
64
|
## Blind spots when auditing a client's API traffic
|
|
50
65
|
|
|
51
66
|
Looking only at `Logs_<Client>.Api` misses four sources:
|
|
@@ -95,6 +110,11 @@ re-send.
|
|
|
95
110
|
|
|
96
111
|
## Change history
|
|
97
112
|
|
|
113
|
+
- 2026-08-03 — Corrected a common wrong assumption: an **HTTP 400 leaves a full log row**.
|
|
114
|
+
`Controller/Index.php` L369-377 commits `DB_LOGS`/`DB_CLIENT_LOGS` and rolls back only the
|
|
115
|
+
business transaction, so rejections are queryable and attributable to the credential; what they
|
|
116
|
+
lack is a `Logs.Issue` (no alerting/owner) — the deciding factor between `_Exception_Validation`
|
|
117
|
+
and `_Exception_Business`. (dfranks)
|
|
98
118
|
- 2026-07-29 — Documented (TRUE-80179) that the core/writer `Logs.Api` retains the **full original
|
|
99
119
|
`requestPayload`** on failed writes, making it a **replayable backlog source** and a scope
|
|
100
120
|
diagnostic: dedupe to one-per-contract with a window function on
|
|
@@ -5,8 +5,8 @@ project: _Underscore
|
|
|
5
5
|
client: shared
|
|
6
6
|
type: standard
|
|
7
7
|
status: active
|
|
8
|
-
updated: 2026-08-
|
|
9
|
-
owners: [jcardinal, mhammontree]
|
|
8
|
+
updated: 2026-08-03
|
|
9
|
+
owners: [jcardinal, mhammontree, dfranks]
|
|
10
10
|
files: []
|
|
11
11
|
related:
|
|
12
12
|
- ../apps/_underscore/architecture.md
|
|
@@ -573,15 +573,42 @@ $c = 'Hello ' . $world;
|
|
|
573
573
|
|
|
574
574
|
* Always use exceptions to handle unexpected conditions.
|
|
575
575
|
|
|
576
|
-
###
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
576
|
+
### Choosing an exception class is a ROUTING decision, not style
|
|
577
|
+
|
|
578
|
+
At a deliberate throw site the class you pick determines the HTTP status, whether Sentry sees
|
|
579
|
+
it, whether a `Logs.Issue` is created, how that Issue is fingerprinted, and **who gets
|
|
580
|
+
notified**. Pick deliberately:
|
|
581
|
+
|
|
582
|
+
| Throw this | When | HTTP | Sentry | `Logs.Issue` | Goes to |
|
|
583
|
+
|---|---|---|---|---|---|
|
|
584
|
+
| `_Exception_Validation` | bad **client input** — a hard-block in a pre/post interceptor or model op | **400** | no | no | the caller, via the response envelope (front end can toast it) |
|
|
585
|
+
| `_Exception_Business` | a **business condition a business user can act on and a developer cannot fix** | 500 | yes | yes, keyed by a stable `issueKey` | business recipients configured in the Tools `/errors` console |
|
|
586
|
+
| plain global `Exception` | a genuine **code defect / unexpected state** | 500 | yes | yes, fingerprinted by **trace** | a developer ClickUp task |
|
|
587
|
+
|
|
588
|
+
`_Exception_Business`'s constructor is `(issueKey, message, minimumUrgency)`. The `issueKey` is
|
|
589
|
+
the fingerprint, so the Issue's identity survives refactoring; a plain `Exception`'s
|
|
590
|
+
trace-based fingerprint changes when the code moves.
|
|
591
|
+
|
|
592
|
+
**A 400 is not "unlogged."** `api2/Controller/Index.php` commits the log transactions and rolls
|
|
593
|
+
back only the business transaction, so a rejection still writes a full `Logs.Api` row (request
|
|
594
|
+
+ response payload, `apiId`, `sourceIp`, `transactionId`). What a 400 lacks is a `Logs.Issue` —
|
|
595
|
+
no alerting, no aggregation, no owner. Do not upgrade a client-input rejection to
|
|
596
|
+
`_Exception_Business` just to "make sure it's recorded."
|
|
597
|
+
|
|
598
|
+
### NEVER `throw new _Exception(...)`
|
|
599
|
+
|
|
600
|
+
`_Exception` is an **error-handler shape**, not a general exception: its constructor requires
|
|
601
|
+
five arguments (`errorNumber`, `errorString`, `errorFile`, `errorLine`, `trace`) because
|
|
602
|
+
`_Error` populates it from `set_error_handler` context. `throw new _Exception('msg')` raises an
|
|
603
|
+
`ArgumentCountError` **before** your exception is ever constructed, destroying the real signal.
|
|
604
|
+
Throwing a plain `_Exception` also yields an HTTP 500 + stack trace — wrong for a client-input
|
|
605
|
+
rejection and an information leak. Census in `_underscore`: 173 plain `throw new Exception` vs
|
|
606
|
+
14 `_Exception`.
|
|
607
|
+
|
|
608
|
+
Sources: `_underscore/Exception.php`, `Exception/Validation.php`, `Exception/Business.php`,
|
|
609
|
+
`_underscore/Error.php`, `api2/Controller/Index.php`. First applied: the three Rate WH
|
|
610
|
+
hard-blocks (TRUE-79533). Taxonomy verified 2026-08-03 **while TRUE-78188 was still in flight** —
|
|
611
|
+
re-verify against `Controller/Index.php` before relying on it.
|
|
585
612
|
|
|
586
613
|
### Comprehensive Error Handling
|
|
587
614
|
|
|
@@ -6,8 +6,8 @@ project: _Underscore
|
|
|
6
6
|
client: compass-usa
|
|
7
7
|
type: client-feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
10
|
-
owners: ["jcardinal", "rgirish"]
|
|
9
|
+
updated: 2026-08-03
|
|
10
|
+
owners: ["jcardinal", "rgirish", "dfranks"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model/Compass/PurchaseOrder.php
|
|
13
13
|
- worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php
|
|
@@ -29,6 +29,56 @@ production data-integrity bug.
|
|
|
29
29
|
- USA/Canada subclasses (`Model/Compass/Usa|Canada/PurchaseOrder.php`) are empty and inherit
|
|
30
30
|
this behavior — edit the parent.
|
|
31
31
|
|
|
32
|
+
## Who actually calls `POST /v2/purchase-orders` (MITS is NOT the only caller)
|
|
33
|
+
|
|
34
|
+
`/v2/purchase-orders` for Compass is a **shared endpoint with two unrelated inbound channels**,
|
|
35
|
+
distinguished in `Core.ClientApiIdentities` (clientId 2). **The channels are told apart by the
|
|
36
|
+
FIELD NAME, not by whether a MITS SO is present at all:**
|
|
37
|
+
|
|
38
|
+
| Identity | Channel | SO field sent | Values | `purchaseOrderItems` |
|
|
39
|
+
|---|---|---|---|---|
|
|
40
|
+
| `153531108` | MITS | **`mitsSalesOrder`** (unprefixed) | only `SA…` or `MR…` | always present |
|
|
41
|
+
| `COMPASS-CXML` | cXML / EDI | **`c_mitsSalesOrder`** (`c_`-prefixed) | numeric, e.g. `472981379001` | present |
|
|
42
|
+
| `COMPASS-CXML` | cXML / EDI | **neither field** | — | often **absent entirely** |
|
|
43
|
+
|
|
44
|
+
Measured over 60 days of inbound `POST /v2/purchase-orders` (`Logs_Compass.Api`):
|
|
45
|
+
- **MITS — 4,547 posts, 100% carry the unprefixed `mitsSalesOrder`**; `SA` 2,928, `MR` 1,619.
|
|
46
|
+
Never `MA`, never any other prefix. **No `SA` value has ever been sent by a non-MITS caller.**
|
|
47
|
+
- **COMPASS-CXML — 2,697 posts**: 2,455 carry `c_mitsSalesOrder`, and **242 carry neither field**.
|
|
48
|
+
In a 30-day slice: 1,508 `c_mitsSalesOrder` posts all had items; **136 posts had neither field
|
|
49
|
+
and no `purchaseOrderItems` key** — the legitimate header-first EDI POs.
|
|
50
|
+
- Compass **Canada** posts under a **separate identity (`409531`)**, so never hardcode an
|
|
51
|
+
identity id.
|
|
52
|
+
|
|
53
|
+
**Consequences for any validation added to this endpoint:**
|
|
54
|
+
- **The unprefixed `mitsSalesOrder` is the MITS marker** — not the identity id, and not the
|
|
55
|
+
`SA`/`MR` prefix. Supporting evidence: `prePost` reads `$payload->mitsSalesOrder`; the only
|
|
56
|
+
`c_mitsSalesOrder` occurrence in the codebase is the model field declaration at
|
|
57
|
+
`_underscore/Model/Compass/PurchaseOrder.php:18`; and `Core.ApiPayloadInterceptors` has **no
|
|
58
|
+
rows for `recordId = 17`** (the `purchase-orders` record), so nothing rewrites field names in
|
|
59
|
+
flight.
|
|
60
|
+
- A "reject purchase orders with zero line items" rule keyed on the **unprefixed** field cannot
|
|
61
|
+
touch the 136 item-less EDI posts — they carry neither field. Gating on **item count alone**
|
|
62
|
+
would reject all of them.
|
|
63
|
+
- **`MA` orders never arrive from MITS.** Compass creates them manually; they go to ODP, reach us
|
|
64
|
+
over EDI, and land on an exception report for manual NetSuite entry. (An earlier belief that MA
|
|
65
|
+
is a third MITS prefix to filter on is wrong.)
|
|
66
|
+
|
|
67
|
+
> **⚠ Querying trap — a substring match on `mitsSalesOrder` conflates the two callers**, because
|
|
68
|
+
> `c_mitsSalesOrder` *contains* it. Anyone characterising this traffic from `Logs_Compass.Api`
|
|
69
|
+
> must match the **exact key**; a pattern like `"mitsSalesOrder":"` also silently misses the
|
|
70
|
+
> prefixed form, which is how this session first reached the wrong conclusion that cXML never
|
|
71
|
+
> sends an SO reference. Second trap: payloads appear **both minified**
|
|
72
|
+
> (`"mitsSalesOrder":"MR…"`) **and pretty-printed** (`"mitsSalesOrder": "MR…"`, space after the
|
|
73
|
+
> colon), so a naive pattern under-counts a second way.
|
|
74
|
+
|
|
75
|
+
> **OPEN / UNVERIFIED:** it is **not** confirmed whether the 2.0 framework strips the `c_` prefix
|
|
76
|
+
> when populating `$payload` for an interceptor. If it does, cXML requests carrying
|
|
77
|
+
> `c_mitsSalesOrder` would also enter a guard keyed on `$payload->mitsSalesOrder`. Harmless while
|
|
78
|
+
> those posts all carry items, but a future item-less cXML post carrying `c_mitsSalesOrder` would
|
|
79
|
+
> then be wrongly rejected. This **cannot be settled from logs** — it needs a dev-environment
|
|
80
|
+
> check.
|
|
81
|
+
|
|
32
82
|
## How it works
|
|
33
83
|
- **SA orders (normal):** each inbound PO item carries `createdFromSalesOrderItem.lineNumber`
|
|
34
84
|
(the MITS line, which includes configuration-line offsets). `prePost` remaps it to the real
|
|
@@ -50,6 +100,17 @@ production data-integrity bug.
|
|
|
50
100
|
because SA/MR originate in Compass (their Compass SO+items pre-exist) whereas MA is
|
|
51
101
|
ODP-originated, so nothing else would populate it.
|
|
52
102
|
|
|
103
|
+
### MR and SA consume PO line items differently (asymmetry inside one method)
|
|
104
|
+
- **MR** — `handleMrOrder` (≈L54-57) **silently skips** any item whose
|
|
105
|
+
`vendorItem->item->partNumber` is empty while building `salesOrderItems`. So an MR PO whose
|
|
106
|
+
lines *all* lack a `partNumber` passes a naive `count($payload->purchaseOrderItems) > 0` check
|
|
107
|
+
and still creates an **empty Sales Order**. A "usable item" filter for MR must therefore test
|
|
108
|
+
`partNumber`, not array length.
|
|
109
|
+
- **SA** — items are linked via `createdFromSalesOrderItem` →
|
|
110
|
+
`salesOrderItemPurchaseOrderItems`, and the quantity cross-check (≈L318-324) explicitly
|
|
111
|
+
`continue`s past `partNumber`-less lines rather than discarding them. Applying the MR
|
|
112
|
+
`partNumber` filter to SA would **reject valid orders**.
|
|
113
|
+
|
|
53
114
|
## Data model
|
|
54
115
|
- `SalesOrderItems_PurchaseOrderItems` (`Client_Compass`): `uuid`, `salesOrderItemId`,
|
|
55
116
|
`purchaseOrderItemId`. One row per (SO item, PO item) the PO fulfills.
|
|
@@ -59,6 +120,15 @@ production data-integrity bug.
|
|
|
59
120
|
USA and Canada share the parent handler unchanged.
|
|
60
121
|
|
|
61
122
|
## Gotchas / known issues
|
|
123
|
+
- **SQL injection in `prePost` (OPEN as of 2026-08-03).** `_underscore/Model/Compass/PurchaseOrder.php`
|
|
124
|
+
**L232** interpolates the raw client payload straight into SQL:
|
|
125
|
+
`WHERE SalesOrders.number LIKE '{$payload->mitsSalesOrder}'` — no escape, no cast. The **same
|
|
126
|
+
value is escaped at L301**, so the file is internally inconsistent and the correct pattern is
|
|
127
|
+
already present. Fix with `_Database::escape()` per the 2.0 back-end standard. Reachable only by
|
|
128
|
+
a caller holding valid Compass API credentials (OAuth2 `client_credentials`), so it is **not**
|
|
129
|
+
anonymous or internet-facing — normal priority, not an emergency. Separately, five sites in this
|
|
130
|
+
file (L148, L301, L461, L462, L471) use **`addslashes()`** where the standard requires
|
|
131
|
+
`_Database::escape()`.
|
|
62
132
|
- **Spurious cross-part links from the `postPost` lineNumber join (fixed 2026-06-11).** The
|
|
63
133
|
`postPost` link pass matched `SalesOrderItems.lineNumber = PurchaseOrderItems.lineNumber`
|
|
64
134
|
for *all* orders. For SA orders that is wrong: PO line numbers are PO-local, so whenever an
|
|
@@ -137,6 +207,16 @@ USA and Canada share the parent handler unchanged.
|
|
|
137
207
|
need item backfill from the sibling ODP SO first).
|
|
138
208
|
|
|
139
209
|
## Change history
|
|
210
|
+
- 2026-08-03 — Documented that `/v2/purchase-orders` has **two** Compass inbound channels (MITS
|
|
211
|
+
`153531108` vs `COMPASS-CXML`; Canada is a third identity `409531`), told apart by **field
|
|
212
|
+
name**: unprefixed `mitsSalesOrder` = MITS, `c_mitsSalesOrder` = cXML, neither = the item-less
|
|
213
|
+
header-first EDI POs. So a zero-item rejection keyed on the unprefixed field is safe, while
|
|
214
|
+
gating on item count alone is not. Recorded the substring/pretty-print querying traps that
|
|
215
|
+
conflate the two callers, and left open whether the framework strips the `c_` prefix for
|
|
216
|
+
interceptor payloads. Also recorded the MR-vs-SA `partNumber` item-consumption
|
|
217
|
+
asymmetry (MR silently skips `partNumber`-less lines → empty SO despite `count() > 0`) and an
|
|
218
|
+
open SQL-injection at L232 plus five `addslashes()` sites. Corrects the earlier belief that
|
|
219
|
+
`MA` arrives from MITS. (dfranks)
|
|
140
220
|
- 2026-07-13 — Fixed empty MA orders: the 3a ODP-PO-import cron's new-SO branch now builds
|
|
141
221
|
`salesOrderItems` from the 850 PO items (the MA back-linking block excludes MA by design).
|
|
142
222
|
Forward-only; 192 historical empties still need item backfill. (rgirish)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: session
|
|
3
|
+
slug: toga25-loading-states
|
|
4
|
+
title: Design-mockup loading states for toga25-supply tables and modals
|
|
5
|
+
author: tcox
|
|
6
|
+
repos: [toga25-supply, toga-blox-npm]
|
|
7
|
+
framework: "2.0"
|
|
8
|
+
client: shared
|
|
9
|
+
status: active
|
|
10
|
+
created: 2026-08-03
|
|
11
|
+
updated: 2026-08-03
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Session: toga25-loading-states
|
|
15
|
+
**Date:** 2026-08-03
|
|
16
|
+
**Project/Repo:** toga25-supply + toga-blox-npm (2.0)
|
|
17
|
+
**Task:** Implement the "Toga25-supply mockup" design's loading states — table skeletons (initial/refresh/filter), pagination loading, Refresh busy pill, bulk progress toast, and modal skeletons (cold/warm/sub-section) — pixel-faithful to the mockup, in toga25-supply with supporting toga-blox changes.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## What WORKED
|
|
22
|
+
- **TableSkeleton built on the real table's CSS module** — `src/components/TableSkeleton/TableSkeleton.tsx` imports `@agilant/toga-blox/dist/components/Table/themeConfig/toga.module.css` (the exact module the blox PrimaryTable is styled by), so skeleton and loaded table are pixel-identical by construction. Real header labels + static sort glyphs from table meta, type-aware cells (status dot, avatar circle, image tile + label bar, badge pill), real pagination chrome. **User visually approved.** Wired into `PrimaryTableServerLayout` replacing blox `TableShimmer`.
|
|
23
|
+
- **Shimmer texture matched to design** — `Shimmer.module.css` overlay rewritten to the mockup gradient: 100deg, transparent→38%, `#F7F8FB` at 50% (strong: `#F0F2F6`), →62% transparent, 100vw-wide band, 2s linear. Fixes every skeleton app-wide (modals included).
|
|
24
|
+
- **Pagination loading** — blox `TablePagination` gained `loadingPage?: number | null` (threaded through `PrimaryTable` + server template + both types); shows 12px spinner + `Loading page N…` (`.paginationLoadingText`, #72757D/14px) in place of the count; `TableBody` dims to 0.55 (`.bodyDimmed`) while a page fetch is in flight; controls stay full-strength (pointer-events blocked only). App wrapper detects page fetch via new `page` prop vs `paginationMeta.page` (`isPageFetch`) — refresh/filter/sort still show the full skeleton (per design), page changes keep rows.
|
|
25
|
+
- **Refresh busy pill** — all 5 pages (SalesOrders, Items, Bundles, VendorItems, Inventory): icon spins 1.1s in fixed 20px box (`.refreshIconBox` in `src/index.css`), label flips to `Refreshing…`; `useIsFetching({queryKey:["table-data"], predicate: q => q.state.data !== undefined})` excludes cold loads.
|
|
26
|
+
- **Fidelity audit (agent) against mockup source** confirmed shimmer/spinner/copy exact and produced 14 fixes, all applied: no columnBorders on skeleton (loaded table has none), rows 16, sort glyph placeholder, avatar/image/badge shapes, active-filter chip stays in skeleton header (`filteredColumnSlugs` from `columnFilters`), bulk toast track 14px + `background-attachment: fixed` + cancel spacing.
|
|
27
|
+
- **Modal skeletons (sales-order modal)** — cold = existing `SalesOrderModalSkeleton` (header shimmer); warm = new `warmHeader` prop renders real `SalesOrderTopBar` from the clicked row while body shimmers; row context plumbed as optional `row` on both wrappers' `renderModal` (server + client), passed as `rowRecord` from SalesOrders.tsx. Sub-section refresh = `SubSectionShimmer` (3×36px r6 bars) in Invoices/Shipments sections while their independent queries (`useInvoices`/`useShipments`) load — sections keep frame+title, no longer pop in late; `getDetailSections` show-gates include loading.
|
|
28
|
+
- **CodeRabbit fixes** — client wrapper's `renderModal` moved OUTSIDE the loading gate (open modal no longer unmounts on table refetch); shared `TableRowRecord = { uuid: string } & Record<string, unknown>` exported from `PrimaryTableServerLayout/types.ts`, reused in both wrappers + modal, `displayRecords` tightened to it (killed 2 pre-existing no-explicit-any errors).
|
|
29
|
+
- **Components added**: `BulkProgressToast` (design-exact, unwired — no bulk feature exists), `SubSectionShimmer`; barrel exports updated.
|
|
30
|
+
- **Client testing infra** — hosts entries added via UAC-elevated PowerShell (`Start-Process powershell -Verb RunAs`) for `compasscanada.dev.sandbox.togasupply` and `nychh.dev.sandbox.togasupply`; NYCHH is the inventory client (only one with dedicated `NYCHH_INVENTORY_GROUPINGS`). All dev servers closed at session end.
|
|
31
|
+
- Every round verified with `npx tsc --noEmit` + eslint (touched files) + `npm run build` — all clean at session end.
|
|
32
|
+
|
|
33
|
+
## What did NOT work — DO NOT RETRY THESE
|
|
34
|
+
- **Hand-rolled skeleton from mockup geometry + big-bang parallel implementation.** First attempt (3 parallel agents: components + table wiring + modal wiring incl. error states) rendered a skeleton with no card border, no header labels, equal-width bars, bright white glint — user: "further off than we started" and **reverted everything** (`git checkout` + clean). Do not rebuild loading UI from the mockup's px values directly; mirror the app's real rendered table (shared CSS module) and ship ONE state at a time for visual sign-off.
|
|
35
|
+
- **`background-attachment: fixed` for the unison shimmer inside modals** — breaks inside the record modal's transform (treated as local). Use the per-element 100vw translateX overlay with the same gradient instead (deliberate, documented in `Shimmer.module.css`). It IS fine for `BulkProgressToast`'s progress fill (not inside a transform).
|
|
36
|
+
- **Rebuilding toga-blox while dev servers run** — `prebuild rm -rf dist` fails with "Directory not empty" (Windows file locks from Vite dep-optimizer). Stop all vite servers first, then `cd /c/WWW/toga-blox-npm && npm run build`, then restart (and `rm -rf node_modules/.vite` in the app if the browser shows stale-dep errors).
|
|
37
|
+
- **Starting a client dev server without its hosts entry** — vite resolves `--host` via DNS at startup: `Error: getaddrinfo ENOTFOUND compasscanada.dev.sandbox.togasupply`. The hosts entry must exist BEFORE `npm run <client>`. Binding 127.0.0.1 instead is NOT a workaround: the client slug comes from the first hostname segment at login.
|
|
38
|
+
- **Background Bash tasks inherit `c:\WWW` cwd, not the repo** — `npm run compasscanada` failed "Missing script"; always `cd /c/WWW/toga25-supply && npm run …` in background commands. Foreground shell cwd also resets between some calls.
|
|
39
|
+
- **`TaskStop` on an `npm run` vite server orphans the node child** holding the port — after stopping, kill listeners: `netstat -ano | grep LISTENING | grep ":517"` → `taskkill //F //PID <pid>`.
|
|
40
|
+
- **Blanket sed for `page,` insertion** hit the wrong block in `useItemFulfillmentModalViewModel.tsx` (duplicated `page` inside a destructure = syntax error). Targeted Edit with unique context fixed it.
|
|
41
|
+
- **`useIsFetching` without a predicate** made Refresh show "Refreshing…" during cold loads (design shows it only for refetches of on-screen data).
|
|
42
|
+
|
|
43
|
+
## Not tried yet (candidates for next session)
|
|
44
|
+
- **Table error states** (server/timeout/empty/no-results/permission/maintenance/ratelimit per mockup ErrorBlock) — spec extracted at scratchpad `table-states-spec.md` §2.12-2.14/§4; first attempt reverted; needs the app-side error surface (`useTableData` discards query errors — an app hook subscribing to the query cache by `["table-data", slug]` prefix worked in the reverted round and can be reused conceptually).
|
|
45
|
+
- **Modal error states** (generic/not-found/permission/conflict per `modal-states-spec.md` §§7-10) — `src/components/RecordLoadError` exists (unused, generic copy matches mockup); six placeholder `<>Error</>` sites mapped in the reverted round's integration map.
|
|
46
|
+
- **Warm open for Item/VendorItem/Bundle record modals** — same two-step as sales-order: accept `rowRecord`, hand real header to skeleton via `warmHeader`-style prop.
|
|
47
|
+
- **TableSkeleton for `PrimaryTableClientLayout`** (modal-embedded tables still use blox `TableShimmer`; also its skin is `supply-modal-table`, different tokens).
|
|
48
|
+
- **Bulk feature wiring** — `BulkProgressToast` has zero consumers; per-row processing scrim (100deg `rgba(63,93,202,0.06)` at 50%) not implemented; app has no bulk selection at all.
|
|
49
|
+
- **Class-B inherited chrome differences (USER DECISION PENDING)** — app table vs mockup: header ~38px/14px vs 34px/13px, rows ~40px vs 48px, cell padding 25px vs 12px, separators #D4D5D8 vs #E8E9EA, "per page" vs "rows shown", filter chip #4f6bd4/r2 vs #3F5DCA/r5 (looks like a blox bug), card shadow. Fixing = restyling the live table in toga-blox.
|
|
50
|
+
- **blox bug**: `.tableAndPaginationInner.supply` sets `font-size: var(--primaryTable-pagination-text-default)` which resolves to a COLOR (#8C8E96) — declaration silently dropped, text inherits ~16px.
|
|
51
|
+
|
|
52
|
+
## Current file state
|
|
53
|
+
| File | Status | Notes |
|
|
54
|
+
|------|--------|-------|
|
|
55
|
+
| toga25-supply `src/components/TableSkeleton/` | new | CSS-module-mirroring table skeleton (approved) |
|
|
56
|
+
| toga25-supply `src/components/BulkProgressToast/` | new | design-exact, unwired |
|
|
57
|
+
| toga25-supply `src/components/SubSectionShimmer/` | new | 3×36px bars sub-section well |
|
|
58
|
+
| toga25-supply `src/components/Shimmer/Shimmer.module.css` | modified | 100deg subtle band per design |
|
|
59
|
+
| toga25-supply `src/components/index.ts` | modified | new barrel exports |
|
|
60
|
+
| toga25-supply `src/layout/PrimaryTableServerLayout/{.tsx,types.ts}` | modified | TableSkeleton gate, `page`/isPageFetch, `loadingPage`, `filteredColumnSlugs`, `renderModal.row`, `TableRowRecord` |
|
|
61
|
+
| toga25-supply `src/layout/PrimaryTableClientLayout/{.tsx,types.ts}` | modified | `renderModal` outside gate + row context; still blox TableShimmer for loading |
|
|
62
|
+
| toga25-supply 6 table layouts + 2 VMs | modified | pass `page` through (SalesOrders/Items/Bundles/VendorItems/Generic/ItemFulfillment) |
|
|
63
|
+
| toga25-supply 5 pages | modified | Refresh busy pill + predicate |
|
|
64
|
+
| toga25-supply `src/index.css` | modified | `.refreshIconBox` + 1.1s spin keyframes |
|
|
65
|
+
| toga25-supply SalesOrderRecordModalLayout + view/VM/sections/skeleton/helpers | modified | warm/cold/sub-section modal loading states |
|
|
66
|
+
| toga-blox-npm (branch `feature-new-table`) `TablePagination.tsx` + `types.ts` | modified | loadingPage indicator, full-strength controls |
|
|
67
|
+
| toga-blox-npm `PrimaryTable.tsx` + `Table/types.ts` | modified | loadingPage threading, bodyDimmed on TableBody |
|
|
68
|
+
| toga-blox-npm `toga.module.css` | modified | `.paginationSpinner`, `.paginationLoadingText`, `.bodyDimmed` |
|
|
69
|
+
| toga-blox-npm `dist/` | rebuilt | current with all source changes |
|
|
70
|
+
| **Both repos: all changes UNCOMMITTED** (toga25-supply on `phase-two`) | — | user decides when/how to land |
|
|
71
|
+
|
|
72
|
+
## Decisions made
|
|
73
|
+
- **Skeletons mirror the real rendered app table, not the mockup's raw geometry** — share the blox CSS module so load→loaded never shifts; the mockup's own px values only apply where the app matches them. Chosen after the hand-rolled attempt was reverted. Rejected: patching blox `TableShimmer` (8 hardcoded columns, Tailwind-JIT-fragile).
|
|
74
|
+
- **One state at a time with user visual sign-off** — replaced the 3-slice parallel big-bang that got reverted.
|
|
75
|
+
- **Per-element translateX shimmer overlay** (viewport-anchored gradient breaks in transformed modals); exact mockup gradient stops preserved.
|
|
76
|
+
- **Page-fetch detection at the wrapper** via requested `page` vs `paginationMeta.page` — no blox useTableData changes needed.
|
|
77
|
+
- **Loading-state-only scope** — explicitly did NOT change Refresh button idle chrome (`secondaryActionAlt` stays, though mockup pill is blue), nor any class-B live-table styling; user instruction.
|
|
78
|
+
- **`TableRowRecord` typed with `unknown` not `any`**; alias lives in the server layout types (the "table-layout contract"), client types re-export.
|
|
79
|
+
- **Modal-embedded client tables render bare when empty** (no onboarding panel) — empty is normal there.
|
|
80
|
+
|
|
81
|
+
## Blockers
|
|
82
|
+
none — pending items are user decisions (class-B chrome; when to commit) and the next feature slices.
|
|
83
|
+
|
|
84
|
+
## Exact next step
|
|
85
|
+
> With DevTools throttling on, visually verify the three modal skeleton states in the sales-order modal (click a row = warm header; reload with the uuid in the URL = cold; watch Invoices/Shipments shimmer wells), then replicate the warm pattern to ItemRecordModalLayout, VendorItemRecordModalLayout, and BundleRecordModalLayout, and start the table error states from `table-states-spec.md` (scratchpad specs may be gone — re-extract from `Toga25-supply mockup/src/inventory-table.jsx` §ErrorBlock if so).
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
_Saved by /session-save on 2026-08-03_
|
package/package.json
CHANGED