toga-ai 1.0.162 → 1.0.164
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/1.0/apps/library/INDEX.md +1 -0
- package/knowledge/1.0/apps/library/features/toga2-api-client-and-bridge.md +173 -0
- package/knowledge/1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md +4 -0
- package/knowledge/2.0/apps/toga25-supply/INDEX.md +9 -0
- package/knowledge/2.0/apps/toga25-supply/architecture.md +115 -0
- package/knowledge/2.0/apps/toga25-supply/features/client-configurable-fields.md +88 -0
- package/knowledge/2.0/apps/toga25-supply/features/column-visibility.md +98 -0
- package/knowledge/2.0/apps/toga25-supply/features/meta-driven-table-data.md +176 -0
- package/knowledge/2.0/apps/toga25-supply/features/record-modals-and-nested-tables.md +147 -0
- package/knowledge/INDEX.md +2 -0
- package/knowledge/clients/wje/INDEX.md +5 -0
- package/knowledge/clients/wje/profile.md +30 -0
- package/knowledge/registry.json +8 -3
- package/package.json +1 -1
|
@@ -9,3 +9,4 @@
|
|
|
9
9
|
| [NetSuite SuiteQL/REST API Reference](features/netsuite-suiteql-api-reference.md) | General working reference for the Agilant NetSuite integration: how to authenticate, how SuiteQL behaves, and the confirmed schema of the tables/columns/codes w | library/app/api/netsuite/rest.php, library/ssl/netsuite_ec_key.pem |
|
|
10
10
|
| [NetSuite SuiteQL/REST Shim — Field Semantics](features/netsuite-suiteql-rest-shim.md) | `App_Api_Netsuite_Rest` is the REST/SuiteQL replacement for the deprecated NetSuite SOAP toolkit. | library/app/api/netsuite/rest.php |
|
|
11
11
|
| [Startech PC Matic B2B Sync (library)](features/startech-pcmaticb2b-sync.md) | `library/app/api/toga2.php` handles bidirectional ticket sync for PC Matic B2B between TOGaDesk 1.0 and TOGA 2.0. | library/app/api/toga2.php |
|
|
12
|
+
| [App_Api_Toga2 — TOGa2 API Client & 1.0↔2.0 Sync Bridge](features/toga2-api-client-and-bridge.md) | `App_Api_Toga2` (`library/app/api/toga2.php`, ~8400 lines) is the **1.0-side client for the TOGa 2 (`_underscore`/api2) public API** *and* the home of the cross | library/app/api/toga2.php, worker/crons/toga2/aig/sync_togasupply_aig.php, worker/crons/toga2/wje/sync_togasupply_wje.php |
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: App_Api_Toga2 — TOGa2 API Client & 1.0↔2.0 Sync Bridge
|
|
3
|
+
framework: "1.0"
|
|
4
|
+
repo: library
|
|
5
|
+
project: Library
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-23
|
|
10
|
+
owners: [jcardinal]
|
|
11
|
+
files:
|
|
12
|
+
- library/app/api/toga2.php
|
|
13
|
+
- worker/crons/toga2/aig/sync_togasupply_aig.php
|
|
14
|
+
- worker/crons/toga2/wje/sync_togasupply_wje.php
|
|
15
|
+
related:
|
|
16
|
+
- netsuite-suiteql-api-reference.md
|
|
17
|
+
- netsuite-suiteql-rest-shim.md
|
|
18
|
+
- ../../worker/features/netsuite-togasupply-per-client-sync.md
|
|
19
|
+
- ../architecture.md
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Summary
|
|
23
|
+
|
|
24
|
+
`App_Api_Toga2` (`library/app/api/toga2.php`, ~8400 lines) is the **1.0-side client for the
|
|
25
|
+
TOGa 2 (`_underscore`/api2) public API** *and* the home of the cross-framework sync routines
|
|
26
|
+
that move data between the legacy 1.0 stack (TOGa, TOGaDesk) and 2.0 (TOGa Supply, TOGa Desk
|
|
27
|
+
2.0). It plays two roles:
|
|
28
|
+
|
|
29
|
+
1. **Transport** — `send()`/`authenticate()`/`sendUsingAccessToken()` plus an options DSL
|
|
30
|
+
(`fields`/`where`/`join`/`sort`) that lets 1.0 worker crons call the 2.0 REST API. This is
|
|
31
|
+
the same transport the NetSuite→TOGa Supply importer uses (see the per-client-sync doc).
|
|
32
|
+
2. **Bridge sync routines** — `syncWithToga()` (2.0 → 1.0 TOGa) and `syncWithTogadesk()` (2.0 ⇄
|
|
33
|
+
1.0 TOGaDesk), invoked by thin per-client crons under `worker/crons/toga2/<client>/`.
|
|
34
|
+
|
|
35
|
+
The `*FromNetsuite()` methods (`syncSalesOrderFromNetsuite`, etc.) also live in this class but
|
|
36
|
+
belong to the NetSuite importer — documented in the per-client-sync doc, not here.
|
|
37
|
+
|
|
38
|
+
## Key files / entry points
|
|
39
|
+
|
|
40
|
+
- **`library/app/api/toga2.php`**
|
|
41
|
+
- **Transport:** `authenticate($uuidClient,$uuidApi,$apiSecret,$endpoint=null)`,
|
|
42
|
+
`send($uuidClient,$uuidApi,$apiSecret,$method,$route,$payload,$options=[],$throwOnError=true,$endpointOverride=null)`,
|
|
43
|
+
`sendUsingAccessToken(...)`, `authenticateUser(...)`.
|
|
44
|
+
- **Options DSL builders:** `assembleOptions`, `assembleOptionsFields`, `assembleOptionsWhere`,
|
|
45
|
+
`assembleOptionsJoin`, `assembleOptionsSort`.
|
|
46
|
+
- **Bridge 2.0→1.0:** `syncWithToga(...)` → `syncContactToToga1Customer`,
|
|
47
|
+
`syncEntitlementToToga1ServiceRequest`.
|
|
48
|
+
- **Bridge 2.0⇄1.0 TOGaDesk:** `syncWithTogadesk(...)` + its many sub-syncs
|
|
49
|
+
(`syncToga2TicketIntoTogadesk1{RepairOrder,Ticket}`, `syncTogaDesk1{RepairOrder,Ticket}IntoToga2Ticket`,
|
|
50
|
+
`syncToga2ContactIntoTogadesk1People`, `syncToga2ItemUnitIntoTogadesk1Asset`,
|
|
51
|
+
`syncToga2PredefinedReplyToTogadesk1TicketsPr`, `syncToga2TicketTeamsIntoTogadesk1Groups`,
|
|
52
|
+
`syncToga2TicketCategoryIntoTogadesk1CategorySubcategoryItem`, `sendTicketNotifications`).
|
|
53
|
+
- **Per-client invokers** (worker, one folder per client, distinct from the `netsuite/` supply
|
|
54
|
+
wrappers): e.g. `worker/crons/toga2/aig/sync_togasupply_aig.php` (calls `syncWithToga` +
|
|
55
|
+
`syncWithTogadesk` for tickets), `worker/crons/toga2/wje/sync_togasupply_wje.php`
|
|
56
|
+
(TOGaDesk-only, all sub-syncs on). These run on tight schedules (AIG every 10 min, WJE every
|
|
57
|
+
2 min) under the standard worker boilerplate.
|
|
58
|
+
|
|
59
|
+
## How it works
|
|
60
|
+
|
|
61
|
+
### Transport (`authenticate` + `send`)
|
|
62
|
+
|
|
63
|
+
- **Endpoint** comes from `App_Registry::get('config')['api']['_']` unless an override is passed
|
|
64
|
+
as the last arg (the bridge passes `self::$toga2ApiEndpoint`).
|
|
65
|
+
- **Auth** posts `{client,api,secret}` to `/auth/api` and caches the returned `{access,refresh}`
|
|
66
|
+
in a static `self::$_tokens[$uuidApi]`. An expired access token is refreshed via `/auth/refresh`
|
|
67
|
+
with the refresh token. Both auth paths retry up to **10×** (1s sleep) before throwing.
|
|
68
|
+
- **`send()`** authenticates, calls `sendUsingAccessToken()`, and on an **`EN-4`** message
|
|
69
|
+
(expired token) clears the cached access token and retries once. Connect/read timeouts are
|
|
70
|
+
generous (300s/900s) because list endpoints can be slow. A `usleep(200000)` throttle precedes
|
|
71
|
+
every call to be gentle on the API. Non-success responses throw unless `$throwOnError=false`.
|
|
72
|
+
- Returns the decoded response object; callers read `->data->{resource}`, `->meta->nextPage`,
|
|
73
|
+
`->isSuccess`, `->status`, `->messages[].code`.
|
|
74
|
+
|
|
75
|
+
### Options DSL (how 1.0 expresses 2.0 queries)
|
|
76
|
+
|
|
77
|
+
`$options` is an assoc array assembled into the 2.0 query string:
|
|
78
|
+
- `fields` — nested arrays become dotted paths (`['primaryContactAddress'=>['address'=>['line1']]]`
|
|
79
|
+
→ `primaryContactAddress.address.line1`); spaces become `+`.
|
|
80
|
+
- `where` — `['and'=>[['_updated'=>['>'=>$dt]]]]` compiles to `(_updated:gt:<urlenc>)`; operators
|
|
81
|
+
map (`>`→`gt`, `=`→`eq`, `in`/`notin` join with `:`); nestable `and`/`or` groups with parens.
|
|
82
|
+
- `join`/`ojoin` — `[['LocationTypes'=>['LocationTypes.id'=>'Locations.locationTypeId']]]`.
|
|
83
|
+
- `page`/`recordsPerPage`/`depth`/`calcDepth`/`key` pass through. Pagination is the universal
|
|
84
|
+
`for ($page=1,$nextPage=0; !is_null($nextPage); ++$page)` loop reading `meta->nextPage`.
|
|
85
|
+
|
|
86
|
+
### `syncWithToga()` — 2.0 → 1.0 (TOGa)
|
|
87
|
+
|
|
88
|
+
Signature: `(int $togaClientId, string $clientUuid, string $apiKey, string $apiSecret,
|
|
89
|
+
bool $enabled2to1, bool $enabled1to2)`. When `enabled2to1`:
|
|
90
|
+
- Pages 2.0 **`/contacts`** (for AIG, only those with `c_togaCustomerId IS NULL`) and upserts each
|
|
91
|
+
into the 1.0 client DB `Customers`/`Addresses` via `syncContactToToga1Customer`, then **writes
|
|
92
|
+
the new 1.0 id back** to the 2.0 contact's `c_togaCustomerId` (PUT `/contacts/{uuid}`).
|
|
93
|
+
- Pages 2.0 **`/entitlements`** without a `c_togaServiceRequestId` and inserts 1.0
|
|
94
|
+
`ServiceRequests` (+ a `Contacts` row if needed) via `syncEntitlementToToga1ServiceRequest`.
|
|
95
|
+
- The 1→2 direction (`enabled1to2`) is a stub (not yet implemented).
|
|
96
|
+
- DB target is selected with `App_Model_Client::changeDbLinkToClientDatabase(getClientDatabaseNames()[$togaClientId])`.
|
|
97
|
+
|
|
98
|
+
### `syncWithTogadesk()` — 2.0 ⇄ 1.0 (TOGaDesk)
|
|
99
|
+
|
|
100
|
+
A large bi-directional sync gated by **two checkpoint parameters** stored in 2.0 via
|
|
101
|
+
`/parameters`: `TOGADESK_LAST_TICKET_INTEGRATION_DATETIME` (newest 2.0 ticket pulled into 1.0)
|
|
102
|
+
and `TOGA_LAST_TICKET_INTEGRATION_DATETIME` (newest 1.0 ticket pushed into 2.0). Each direction
|
|
103
|
+
filters on records changed after its checkpoint, then advances it to the newest record seen.
|
|
104
|
+
|
|
105
|
+
Signature takes `$integrationType` (`TICKETS` or `REPAIR_ORDERS`) plus **seven independent
|
|
106
|
+
enable flags** and an optional `$monitorTogadeskDepartmentIds[]`:
|
|
107
|
+
- **2.0 → TOGaDesk** (`$isEnabledToga2ToTogadeskIntegration`): pages 2.0 `/tickets` and
|
|
108
|
+
`/ticket-notes` changed since the checkpoint; routes each to
|
|
109
|
+
`syncToga2TicketIntoTogadesk1{RepairOrder|Ticket}` by `$integrationType`. Per-ticket exceptions
|
|
110
|
+
are caught and sent to Sentry so one bad ticket doesn't abort the batch.
|
|
111
|
+
- **TOGaDesk → 2.0** (`$isEnabledTogadeskToToga2Integration`): queries the 1.0 `db_togadesk` DB
|
|
112
|
+
directly (SQL over `repair_orders`/`repair_order_history` or `tickets`/`tickets_replies`/`comments`,
|
|
113
|
+
using `GREATEST(...)` of reply/comment timestamps) for the client's department(s), then calls
|
|
114
|
+
`syncTogaDesk1{RepairOrder|Ticket}IntoToga2Ticket($id)`.
|
|
115
|
+
- Five further one-way 2.0 → TOGaDesk sub-syncs, each its own flag: Contacts→People,
|
|
116
|
+
Items/Units→Assets, Predefined-Replies→TicketsPr, Ticket-Teams→Groups,
|
|
117
|
+
Ticket-Categories→Category/Subcategory/Items.
|
|
118
|
+
- Finishes by PUT-ing both checkpoint parameters back to 2.0.
|
|
119
|
+
|
|
120
|
+
## Data model
|
|
121
|
+
|
|
122
|
+
- **2.0 side** is reached only through the API (never direct SQL): resources like `/contacts`,
|
|
123
|
+
`/entitlements`, `/tickets`, `/ticket-notes`, `/units`, `/parameters`, and the TOGa Supply
|
|
124
|
+
resources used by the importer.
|
|
125
|
+
- **1.0 side** is written with direct SQL through `App_Database` against per-client link strings
|
|
126
|
+
(`db_toga`, `db_togadesk`), after `changeDbLinkToClientDatabase()`. Tables touched include
|
|
127
|
+
`Customers`, `Addresses`, `Contacts`, `ServiceRequests` (TOGa) and `repair_orders`,
|
|
128
|
+
`repair_order_history`, `tickets`, `tickets_replies`, `comments` (TOGaDesk).
|
|
129
|
+
- **Checkpoints:** supply importer uses per-record-type `NETSUITE_*` keys (see per-client-sync
|
|
130
|
+
doc); the TOGaDesk bridge uses the two `*_LAST_TICKET_INTEGRATION_DATETIME` keys above. Both
|
|
131
|
+
live in the 2.0 client `Parameters` table and are read/written via `/parameters`.
|
|
132
|
+
|
|
133
|
+
## Client variations
|
|
134
|
+
|
|
135
|
+
- **Per-client credentials** (client/api/secret UUIDs) and TOGaDesk client/department IDs are
|
|
136
|
+
passed in by each cron — defined either as `App_Api_Toga2::{CLIENT_UUID_*,API_UUID_*,API_SECRET_*}`
|
|
137
|
+
class constants or as literals in the cron.
|
|
138
|
+
- Behavior is tuned per client by which enable flags are passed: AIG runs `syncWithToga` +
|
|
139
|
+
TOGaDesk **tickets inbound only**; WJE runs TOGaDesk with **all** sub-syncs on. The
|
|
140
|
+
`syncWithToga` contact query has an explicit `$togaClientId === 15` (AIG) special case.
|
|
141
|
+
|
|
142
|
+
## Gotchas / known issues
|
|
143
|
+
|
|
144
|
+
- **🔐 Hardcoded credentials.** Every client's `CLIENT_UUID_*`, `API_UUID_*`, and `API_SECRET_*`
|
|
145
|
+
(plus the "True Workers" master keys) are committed as plaintext class constants at the top of
|
|
146
|
+
`toga2.php`. Treat them as **compromised-if-leaked**; do not reproduce the values in the KB,
|
|
147
|
+
tickets, or logs. Long-term they should move to config/SSM. (Observed 2026-06-23.)
|
|
148
|
+
- **Two unrelated `sync_togasupply_<client>.php` families.** Files under
|
|
149
|
+
`worker/crons/toga2/netsuite/` are the **NetSuite supply importer** (`require` the common
|
|
150
|
+
engine). Files under `worker/crons/toga2/<client>/` (e.g. `aig/`, `wje/`) are this **bridge**
|
|
151
|
+
(`syncWithToga`/`syncWithTogadesk`). Same filename, different integration, different parameter
|
|
152
|
+
keys — they coexist harmlessly. Confirm which folder before editing.
|
|
153
|
+
- **`syncWithToga` and the bridge build SQL by hand** with `App_Database::sqlEscape()` rather than
|
|
154
|
+
the `App_Model` layer; follow the surrounding escaping discipline when modifying.
|
|
155
|
+
- **Per-record error isolation** in the TOGaDesk sync is via `try/catch` → `\Sentry\captureException`;
|
|
156
|
+
a thrown exception elsewhere (transport, checkpoint read) still aborts the whole run.
|
|
157
|
+
- **PHP 7.2** target (prod worker/library) — no arrow functions, typed properties, `??=`, `match`.
|
|
158
|
+
Lint with `C:\xampp7\php\php.exe -l`.
|
|
159
|
+
|
|
160
|
+
## Change history
|
|
161
|
+
|
|
162
|
+
- 2026-06-23 — Initial documentation of the `App_Api_Toga2` transport (auth/token caching, options
|
|
163
|
+
DSL) and the 1.0↔2.0 bridge routines `syncWithToga` (Contacts/Entitlements → Customers/ServiceRequests)
|
|
164
|
+
and `syncWithTogadesk` (bi-directional ticket/repair-order/people/asset sync with dual checkpoint
|
|
165
|
+
parameters). Flagged the committed per-client API credentials. (jcardinal)
|
|
166
|
+
|
|
167
|
+
## Related docs
|
|
168
|
+
|
|
169
|
+
- [NetSuite → TOGa Supply Per-Client Sync](../../worker/features/netsuite-togasupply-per-client-sync.md)
|
|
170
|
+
— the importer that uses this class's transport and `*FromNetsuite()` methods.
|
|
171
|
+
- [NetSuite SuiteQL/REST API Reference](netsuite-suiteql-api-reference.md) and
|
|
172
|
+
[SuiteQL/REST Shim](netsuite-suiteql-rest-shim.md) — the NetSuite side (`App_Api_Netsuite_Rest`).
|
|
173
|
+
- [Library architecture](../architecture.md) — `App_Api` pattern, autoloader, `App_Database` layer.
|
|
@@ -16,6 +16,7 @@ files:
|
|
|
16
16
|
related:
|
|
17
17
|
- ../architecture.md
|
|
18
18
|
- forecast2-netsuite-reconciliation.md
|
|
19
|
+
- ../../library/features/toga2-api-client-and-bridge.md
|
|
19
20
|
---
|
|
20
21
|
|
|
21
22
|
## Summary
|
|
@@ -161,3 +162,6 @@ Parameters are stored **per client DB** but accessed **through the TOGa2 API**,
|
|
|
161
162
|
- [Worker architecture](../architecture.md) — cron dispatch, schedule-as-source-of-truth.
|
|
162
163
|
- [Forecast2 NetSuite reconciliation](forecast2-netsuite-reconciliation.md) — sibling NetSuite
|
|
163
164
|
sync surface (different target DB).
|
|
165
|
+
- [App_Api_Toga2 — API Client & 1.0↔2.0 Bridge](../../library/features/toga2-api-client-and-bridge.md)
|
|
166
|
+
— the transport (`send`/`authenticate`/options DSL) and `*FromNetsuite()` methods this engine calls,
|
|
167
|
+
plus the separate `syncWithToga`/`syncWithTogadesk` bridge that shares the `sync_togasupply_*` filename.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# toga25-supply (TOGa 2.5 Supply) — 2.0 knowledge
|
|
2
|
+
|
|
3
|
+
| Doc | Summary | Files |
|
|
4
|
+
|-----|---------|-------|
|
|
5
|
+
| [TOGa 2.5 Supply — Architecture](architecture.md) | `toga25-supply` ("TOGa 2.5 Supply") is the **React/TypeScript frontend** for the 2.0 Supply application — an iteration and improvement of `toga2-supply`. | toga25-supply/src/main.tsx, toga25-supply/src/App.tsx, toga25-supply/src/routes.tsx, toga25-supply/src/fieldsConfig/index.ts, toga25-supply/src/fieldsConfig/useClientFields.ts, toga25-supply/src/layout/, toga25-supply/src/pages/, toga25-supply/src/hooks/useTableCellInteractions.ts |
|
|
6
|
+
| [Client-Configurable Fields (useClientFields / fieldsConfig)](features/client-configurable-fields.md) | The mechanism for config that **varies by client** (or client × role) — field overrides, filter buttons, group-by options, column pickers, layout toggles — with | toga25-supply/src/fieldsConfig/index.ts, toga25-supply/src/fieldsConfig/useClientFields.ts, toga25-supply/src/pages/SalesOrders/viewModel/FIELDS/, toga25-supply/src/pages/Inventory/viewModel/FIELDS/, toga25-supply/src/pages/Inventory/README.md |
|
|
7
|
+
| [Column Visibility (URL-driven show/hide columns)](features/column-visibility.md) | A "Columns" header button that opens a modal listing every column from the table meta, lets the user show/hide columns, adjusts the table live, and persists the | toga25-supply/src/components/ColumnVisibilityModal/, toga25-supply/src/pages/SalesOrders/SalesOrders.tsx, toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableData.tsx |
|
|
8
|
+
| [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 | toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableState.ts, toga25-supply/src/hooks/useTablePageMeta.ts, toga-blox-npm/dist/hooks/useFetchPageMeta.d.ts, toga-blox-npm/dist/hooks/useFetchTablePageMeta.d.ts, toga-blox-npm/dist/hooks/useAssignTableFieldLabels.d.ts, toga-blox-npm/dist/components/Table/hooks/useTableData.d.ts |
|
|
9
|
+
| [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`. | toga25-supply/src/layout/ItemRecordModalLayout/, toga25-supply/src/layout/SalesOrderRecordModalLayout/, toga25-supply/src/layout/SalesOrderItemsTableLayout/, toga25-supply/src/layout/ItemFulfillmentModal/, toga25-supply/src/layout/GenericNestedTables/, toga25-supply/src/hooks/useTableCellInteractions.ts |
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: TOGa 2.5 Supply — Architecture
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: toga25-supply
|
|
5
|
+
project: TOGa 2.5 Supply
|
|
6
|
+
client: shared
|
|
7
|
+
type: architecture
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-23
|
|
10
|
+
owners: [apeterson]
|
|
11
|
+
files:
|
|
12
|
+
- toga25-supply/src/main.tsx
|
|
13
|
+
- toga25-supply/src/App.tsx
|
|
14
|
+
- toga25-supply/src/routes.tsx
|
|
15
|
+
- toga25-supply/src/fieldsConfig/index.ts
|
|
16
|
+
- toga25-supply/src/fieldsConfig/useClientFields.ts
|
|
17
|
+
- toga25-supply/src/layout/
|
|
18
|
+
- toga25-supply/src/pages/
|
|
19
|
+
- toga25-supply/src/hooks/useTableCellInteractions.ts
|
|
20
|
+
related:
|
|
21
|
+
- features/client-configurable-fields.md
|
|
22
|
+
- features/meta-driven-table-data.md
|
|
23
|
+
- features/column-visibility.md
|
|
24
|
+
- features/record-modals-and-nested-tables.md
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Summary
|
|
28
|
+
|
|
29
|
+
`toga25-supply` ("TOGa 2.5 Supply") is the **React/TypeScript frontend** for the 2.0 Supply
|
|
30
|
+
application — an iteration and improvement of `toga2-supply`. It is a Vite + React 18 SPA
|
|
31
|
+
(react-router-dom 7, TanStack Query 5, react-hook-form 7) that talks to the 2.0 backend API
|
|
32
|
+
(`_underscore`). It does **not** depend on `toga2-supply`; its components, table templates, and
|
|
33
|
+
data hooks come from the shared in-house library **`@agilant/toga-blox`** (repo `toga-blox-npm`,
|
|
34
|
+
currently consumed at `1.0.318-beta.37`).
|
|
35
|
+
|
|
36
|
+
The app is **multi-client and meta-driven by design**. The same build serves many clients
|
|
37
|
+
(Compass USA/Canada, NYC Health & Hospitals, Quad, Prudential, GroWrk, and others — there are
|
|
38
|
+
per-client build scripts such as `nychh`, `compass`, `compasscanada`, `growrk`, `prudential`,
|
|
39
|
+
`spglobal`, `bankofamerica`, `wmchealth`, `quad`). Almost nothing is hardcoded per client:
|
|
40
|
+
table columns/labels come from server **table meta**, and client/role-specific UI config
|
|
41
|
+
(filter buttons, group-by options, column pickers, layout toggles) is resolved at runtime
|
|
42
|
+
through `src/fieldsConfig` + `useClientFields()`. Pages are thin; the real logic lives in
|
|
43
|
+
per-page **view models** (`use*ViewModel` hooks) and reusable **layout** components that wrap
|
|
44
|
+
toga-blox table/modal templates.
|
|
45
|
+
|
|
46
|
+
**Critical rules:**
|
|
47
|
+
- **Don't hardcode per-client behavior in components or view models** — resolve it through
|
|
48
|
+
`useClientFields()` / `src/fieldsConfig`, keyed by `clientSlug` (from `useHostnameStore`) ×
|
|
49
|
+
`role`. Every config must have a `DEFAULT` fallback or a client/role with no entry won't render.
|
|
50
|
+
- **A table's pagination-vs-infinite-scroll mode is decided solely by `isPaginationEnabled` from
|
|
51
|
+
the table meta**, consumed via the single `useTableData` hook — never pick a data hook or
|
|
52
|
+
hardcode the mode per page.
|
|
53
|
+
- **`@agilant/toga-blox` is symlinked from the local `toga-blox-npm` repo and the app consumes its
|
|
54
|
+
`dist/`** — after editing toga-blox source you MUST rebuild (`cd toga-blox-npm && npm run build`)
|
|
55
|
+
or the change won't reach runtime. Patching only `src` is a silent no-op.
|
|
56
|
+
- **Source files in this repo use TAB indentation** — match exactly or string-replace edits fail.
|
|
57
|
+
|
|
58
|
+
## Tech stack & entry points
|
|
59
|
+
|
|
60
|
+
- **Build/dev:** Vite. `src/main.tsx` → `src/App.tsx`; routes in `src/routes.tsx`. Per-client
|
|
61
|
+
dev/build scripts in `package.json` (`dev`, `build`, plus `nychh`, `compass`, `growrk`, etc.).
|
|
62
|
+
- **Data:** TanStack Query 5 (`@tanstack/react-query`). The canonical table query key prefix is
|
|
63
|
+
`["table-data", slug, …]`; a blanket `invalidateQueries({ queryKey: ["table-data"] })` refetches
|
|
64
|
+
any table after a mutation.
|
|
65
|
+
- **Forms:** react-hook-form 7 (`FormProvider` + `BaseInput`), used by the create/edit record modals.
|
|
66
|
+
- **Routing:** react-router-dom 7. URL search params are the primary state store for tables
|
|
67
|
+
(sorting, filters, pagination, active-row uuid, column visibility, nested-table swap keys).
|
|
68
|
+
- **UI library:** `@agilant/toga-blox` provides `PrimaryTable*Layout`, `TableRecordModal`,
|
|
69
|
+
`BaseInput`, `BaseButton`, `DetailSection`, the `useTableData` hook, FontAwesome helpers, etc.
|
|
70
|
+
|
|
71
|
+
## Directory layout (`src/`)
|
|
72
|
+
|
|
73
|
+
- `pages/<Entity>/` — page component (thin) + `viewModel/` (the `use*PageViewModel` hook) +
|
|
74
|
+
per-page `FIELDS/<CLIENT>/*.json` client config + `hooks/` + `dummyData/` (column-width JSON).
|
|
75
|
+
- `layout/<Thing>Layout/` — reusable table/modal layout wrappers around toga-blox templates,
|
|
76
|
+
each with its own `use*ViewModel`. Key ones: `SalesOrdersTableLayout`,
|
|
77
|
+
`SalesOrderRecordModalLayout`, `SalesOrderItemsTableLayout`, `ItemFulfillmentModal`,
|
|
78
|
+
`ItemRecordModalLayout`, `GenericNestedTables`, `RecordApprovalModalLayout`.
|
|
79
|
+
- `fieldsConfig/` — `index.ts` (the `ClientFields` bundle + `FIELDS[clientSlug][role]` map) and
|
|
80
|
+
`useClientFields.ts` (resolves active client/role, falls back to `DEFAULT_CLIENT_FIELDS`).
|
|
81
|
+
- `globalFieldsConfig/`, `hooks/`, `stores/` (e.g. `useHostnameStore`), `contexts/`, `utils/`,
|
|
82
|
+
`types/`, `api/`, `styles/`, `themeConfig.json`.
|
|
83
|
+
|
|
84
|
+
## Architectural pillars (see feature docs)
|
|
85
|
+
|
|
86
|
+
1. **Client/role-driven config** — runtime UI config without rebuilds. See
|
|
87
|
+
[client-configurable-fields](features/client-configurable-fields.md).
|
|
88
|
+
2. **Meta-driven table data** — one `useTableData` hook; meta's `isPaginationEnabled` picks the
|
|
89
|
+
fetch strategy. See [meta-driven-table-data](features/meta-driven-table-data.md).
|
|
90
|
+
3. **URL-driven column visibility** — shareable show/hide-columns, client-gated button. See
|
|
91
|
+
[column-visibility](features/column-visibility.md).
|
|
92
|
+
4. **Layered modals over tables** — record modals, nested client/server tables, cell-click
|
|
93
|
+
modals, and the `GenericNestedTables` config-driven renderer. See
|
|
94
|
+
[record-modals-and-nested-tables](features/record-modals-and-nested-tables.md).
|
|
95
|
+
|
|
96
|
+
## toga-blox relationship
|
|
97
|
+
|
|
98
|
+
`@agilant/toga-blox` is the shared frontend component/template/utility library (repo
|
|
99
|
+
`toga-blox-npm`). It is **not published to npm for this app's purposes** — the app is currently
|
|
100
|
+
its primary consumer and resolves it via a symlinked `node_modules/@agilant/toga-blox` →
|
|
101
|
+
`toga-blox-npm`. Because the app imports the built `dist/` (JS + `.d.ts`), any change to toga-blox
|
|
102
|
+
**source** requires a rebuild before it is visible at runtime; in urgent cases the `dist/` files
|
|
103
|
+
(and their `.d.ts`) have been patched directly. The `/update-blox` skill covers version bumps.
|
|
104
|
+
|
|
105
|
+
## Conventions & gotchas
|
|
106
|
+
|
|
107
|
+
- **Tabs, not spaces** for indentation in this repo's source.
|
|
108
|
+
- **`BaseButton` is a default import:** `import BaseButton from "@agilant/toga-blox/dist/components/BaseButton/BaseButton.js"`.
|
|
109
|
+
- **Per-consumer React Query cache scope:** when two components fetch the same record with
|
|
110
|
+
different field configs, share a `scope` discriminator in the query key so they don't clobber
|
|
111
|
+
each other's cached data (e.g. record modal vs. approval modal — `scope="record-modal"` vs
|
|
112
|
+
`scope="approval-modal"`). Including `additionalData` in the query key is mandatory for
|
|
113
|
+
WHERE-filtered modal tables, or React Query serves a stale unfiltered first render.
|
|
114
|
+
- **Typecheck with `npx tsc --noEmit` / `npx tsc -b`** after changes; adding a required key to
|
|
115
|
+
`ClientFields` surfaces every bundle missing it.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Client-Configurable Fields (useClientFields / fieldsConfig)
|
|
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-06-23
|
|
10
|
+
owners: [apeterson]
|
|
11
|
+
files:
|
|
12
|
+
- toga25-supply/src/fieldsConfig/index.ts
|
|
13
|
+
- toga25-supply/src/fieldsConfig/useClientFields.ts
|
|
14
|
+
- toga25-supply/src/pages/SalesOrders/viewModel/FIELDS/
|
|
15
|
+
- toga25-supply/src/pages/Inventory/viewModel/FIELDS/
|
|
16
|
+
- toga25-supply/src/pages/Inventory/README.md
|
|
17
|
+
related:
|
|
18
|
+
- ../architecture.md
|
|
19
|
+
- column-visibility.md
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## What it is
|
|
23
|
+
|
|
24
|
+
The mechanism for config that **varies by client** (or client × role) — field overrides,
|
|
25
|
+
filter buttons, group-by options, column pickers, layout toggles — without hardcoding it in
|
|
26
|
+
the view model or rebuilding per client. All such config resolves through `src/fieldsConfig`
|
|
27
|
+
and the `useClientFields()` hook.
|
|
28
|
+
|
|
29
|
+
## How it works
|
|
30
|
+
|
|
31
|
+
1. **Per-client JSON lives next to the consuming page**, under `FIELDS/<CLIENT>/`
|
|
32
|
+
(e.g. `src/pages/SalesOrders/viewModel/FIELDS/COMPASS/salesOrdersPageFields.json`). There is
|
|
33
|
+
**always a `DEFAULT/`** folder every client falls back to. `<CLIENT>` must match the hostname
|
|
34
|
+
`clientSlug` (uppercase — `COMPASS`, `NYCHH`, …).
|
|
35
|
+
2. **`src/fieldsConfig/index.ts`** imports those JSON files and wires them into the
|
|
36
|
+
`ClientFields` bundle — a normalized `FIELDS[clientSlug][role]` map merged over `DEFAULT`.
|
|
37
|
+
3. **`useClientFields()`** resolves the active `clientSlug` (from `useHostnameStore`) + `role`
|
|
38
|
+
(from `resolveRole`), looks up the bundle, falls back to `DEFAULT_CLIENT_FIELDS`, and caches
|
|
39
|
+
the result in React Query. It **always returns a defined bundle** (DEFAULT while loading/missing),
|
|
40
|
+
so reading `clientFields.yourKey` is safe without optional chaining — but guard array access.
|
|
41
|
+
4. **The view model reads its slice:**
|
|
42
|
+
```ts
|
|
43
|
+
const { fields: clientFields } = useClientFields();
|
|
44
|
+
const tenantFields = clientFields.salesOrdersPageFields; // your key
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### By client × role vs. by client only
|
|
48
|
+
|
|
49
|
+
- **Client × role** (e.g. `salesOrdersPageFields` — ADMIN vs MANAGER see different filter buttons):
|
|
50
|
+
the JSON is keyed by role at the top level (`{ "ADMIN": {...}, "MANAGER": {...} }`); use a
|
|
51
|
+
`pick*`/`merge*` helper to merge `DEFAULT[role]` with the client's `[role]`.
|
|
52
|
+
- **Client only** (e.g. `inventoryGroupings` — every role sees the same group-by options): the JSON
|
|
53
|
+
is flat, and you **inject** it across every role bundle for that client rather than hand-writing
|
|
54
|
+
it into each. Author the map literal as `FIELDS_BASE` (typed `Omit<ClientFields, "yourKey">`),
|
|
55
|
+
then build `FIELDS` by mapping each `[clientSlug][role]` entry and grafting
|
|
56
|
+
`yourKey: BY_CLIENT[clientSlug] ?? DEFAULT_*`. Avoids editing all ~13 role bundles.
|
|
57
|
+
`DEFAULT_CLIENT_FIELDS` must also carry the new key (ultimate fallback).
|
|
58
|
+
|
|
59
|
+
### Hydration (non-serializable bits)
|
|
60
|
+
|
|
61
|
+
Keep JSON **serializable** — strings, enums, arrays. No JSX, no functions, no Tailwind classes.
|
|
62
|
+
Anything non-serializable is referenced by an **enum key** and resolved in the view model via a
|
|
63
|
+
registry that maps enum → real value (e.g. `MODAL_RENDERERS: Record<ModalKey, renderModal>`).
|
|
64
|
+
This is the one place app-owned JSX is grafted back onto client-authored config. Clients never
|
|
65
|
+
author JSX.
|
|
66
|
+
|
|
67
|
+
## Adding a new client-configurable field
|
|
68
|
+
|
|
69
|
+
1. Author the serializable JSON under `FIELDS/DEFAULT/<thing>.json` + a folder per overriding client.
|
|
70
|
+
2. Declare the types next to the JSON (`FIELDS/index.ts`) and export the shape.
|
|
71
|
+
3. Wire into `fieldsConfig/index.ts`: add the key to `ClientFields`, import each client's JSON,
|
|
72
|
+
add the value to **every** bundle + `DEFAULT_CLIENT_FIELDS` (or use the injection pattern).
|
|
73
|
+
4. Consume in the view model via `useClientFields()`.
|
|
74
|
+
5. Hydrate enum→value in the view model if the config references components/render callbacks.
|
|
75
|
+
6. `npx tsc -b` — a new required `ClientFields` key surfaces every bundle missing it.
|
|
76
|
+
|
|
77
|
+
## Gotchas
|
|
78
|
+
|
|
79
|
+
- **Always provide `DEFAULT`.** Resolution order: `FIELDS[client]?.[role]` → `FIELDS[client]?.DEFAULT`
|
|
80
|
+
→ `DEFAULT_CLIENT_FIELDS`. A client/role with no entry must still render.
|
|
81
|
+
- **New client *file* = no code change** (just import/wiring). **New render style/modal type
|
|
82
|
+
(new enum value) = code change** in the hydration registry — that's the intended boundary
|
|
83
|
+
between client config and app rendering.
|
|
84
|
+
- Worked examples: `salesOrdersPageFields` (client × role) and `inventoryGroupings` (client-only +
|
|
85
|
+
hydration; full write-up in `src/pages/Inventory/README.md`).
|
|
86
|
+
|
|
87
|
+
## Change history
|
|
88
|
+
- 2026-06-23 — Documented from the `add-client-fields` skill during initial knowledge seed. (apeterson)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Column Visibility (URL-driven show/hide columns)
|
|
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-06-23
|
|
10
|
+
owners: [apeterson]
|
|
11
|
+
files:
|
|
12
|
+
- toga25-supply/src/components/ColumnVisibilityModal/
|
|
13
|
+
- toga25-supply/src/pages/SalesOrders/SalesOrders.tsx
|
|
14
|
+
- toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx
|
|
15
|
+
- toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableData.tsx
|
|
16
|
+
related:
|
|
17
|
+
- ../architecture.md
|
|
18
|
+
- client-configurable-fields.md
|
|
19
|
+
- meta-driven-table-data.md
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## What it is
|
|
23
|
+
|
|
24
|
+
A "Columns" header button that opens a modal listing every column from the table meta, lets the
|
|
25
|
+
user show/hide columns, adjusts the table live, and persists the selection in the URL as a
|
|
26
|
+
shareable link. The button's presence/label/icon — and the modal's entire copy — are driven per
|
|
27
|
+
client × role via the FIELDS config. Built for pages using `PrimaryTableServerLayout` (the Sales
|
|
28
|
+
Orders pattern). Reference: `ColumnVisibilityModal` + Sales Orders page/view model/data hook.
|
|
29
|
+
|
|
30
|
+
## How the table renders columns (key facts)
|
|
31
|
+
|
|
32
|
+
- The table meta (`TableViewMeta` / `DataTableMetaProp`) carries `fields: TableViewField[]`; each
|
|
33
|
+
field has a unique `slug` and an `isVisible`. `buildTanstackColumns` (toga-blox) **filters by
|
|
34
|
+
`isVisible`** before building columns, so the tanstack column `id` is the field `slug`. Hiding =
|
|
35
|
+
`isVisible: false` or removing the field — identical column set.
|
|
36
|
+
- `getDataTableData` builds the API field list **from `meta.fields`**. So the meta used for
|
|
37
|
+
*fetching* must keep all fields; only the meta passed to the *table* should be visibility-adjusted.
|
|
38
|
+
**Keep two metas separate: fetch = full, display = adjusted.**
|
|
39
|
+
|
|
40
|
+
## URL-state semantics
|
|
41
|
+
|
|
42
|
+
Single param `cols` = comma-separated **visible** column slugs.
|
|
43
|
+
- **Absent** → fall back to each field's default `isVisible`.
|
|
44
|
+
- **Present (even empty)** → only the listed slugs are visible. Distinguish absent from empty with
|
|
45
|
+
`searchParams.has("cols")`, NOT truthiness (so "hide all" works):
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
const visibleSlugSet = searchParams.has("cols")
|
|
49
|
+
? new Set((searchParams.get("cols") ?? "").split(",").filter(Boolean))
|
|
50
|
+
: null;
|
|
51
|
+
const isFieldVisible = (f) => visibleSlugSet ? visibleSlugSet.has(f.slug) : f.isVisible;
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Steps
|
|
55
|
+
|
|
56
|
+
1. **Reuse the modal.** `ColumnVisibilityModal` is generic and holds **zero hardcoded copy** —
|
|
57
|
+
props `{ isOpen, columns: { slug, label, visible }[], labels, icon, onApply(visibleSlugs),
|
|
58
|
+
onReset?, onClose }`. `labels` = `{ title, showAll, hideAll, reset, cancel, apply, close }`;
|
|
59
|
+
`icon` = `{ name, variant }`. It keeps pending state internally, commits only on Apply.
|
|
60
|
+
2. **View model — derive visibility from URL.** Add `visibleSlugSet`/`isFieldVisible`,
|
|
61
|
+
`columnOptions` (`fields.map(f => ({ slug, label: f.label ?? f.slug, visible: isFieldVisible(f) }))`),
|
|
62
|
+
`displayTableMeta` (`{ ...meta, fields: fields.map(f => ({ ...f, isVisible: isFieldVisible(f) })) }` —
|
|
63
|
+
return THIS as the table meta; data hook keeps full meta), `columnVisibilityKey` (visible slugs
|
|
64
|
+
joined; remount key), and `applyColumnVisibility(slugs)`/`resetColumnVisibility()`.
|
|
65
|
+
3. **Data hook — exclude `cols` from the query key.** Where the slug key is stripped from
|
|
66
|
+
`tableSearchParams`, also `filtered.delete("cols")` — else every toggle refetches.
|
|
67
|
+
4. **Page — button + modal + remount key.** Local `isColumnsModalOpen` state; render the button
|
|
68
|
+
from client config; pass `columnsButton.modal.labels`/`.icon` to the modal; put
|
|
69
|
+
`key={columnVisibilityKey}` on the table layout (required — see gotcha).
|
|
70
|
+
5. **Make button + all modal copy client-configurable** (mirrors `filterButtons`; see
|
|
71
|
+
[client-configurable-fields](client-configurable-fields.md)). Add a `columnsButton` block to each
|
|
72
|
+
role in `FIELDS/<CLIENT>/<page>Fields.json` (and to `DEFAULT` first). Resolve it off `tenantFields`
|
|
73
|
+
in the view model into `{ icon, labels }` with **per-key fallbacks living in the resolution layer**,
|
|
74
|
+
never in the component.
|
|
75
|
+
6. **Verify:** `npx tsc --noEmit`, validate each JSON parses, click live (toggle, Apply, copy URL,
|
|
76
|
+
reload → state persists).
|
|
77
|
+
|
|
78
|
+
## Gotchas
|
|
79
|
+
|
|
80
|
+
- **No hardcoded copy in the component** — every user-facing string (incl. Close `aria-label`) is a
|
|
81
|
+
required `labels` prop from client FIELDS; fallbacks live in the view model's resolution, not the
|
|
82
|
+
component, not default prop values. Only structural glyphs (checkbox tick, close `xmark`) stay.
|
|
83
|
+
- **Remount key is REQUIRED.** toga-blox's `PrimaryTableRow` memoizes
|
|
84
|
+
`useMemo(() => row.getVisibleCells(), [row])`; TanStack caches `row` by data, so a columns-only
|
|
85
|
+
change doesn't change `row` → stale cells. Symptom: **toggling off removes the header but leaves the
|
|
86
|
+
body column.** `key={columnVisibilityKey}` forces a fresh table instance — the only fix without
|
|
87
|
+
editing `node_modules`. Real upstream fix: repair that memo in toga-blox.
|
|
88
|
+
- **Don't shrink the fetch meta** — adjust `isVisible` only on the table meta; `getDataTableData`
|
|
89
|
+
builds its field list from `meta.fields`.
|
|
90
|
+
- **`cols` must be stripped from the data query** or every toggle refetches.
|
|
91
|
+
- **`has("cols")` vs truthiness** — empty `cols` is a real "hide all" state, not "absent".
|
|
92
|
+
- **Checkbox can't be a nested `<button>`** — the row is the clickable `<button>`; the checkbox is a
|
|
93
|
+
visual `<span aria-hidden>`. A button-in-button is invalid HTML and silently breaks clicks.
|
|
94
|
+
- **Shareable-link trade-off** — an explicit `cols` set captures an exact view; columns added to the
|
|
95
|
+
meta later won't appear for an old link until Reset (intended).
|
|
96
|
+
|
|
97
|
+
## Change history
|
|
98
|
+
- 2026-06-23 — Documented from the `add-column-visibility` skill during initial knowledge seed. (apeterson)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Meta-Driven Page & Table Setup
|
|
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-06-23
|
|
10
|
+
owners: [apeterson]
|
|
11
|
+
files:
|
|
12
|
+
- toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx
|
|
13
|
+
- toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableState.ts
|
|
14
|
+
- toga25-supply/src/hooks/useTablePageMeta.ts
|
|
15
|
+
- toga-blox-npm/dist/hooks/useFetchPageMeta.d.ts
|
|
16
|
+
- toga-blox-npm/dist/hooks/useFetchTablePageMeta.d.ts
|
|
17
|
+
- toga-blox-npm/dist/hooks/useAssignTableFieldLabels.d.ts
|
|
18
|
+
- toga-blox-npm/dist/components/Table/hooks/useTableData.d.ts
|
|
19
|
+
related:
|
|
20
|
+
- ../architecture.md
|
|
21
|
+
- column-visibility.md
|
|
22
|
+
- record-modals-and-nested-tables.md
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## What it is
|
|
26
|
+
|
|
27
|
+
A page in this app is **meta-driven end to end**: the page view model fetches *page meta* (labels,
|
|
28
|
+
sections, ACL) and *table meta* (the columns/fields + table settings), then fetches the *table
|
|
29
|
+
data* — each step keyed by a slug, each hook supplied by `@agilant/toga-blox`. A developer wires a
|
|
30
|
+
new page by composing these hooks in a view model; they never hand-build columns or choose a
|
|
31
|
+
fetch strategy. `useSalesOrdersViewModel`
|
|
32
|
+
(`src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx`) is the canonical example.
|
|
33
|
+
|
|
34
|
+
## The flow (Sales Orders)
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
useSalesOrdersViewModel
|
|
38
|
+
│
|
|
39
|
+
│ 1. PAGE META ── page slug "sales-orders-listing", app "supply"
|
|
40
|
+
├─▶ useFetchPageMeta("sales-orders-listing", { app: "supply", enabled: true }) [toga-blox]
|
|
41
|
+
│ → pageMeta: PageMeta { settings: { sections, fields }, acl }
|
|
42
|
+
│ (field-label/section overrides + ACL for this page)
|
|
43
|
+
│
|
|
44
|
+
│ 2. TABLE SLUG ── from the route, not from pageMeta
|
|
45
|
+
├─▶ useSalesOrdersTableState() → useServerTableUrlState(slug, …) [app]
|
|
46
|
+
│ slug = location.pathname.replace("/", "") // e.g. "salesorders"
|
|
47
|
+
│ also owns sorting / filters / page / recordsPerPage / activeRowUuid (URL state)
|
|
48
|
+
│
|
|
49
|
+
│ 3. TABLE META ── table slug → columns + table settings
|
|
50
|
+
├─▶ useTablePageMeta({ slug: tableState.slug }) [app wrapper]
|
|
51
|
+
│ └─ useFetchTablePageMeta({ slug }) [toga-blox]
|
|
52
|
+
│ → { table: TableViewMeta, nestedTable }, isLoadingFilterOptions, …
|
|
53
|
+
│ table.fields[] = every column (TableViewField[])
|
|
54
|
+
│ table.{ slug, route, joins, sort, recordsPerPage,
|
|
55
|
+
│ isPaginationEnabled, isInfiniteScrollingEnabled, apiWhereClause }
|
|
56
|
+
│
|
|
57
|
+
│ 4. MERGE LABELS ── graft page-meta labels onto table fields
|
|
58
|
+
├─▶ useAssignTableFieldLabels(fetchedTableViewMeta, pageMeta) [toga-blox]
|
|
59
|
+
│ → salesOrdersTableMeta: TableViewMeta | null
|
|
60
|
+
│
|
|
61
|
+
│ 5. TABLE DATA ── same table slug → rows
|
|
62
|
+
└─▶ useTableData({ slug: tableState.slug, tableViewMeta: salesOrdersTableMeta,
|
|
63
|
+
isPaginationEnabled: !!salesOrdersTableMeta?.isPaginationEnabled,
|
|
64
|
+
sorting, columnFilters, page, recordsPerPage,
|
|
65
|
+
searchParams, additionalData }) [toga-blox]
|
|
66
|
+
→ { records, isLoadingTableDataLoading, isFetching,
|
|
67
|
+
paginationMeta, fetchNextPage, hasNextPage }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The view model then returns `salesOrdersTableMeta` (after column-visibility adjustment — see
|
|
71
|
+
[column-visibility](column-visibility.md)), `displayRecords`, the pagination/infinite wiring, and
|
|
72
|
+
`tableSettings = { isPaginationEnabled }` for the `*TableLayout` to forward to
|
|
73
|
+
`PrimaryTableServerLayout`.
|
|
74
|
+
|
|
75
|
+
### Two slugs, two metas — keep them straight
|
|
76
|
+
|
|
77
|
+
- **Page slug** (`"sales-orders-listing"`) is a literal passed to `useFetchPageMeta` and identifies
|
|
78
|
+
the *page* (its label overrides, sections, ACL). It does **not** identify the table.
|
|
79
|
+
- **Table slug** (`tableState.slug`) identifies the *table* and is used for **both** the table-meta
|
|
80
|
+
fetch (step 3) and the table-data fetch (step 5) — they must match so the columns and the rows
|
|
81
|
+
describe the same table. In Sales Orders this slug is derived from the route pathname via
|
|
82
|
+
`useServerTableUrlState`, not read out of `pageMeta`.
|
|
83
|
+
- `useAssignTableFieldLabels` is the bridge: it overlays the page meta's per-field labels
|
|
84
|
+
(`pageMeta.settings.fields[table][field].label`) onto the table meta's fields, so page-scoped
|
|
85
|
+
copy wins without re-fetching.
|
|
86
|
+
|
|
87
|
+
## What's in the table meta (the columns)
|
|
88
|
+
|
|
89
|
+
`useFetchTablePageMeta` returns `{ table, nestedTable }`. The `table` object is the `TableViewMeta`
|
|
90
|
+
(alias of `DataTableMetaProp`) and carries:
|
|
91
|
+
|
|
92
|
+
- `fields: TableViewField[]` — **every column**. Each field: `slug` (unique id / tanstack column
|
|
93
|
+
id), `label`, `isVisible`, `index`, `type` (`STRING | NUMBER | DATE | DATETIME | SELECT |
|
|
94
|
+
MULTISELECT | IMAGE | HYPERLINK | STATUS | CURRENCY | AVATAR | LINK | URGENCY | CLIENT |
|
|
95
|
+
STATUS_BADGE | BOOLEAN | null`), `isEditable`, `isSortable`, `isFilterable`, `isCopyable`,
|
|
96
|
+
`isExpandableCell`, `width`, `sticky` (`left|right|null`), `precision`, `casing`, `recordRoute`,
|
|
97
|
+
`hyperlinkField`, `imageUrlField`, `filterOptions`, `settings: { table, field, contextTable,
|
|
98
|
+
contextField }`, `selectIdentifierField`, `selectNameField`.
|
|
99
|
+
- Table-level: `slug`, `route`, `table`, `joins[]`, `sort[]`, `recordsPerPage`, `apiWhereClause`,
|
|
100
|
+
`isPaginationEnabled`, `isInfiniteScrollingEnabled`.
|
|
101
|
+
- `nestedTable` — a second `TableViewMeta | null` for nested/expand rows.
|
|
102
|
+
|
|
103
|
+
`buildTanstackColumns` (toga-blox) builds columns from `fields`, filtering by `isVisible` (the
|
|
104
|
+
tanstack column `id` is the field `slug`).
|
|
105
|
+
|
|
106
|
+
## Pagination vs. infinite scroll is meta-driven too
|
|
107
|
+
|
|
108
|
+
`useTableData` runs **both** a paginated `useQuery` and an infinite `useInfiniteQuery`, gating each
|
|
109
|
+
with `enabled` on `isPaginationEnabled` so only the active one fetches, and returns one normalized
|
|
110
|
+
shape (`records`, `paginationMeta` *or* `fetchNextPage`/`hasNextPage`). The view model passes only
|
|
111
|
+
`isPaginationEnabled` (from the table meta); `PrimaryTable*Layout` derives
|
|
112
|
+
`isInfiniteScrollingEnabled = !isPaginationEnabled`. So a table's mode comes from its meta, never a
|
|
113
|
+
per-page hook choice. Other baked-in behavior: unified `["table-data", slug, …]` query key (a
|
|
114
|
+
blanket `invalidateQueries({ queryKey: ["table-data"] })` refetches either mode); display params
|
|
115
|
+
(`<slug>` active-row uuid, `cols`) stripped from the key/API call; generic record extraction (first
|
|
116
|
+
array in `response.data`).
|
|
117
|
+
|
|
118
|
+
## From toga-blox `dist` (reference)
|
|
119
|
+
|
|
120
|
+
Everything in the pipeline except the route-derived table slug comes from `@agilant/toga-blox`. The
|
|
121
|
+
app imports the built `dist/` — edits to toga-blox source require a rebuild (`cd toga-blox-npm &&
|
|
122
|
+
npm run build`) before they reach runtime.
|
|
123
|
+
|
|
124
|
+
| Export | `dist` path | Signature / role |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| `useFetchPageMeta` | `dist/hooks/useFetchPageMeta` | `(slug, { app, enabled?, onError? }) → { pageMeta: PageMeta \| null, labels, isLoading, isError, refetch }`. `PageMeta = { settings: { sections: MetaSections, fields: Record<string,any> }, acl }`. Page-level labels/sections/ACL. |
|
|
127
|
+
| `useFetchTablePageMeta` | `dist/hooks/useFetchTablePageMeta` | `({ slug }) → { tableViewMeta: { table, nestedTable }, isLoadingFilterOptions, isLoadingTableDataLoading, isFetching }`. The columns + table settings. |
|
|
128
|
+
| `useAssignTableFieldLabels` | `dist/hooks/useAssignTableFieldLabels` | `(fetchedTableViewMeta: { table? } \| null, pageMeta: { settings?: { fields? } } \| null) → TableViewMeta \| null`. Merges page-meta field labels onto table fields. |
|
|
129
|
+
| `useTableData` | `dist/components/Table/hooks/useTableData` | `(UseTableDataOptions) → { records, isLoadingTableDataLoading, isFetching, paginationMeta, fetchNextPage, hasNextPage }`. Bimodal data fetch (see above). |
|
|
130
|
+
| `TableViewMeta` (type) | `dist/components/Table/types` → `= DataTableMetaProp` | The `table`/`nestedTable` shape; `TableSkin = "supply" \| "desk" \| "supply-nested" \| "supply-modal-table"`. |
|
|
131
|
+
| `TableViewField` (type) | `dist/api/index` | Per-column shape (see "What's in the table meta"). |
|
|
132
|
+
|
|
133
|
+
App-side glue (NOT in toga-blox):
|
|
134
|
+
- `useTablePageMeta` (`src/hooks/useTablePageMeta.ts`) — thin wrapper over `useFetchTablePageMeta`
|
|
135
|
+
that re-types `table`/`nestedTable` as `TableViewMeta | null` and re-exposes the rest unchanged.
|
|
136
|
+
- `useSalesOrdersTableState` → `useServerTableUrlState` (`src/hooks/`) — derives the **table slug**
|
|
137
|
+
from the route pathname and owns URL-backed sorting/filters/pagination/active-row state.
|
|
138
|
+
|
|
139
|
+
## Wiring a new page (recipe)
|
|
140
|
+
|
|
141
|
+
1. `useFetchPageMeta("<page-slug>", { app: "supply", enabled: true })` → `pageMeta`.
|
|
142
|
+
2. A table-state hook (`use<Page>TableState` → `useServerTableUrlState(slug, …)`) to get the table
|
|
143
|
+
`slug` + URL state (`sorting`, `columnFilters`, `page`, `recordsPerPage`, `searchParams`).
|
|
144
|
+
3. `useTablePageMeta({ slug })` → `tableViewMeta` (columns + settings).
|
|
145
|
+
4. `useAssignTableFieldLabels(tableViewMeta, pageMeta)` → the merged meta you hand to the table.
|
|
146
|
+
5. `useTableData({ slug, tableViewMeta: merged, isPaginationEnabled: !!merged?.isPaginationEnabled,
|
|
147
|
+
sorting, columnFilters, page, recordsPerPage, searchParams, additionalData })` → rows.
|
|
148
|
+
6. `tableSettings = useMemo(() => ({ isPaginationEnabled: merged?.isPaginationEnabled }), [merged])`.
|
|
149
|
+
7. Return both pagination wiring (`paginationMeta`, `handlePageChange`, `handleRecordsPerPageChange`)
|
|
150
|
+
**and** infinite wiring (`fetchNextPage`, `hasNextPage`); forward all from the `*TableLayout` to
|
|
151
|
+
`PrimaryTableServerLayout`.
|
|
152
|
+
|
|
153
|
+
## Gotchas
|
|
154
|
+
|
|
155
|
+
- **Page slug ≠ table slug.** The page slug is a literal for `useFetchPageMeta`; the table slug
|
|
156
|
+
(route-derived here) drives table meta *and* table data — those two must use the **same** slug.
|
|
157
|
+
- **Server meta currently returns `isPaginationEnabled: true` for every slug**, so all tables
|
|
158
|
+
paginate today. A table that should infinite-scroll needs the backend to return `false` for that
|
|
159
|
+
slug — the frontend no longer hardcodes it. (This is why VendorItems, historically infinite, now
|
|
160
|
+
paginates.)
|
|
161
|
+
- **Don't shrink the fetch meta for column hiding.** `getDataTableData` builds its API field list
|
|
162
|
+
from `meta.fields`; adjust `isVisible` only on the *display* meta (see column-visibility).
|
|
163
|
+
- **Infinite scroll needs a height-constrained scroll container** (`max-height` + `overflow-y: auto`);
|
|
164
|
+
inline-expanded nested tables where the page scrolls won't load more. Pagination is unaffected.
|
|
165
|
+
- **`additionalData` must be in the query key** (it is, inside `useTableData`) — required for
|
|
166
|
+
WHERE-filtered modal tables or React Query serves a stale unfiltered first render.
|
|
167
|
+
- **toga-blox edits need a rebuild** — the app consumes `dist/`; editing only `src` is a silent no-op.
|
|
168
|
+
|
|
169
|
+
## Change history
|
|
170
|
+
- 2026-06-23 — Refocused on the full page→table→data meta pipeline (page meta via `useFetchPageMeta`,
|
|
171
|
+
table meta via `useTablePageMeta`/`useFetchTablePageMeta`, label merge via
|
|
172
|
+
`useAssignTableFieldLabels`, data via `useTableData`), grounded in `useSalesOrdersViewModel`; added
|
|
173
|
+
a toga-blox `dist` reference table. (apeterson)
|
|
174
|
+
- 2026-06-09→06-10 — Migration that established the bimodal `useTableData`: added it to toga-blox;
|
|
175
|
+
made `PrimaryTable*Layout` derive `isInfiniteScrollingEnabled`; migrated Items/SalesOrders/VendorItems
|
|
176
|
+
view models + `GenericNestedTables` + `ItemFulfillmentModal`; deleted per-page data hooks. (apeterson)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Record Modals & Nested Tables
|
|
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-06-23
|
|
10
|
+
owners: [apeterson]
|
|
11
|
+
files:
|
|
12
|
+
- toga25-supply/src/layout/ItemRecordModalLayout/
|
|
13
|
+
- toga25-supply/src/layout/SalesOrderRecordModalLayout/
|
|
14
|
+
- toga25-supply/src/layout/SalesOrderItemsTableLayout/
|
|
15
|
+
- toga25-supply/src/layout/ItemFulfillmentModal/
|
|
16
|
+
- toga25-supply/src/layout/GenericNestedTables/
|
|
17
|
+
- toga25-supply/src/hooks/useTableCellInteractions.ts
|
|
18
|
+
related:
|
|
19
|
+
- ../architecture.md
|
|
20
|
+
- meta-driven-table-data.md
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## What it is
|
|
24
|
+
|
|
25
|
+
The repo's family of modal + nested-table patterns layered over toga-blox `TableRecordModal` and
|
|
26
|
+
`PrimaryTable*Layout`. Covers: row-click record modals, nested client/server tables inside a
|
|
27
|
+
modal, cell-click modals, modal-contained server tables, the config-driven `GenericNestedTables`
|
|
28
|
+
renderer, and form-driven create/edit record modals. The canonical examples live in
|
|
29
|
+
`src/layout/*` (Sales Orders + Item). These six patterns are the load-bearing UI architecture for
|
|
30
|
+
this app.
|
|
31
|
+
|
|
32
|
+
## Pattern 1 — Row click opens a record modal (URL-driven)
|
|
33
|
+
|
|
34
|
+
Page renders a `*TableLayout` and passes a `renderModal` prop. The table layout owns all state via
|
|
35
|
+
`use*ViewModel` → `useServerTableUrlState`. Clicking a row sets `activeRowUuid` in the URL;
|
|
36
|
+
`PrimaryTableServerLayout` calls `renderModal({ uuid, onClose })`, which renders the record modal
|
|
37
|
+
wrapped in `TableRecordModal position="bottom"`.
|
|
38
|
+
|
|
39
|
+
- `renderModal` is `({ uuid, onClose }) => ReactNode | null` — return `null` when `uuid` is falsy.
|
|
40
|
+
- `onClose` clears the URL state (`handleModalClose`).
|
|
41
|
+
- The record modal layout calls its own view model to fetch data for that uuid.
|
|
42
|
+
- **Action-column modals** (e.g. approval) use a separate `useState<Record|null>` in the page,
|
|
43
|
+
rendered as a second `TableRecordModal position="center"` — not URL-driven.
|
|
44
|
+
|
|
45
|
+
## Pattern 2 — Record modal contains a nested client-side table
|
|
46
|
+
|
|
47
|
+
The record modal view renders e.g. `SalesOrderItemsTableLayout` with `displayRecords` from the
|
|
48
|
+
parent view model. The nested table uses `PrimaryTableClientLayout` (data already in memory),
|
|
49
|
+
`tableSkin="supply-modal-table"`, and its **own** slug + `useSearchParams` + `useServerTableUrlState`
|
|
50
|
+
so URL-state namespacing doesn't collide with the outer table. Column widths come from
|
|
51
|
+
`src/pages/<Entity>/dummyData/*.json`; `headerSpan` adds a spanning header above grouped columns.
|
|
52
|
+
|
|
53
|
+
## Pattern 3 — Cell click within a nested table opens another modal
|
|
54
|
+
|
|
55
|
+
Driven by the reusable hook `useTableCellInteractions` (`src/hooks/useTableCellInteractions.ts`),
|
|
56
|
+
which owns all click/hover/style logic. Each table's view model only defines the registries:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
const { activeModal, closeModal, handleTableClick, handleTableMouseOver,
|
|
60
|
+
handleTableMouseOut, applyClickableStyles } = useTableCellInteractions({
|
|
61
|
+
fields, cellClickRegistry, headerClickRegistry /* optional */, contextUuid /* optional */ });
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- `cellClickRegistry[key]` (key matches the field's `onClick` in the table meta JSON):
|
|
65
|
+
`{ canOpen?(row), getModalSpec(row) => ({ key, uuid, row }) }`. `canOpen` is the single source
|
|
66
|
+
of "is this cell clickable?" — used by the click handler, hover styles, and cursor styles.
|
|
67
|
+
- `headerClickRegistry[key]` (key matches the field's `headerOnClick`):
|
|
68
|
+
`{ getModalSpec(contextUuid) => ({ key, uuid, row }) }`.
|
|
69
|
+
- Table meta JSON marks a field: `{ "name": "qtyFulfilled", "onClick": "openFulfilledModal",
|
|
70
|
+
"headerOnClick": "openAllFulfillments" }` — both independent.
|
|
71
|
+
|
|
72
|
+
Layout setup: wrap the table in a div with `onClick={(e) => handleTableClick(e, displayRecords)}`
|
|
73
|
+
+ mouseover/out; re-apply styles on DOM changes via a `MutationObserver` calling
|
|
74
|
+
`applyClickableStyles` (virtual scroll re-renders rows); render each modal as a sibling switching
|
|
75
|
+
on `activeModal.key`. **Adding a clickable column = 3 edits:** meta JSON `onClick`, one
|
|
76
|
+
`cellClickRegistry` entry, one `{activeModal?.key === "myKey" && <Modal/>}` block.
|
|
77
|
+
|
|
78
|
+
## Pattern 4 — Modal containing a server-side table
|
|
79
|
+
|
|
80
|
+
For modal tables needing server data, use `PrimaryTableServerLayout` inside `TableRecordModal
|
|
81
|
+
position="bottom"`. The modal view model calls `useFetchTablePageMeta` + `useTablePageData`
|
|
82
|
+
(or `useTableData`) with an `additionalData` WHERE filter, e.g. `{ slug: "SalesOrderItems.uuid",
|
|
83
|
+
uuid: "<row-uuid>" }`. **`additionalData` MUST be in the query key** (in toga-blox) or React Query
|
|
84
|
+
caches the unfiltered first render (uuid null) and never refetches. Guard with `isOpen={!!uuid}`;
|
|
85
|
+
when uuid is null, `additionalData` returns `{}`. Typically `isPaginationEnabled: false` +
|
|
86
|
+
`isInfiniteScrollingEnabled: true` (but see [meta-driven-table-data](meta-driven-table-data.md)).
|
|
87
|
+
|
|
88
|
+
## Pattern 5 — `GenericNestedTables` (config-driven multi-level / swap tables)
|
|
89
|
+
|
|
90
|
+
A config-driven renderer: declare an array of `TableLevel` objects instead of hand-wiring each
|
|
91
|
+
level. Each level fetches its own meta + data and renders a `GenericTableLayout` (wraps
|
|
92
|
+
`PrimaryTableServerLayout`). Reference: `src/pages/Inventory/`.
|
|
93
|
+
|
|
94
|
+
`TableLevel` carries `fetchSlug`, `metaVariant`, `tableSkin`/`swapTableSkin`, `actionColumns`,
|
|
95
|
+
`renderModal` (Pattern 1), `rowInteraction` (`"expand"` default | `"swap"`), `urlParamKey`
|
|
96
|
+
(required for swap, unique per level), `getAdditionalData(parentUuid)`,
|
|
97
|
+
`getSwapAdditionalData(selfUuid)`.
|
|
98
|
+
|
|
99
|
+
- **`"expand"`** — clicking a row inline-expands the next level beneath it (filtered via
|
|
100
|
+
`getAdditionalData(parentUuid)`).
|
|
101
|
+
- **`"swap"`** — clicking writes `urlParamKey=<rowUuid>`; the renderer makes that level the new
|
|
102
|
+
active root, filtered by `getSwapAdditionalData(selfUuid)` (using `swapTableSkin`).
|
|
103
|
+
|
|
104
|
+
`additionalData` is produced per-level by callbacks (the WHERE-filter shape from Pattern 4). Each
|
|
105
|
+
level runs its own `useServerTableUrlState` and strips its own slug key, isolating URL state.
|
|
106
|
+
A page-level `pageMeta` (from `useFetchPageMeta`) flows down for field-label overrides. The page
|
|
107
|
+
view model builds `TableLevel[]` arrays, picks the active one via a URL param, and passes `levels`
|
|
108
|
+
to `GenericNestedTables` with `key={activeGrouping.key}` to force remount and avoid stale state.
|
|
109
|
+
|
|
110
|
+
## Pattern 6 — Create/edit record modal (form-driven)
|
|
111
|
+
|
|
112
|
+
Scaffold per the `ItemRecordModalLayout` pattern (`src/layout/ItemRecordModalLayout/`). Form-driven
|
|
113
|
+
(react-hook-form + `FormProvider`), fields rendered through a section/field config + `DetailSection`
|
|
114
|
+
+ `BaseInput`, inside `TableRecordModal position="bottom" variant="expandable"`.
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
<Entity>RecordCreateModalLayout ← owns form hook, mounts TableRecordModal
|
|
118
|
+
└─ <Entity>RecordCreate ← FormProvider + form, renders sections
|
|
119
|
+
├─ use<Entity>CreateForm ← rhf state + apiPost submit
|
|
120
|
+
└─ use<Entity>RecordEditViewModel ← fetches advancedSelect options
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
- Form hook: `useForm({ defaultValues, mode: "onChange", reValidateMode: "onChange" })`; `onSubmit`
|
|
124
|
+
builds a payload (map relation objects to `.uuid`), `apiPost("/<entities>", payload)`, then
|
|
125
|
+
`queryClient.invalidateQueries({ queryKey: ["table-data"] })`, reset, `onCreated?.()`. Edit
|
|
126
|
+
variant: `form.reset(record)` in a `useEffect`, `apiPut`, expose `isEditing`/`onEnableEdit`/`onCancelEdit`.
|
|
127
|
+
- Field types: `text`, `textArea`, `toggle`, `advancedSelect`, `advancedMultiSelect`; mark required
|
|
128
|
+
with `isRequired` + `showRequiredIndicator`; feed select options via `optionsByFieldUuid` keyed by
|
|
129
|
+
field `uuid`. The edit view model that supplies options is **reused** by both create and edit.
|
|
130
|
+
- Page holds `isCreateOpen` state + a "New <Entity>" `BaseButton`, renders the layout with `isOpen`/`onClose`.
|
|
131
|
+
|
|
132
|
+
## Gotchas
|
|
133
|
+
|
|
134
|
+
- **Files use TABS** — match exactly or edits won't apply.
|
|
135
|
+
- **`BaseButton` is a default import:** `@agilant/toga-blox/dist/components/BaseButton/BaseButton.js`.
|
|
136
|
+
- **Submit payload diverges from form shape** — send relation `.uuid`, not the `{uuid,name}` object;
|
|
137
|
+
confirm field names against the real POST/PUT contract.
|
|
138
|
+
- **Nested tables need their own slug + URL-state isolation** or sibling tables clobber each other.
|
|
139
|
+
- **`additionalData` in the query key is mandatory** for WHERE-filtered modal tables (Pattern 4).
|
|
140
|
+
- **Per-consumer cache `scope`** — two consumers fetching the same record with different field
|
|
141
|
+
configs must use a `scope` discriminator in the query key or they overwrite each other's cache
|
|
142
|
+
(record modal vs. approval modal: the approval modal omitting item-image fields blanked the
|
|
143
|
+
record modal's images until scoping isolated the entries).
|
|
144
|
+
|
|
145
|
+
## Change history
|
|
146
|
+
- 2026-06-23 — Documented from CLAUDE.md Modal Patterns 1–5 + the `create-record-modal` skill during
|
|
147
|
+
initial knowledge seed. (apeterson)
|
package/knowledge/INDEX.md
CHANGED
|
@@ -27,6 +27,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
27
27
|
- **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
|
|
28
28
|
- **ai-bdr** (AI-BDR) — 4 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
|
|
29
29
|
- **toga2-commerce** (TOGa Commerce) — 2 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
|
|
30
|
+
- **toga25-supply** (TOGa 2.5 Supply) — 5 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
|
|
30
31
|
|
|
31
32
|
## standalone framework
|
|
32
33
|
|
|
@@ -49,4 +50,5 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
49
50
|
- **Rate** (`rate`) → [clients/rate/INDEX.md](clients/rate/INDEX.md)
|
|
50
51
|
- **Tow Foundation** (`tow-foundation`) → [clients/tow-foundation/INDEX.md](clients/tow-foundation/INDEX.md)
|
|
51
52
|
- **Walmart Client Profile** (`walmart`) → [clients/walmart/INDEX.md](clients/walmart/INDEX.md)
|
|
53
|
+
- **Wiss, Janney, Elstner Associates, Inc.** (`wje`) → [clients/wje/INDEX.md](clients/wje/INDEX.md)
|
|
52
54
|
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Client: Wiss, Janney, Elstner Associates, Inc. `wje`
|
|
2
|
+
|
|
3
|
+
| Doc | Framework | Summary | Files |
|
|
4
|
+
|-----|-----------|---------|-------|
|
|
5
|
+
| [Wiss, Janney, Elstner Associates, Inc.](profile.md) | 2.0 | Wiss, Janney, Elstner Associates, Inc. | worker/crons/toga2/wje/sync_togasupply_wje.php |
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Wiss, Janney, Elstner Associates, Inc."
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
apps:
|
|
5
|
+
- worker
|
|
6
|
+
- library
|
|
7
|
+
project: Library
|
|
8
|
+
client: wje
|
|
9
|
+
type: profile
|
|
10
|
+
status: active
|
|
11
|
+
updated: 2026-06-23
|
|
12
|
+
owners: [jcardinal]
|
|
13
|
+
files:
|
|
14
|
+
- worker/crons/toga2/wje/sync_togasupply_wje.php
|
|
15
|
+
related:
|
|
16
|
+
- ../../1.0/apps/library/features/toga2-api-client-and-bridge.md
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Summary
|
|
20
|
+
|
|
21
|
+
Wiss, Janney, Elstner Associates, Inc. (WJE) is a TOGa Desk client integrated through the
|
|
22
|
+
**1.0↔2.0 TOGaDesk bridge** (`App_Api_Toga2::syncWithTogadesk`). The cron
|
|
23
|
+
`worker/crons/toga2/wje/sync_togasupply_wje.php` runs every 2 minutes and enables the **full**
|
|
24
|
+
set of bridge sub-syncs (tickets both directions, ticket-notes, contacts→people, items/units→
|
|
25
|
+
assets, predefined-replies, ticket-teams→groups, ticket-categories) for TOGaDesk client id 153,
|
|
26
|
+
department 269 (plus internal-tickets department 290). WJE requires a group on every ticket and
|
|
27
|
+
falls back to a default group/agent when none is assigned.
|
|
28
|
+
|
|
29
|
+
See the bridge feature doc for the mechanism. No NetSuite TOGa Supply importer runs for WJE
|
|
30
|
+
(it is a help-desk integration, not a supply/procurement client).
|
package/knowledge/registry.json
CHANGED
|
@@ -158,9 +158,7 @@
|
|
|
158
158
|
"project": "TOGa Commerce",
|
|
159
159
|
"framework": "2.0",
|
|
160
160
|
"role": "app",
|
|
161
|
-
"dependsOn": [
|
|
162
|
-
"api2"
|
|
163
|
-
]
|
|
161
|
+
"dependsOn": ["api2"]
|
|
164
162
|
},
|
|
165
163
|
{
|
|
166
164
|
"repo": "toga",
|
|
@@ -170,5 +168,12 @@
|
|
|
170
168
|
"dependsOn": [
|
|
171
169
|
"library"
|
|
172
170
|
]
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"repo": "toga25-supply",
|
|
174
|
+
"project": "TOGa 2.5 Supply",
|
|
175
|
+
"framework": "2.0",
|
|
176
|
+
"role": "app",
|
|
177
|
+
"dependsOn": []
|
|
173
178
|
}
|
|
174
179
|
]
|
package/package.json
CHANGED