toga-ai 1.0.418 → 1.0.419
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/INDEX.md +1 -1
- package/knowledge/2.0/apps/_underscore/features/persona-name-translation.md +84 -50
- package/knowledge/2.0/apps/api2/features/language-translation-layer.md +33 -0
- package/knowledge/2.0/apps/toga2-commerce/INDEX.md +1 -1
- package/knowledge/2.0/apps/toga2-commerce/features/client-fields.md +25 -5
- package/knowledge/clients/compass-canada/profile.md +9 -6
- package/package.json +1 -1
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
| [_Model::save() vs raw _Query — no atomic conditional update](features/model-save-vs-query-atomic-update.md) | `_Model::save()` is a plain load-then-write ORM primitive and **cannot express an atomic conditional update** (an optimistic-concurrency / row-claim guard such | _underscore/Model.php, _underscore/Query.php |
|
|
23
23
|
| [NetSuite REST Client (_Component_Api_Netsuite) — record writes & SuiteQL](features/netsuite-rest-client.md) | `_Component_Api_Netsuite` is the **2.0 `_underscore` NetSuite REST client** — the shared primitive every worker2/api2 NetSuite caller uses for record GETs, Suit | _underscore/Component/Api/Netsuite/Netsuite.php |
|
|
24
24
|
| [Per-Client Database Connections & the Local Logs Trap](features/per-client-database-connections.md) | When `_underscore` serves a request for a client it opens **three distinct per-client database connections**, not one. | _underscore/Database.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php |
|
|
25
|
-
| [Persona Name Translation (PersonaTranslations sidecar)](features/persona-name-translation.md) | Serves Persona **names** in multiple languages by adding a per-language **sidecar** table `PersonaTranslations`, reusing the platform's existing metadata-driven | _underscore/Model/Client/PersonaTranslation.php, dbchanges2/Client/2026-07-
|
|
25
|
+
| [Persona Name Translation (PersonaTranslations sidecar)](features/persona-name-translation.md) | Serves Persona **names** in multiple languages by adding a per-language **sidecar** table `PersonaTranslations`, reusing the platform's existing metadata-driven | _underscore/Model/Client/PersonaTranslation.php, dbchanges2/Client/2026-07-22b - PersonaTranslations.sql, dbchanges2/Core/2026-07-22a - PersonaTranslationsRecord.sql, dbchanges2/Client/2026-07-22c - PersonaTranslationsAcl.sql, dbchanges2/Client_CompassCanada/2026-07-22 - PersonaTranslationsFrench.sql, toga2-commerce/src/pages/Account/view/MySettingsView.tsx |
|
|
26
26
|
| [Recursive Item Fulfillments (upstream mirroring)](features/recursive-item-fulfillments.md) | In a multi-tier supply chain a sales order (SO) spawns a purchase order (PO) that becomes another SO downstream, and so on. | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemFulfillmentItem.php, _underscore/Model/Client/ItemFulfillmentItemUnit.php, _underscore/Model/Client/ItemFulfillmentPackage.php, _underscore/Model/Compass/AdvanceShippingNotice.php, dbchanges2/Core/2026-02-13 - 75601 - RecursiveItemFulfillmentCreation.sql, dbchanges2/Core/2026-06-04 - RecursiveItemFulfillmentPut.sql, dbchanges2/Client_Compass/2026-07-02a - FixSA133377TrackingSerialAndDuplicateIF.sql |
|
|
27
27
|
| [Surface Resolver (_Model_Core_Surface::resolve — replaces Page::meta)](features/surface-resolver.md) | The runtime for the platform-wide **Surface** UI presentation layer: 9 `_underscore` models plus a cached resolver, `_Model_Core_Surface::resolve(&$api, string | _underscore/Model/Core/Surface.php, _underscore/Model/Client/AclRecordScript.php, _underscore/Model/Core/RecordScript.php, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Quad/2026-07-01a - GrantSurfacesMetaGroupScriptAcl.sql, dbchanges2/Client_CompassCanada/2026-07-01a - GrantSurfacesMetaGroupScriptAcl.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Client_Compass/2026-07-15f - SalesOrderRecordActionsRemoveDeadConfigRuleOverrides.sql, dbchanges2/Core/2026-07-17h - Update - ClearApprovalsFilterButtonConfig.sql, dbchanges2/Client_Compass/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Compass/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_Quad/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Quad/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, _underscore/Model/Core/SurfaceElement.php, _underscore/Model/Core/Action.php, _underscore/Model/Core/Vocabulary.php, _underscore/Model/Core/VocabularyTerm.php, _underscore/Model/Core/Message.php, _underscore/Model/Client/SurfaceOverride.php, _underscore/Model/Client/MessageTranslation.php, _underscore/Model/Client/ThemeToken.php, _underscore/Model/Core/Page.php, api2/Component/Api/V2/V2.php |
|
|
28
28
|
| [Table-View Hyperlink Columns (meta → ACL → computed URL → render)](features/tableview-hyperlink-columns.md) | Any 2.0 table-view column can render its value as a clickable link instead of plain text. | _underscore/Model/Client/TableView.php, _underscore/Model/Client/TrackingNumber.php, api2/Component/Api/V2/V2.php, toga2-supply/src/api/toga.ts, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/formatTableData.tsx, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/convertData.tsx, toga2-supply/src/components/ui/Tables/hooks/useDataTableState.tsx, dbchanges2/Client/2026-07-20 - TrackingNumberHyperlinkAndFieldPermission.sql |
|
|
@@ -6,14 +6,15 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: draft
|
|
9
|
-
updated: 2026-07-
|
|
10
|
-
owners: ["tcox"]
|
|
9
|
+
updated: 2026-07-23
|
|
10
|
+
owners: ["tcox", "bala"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model/Client/PersonaTranslation.php
|
|
13
|
-
- dbchanges2/Client/2026-07-
|
|
13
|
+
- dbchanges2/Client/2026-07-22b - PersonaTranslations.sql
|
|
14
14
|
- dbchanges2/Core/2026-07-22a - PersonaTranslationsRecord.sql
|
|
15
|
-
- dbchanges2/Client/2026-07-
|
|
16
|
-
- dbchanges2/Client_CompassCanada/2026-07-
|
|
15
|
+
- dbchanges2/Client/2026-07-22c - PersonaTranslationsAcl.sql
|
|
16
|
+
- dbchanges2/Client_CompassCanada/2026-07-22 - PersonaTranslationsFrench.sql
|
|
17
|
+
- toga2-commerce/src/pages/Account/view/MySettingsView.tsx
|
|
17
18
|
related:
|
|
18
19
|
- ./assortment-name-translation.md
|
|
19
20
|
- ../../api2/features/language-translation-layer.md
|
|
@@ -31,25 +32,28 @@ pattern. English stays in `Personas.name`; the sidecar holds only non-English ov
|
|
|
31
32
|
English fallback). Because the translation layer is fully metadata-driven (see
|
|
32
33
|
[Language Translation Layer](../../api2/features/language-translation-layer.md)), wiring this up
|
|
33
34
|
required **no api2 PHP logic change**: `GET /personas` names flow through `getTranslatedFieldValue`
|
|
34
|
-
at the top-level serialization sites.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
>
|
|
39
|
-
>
|
|
40
|
-
>
|
|
41
|
-
>
|
|
42
|
-
>
|
|
43
|
-
>
|
|
35
|
+
at the top-level serialization sites. Only `Personas.name` is translatable — `Personas.number` is a
|
|
36
|
+
code, not display text, and is left untranslated. Compass Canada's French (fr-CA) persona names are
|
|
37
|
+
the first (and so far only) intended consumer.
|
|
38
|
+
|
|
39
|
+
> **Status (2026-07-23): backend artifacts FINALIZED (ids verified against prod), NOT yet run;
|
|
40
|
+
> fr-CA seed values PENDING business sign-off.** The Core ids were re-based this session against a
|
|
41
|
+
> confirmed prod snapshot — `MAX(Core.Records) = 346`, `MAX(Core.RecordFields) = 2462` at capture
|
|
42
|
+
> time — and the sidecar now takes **Record 348 / RecordFields 2469–2473**, following the
|
|
43
|
+
> `NotificationTranslations` sidecar (347 / 2463–2468) that landed just ahead of it. The five
|
|
44
|
+
> artifacts still live in working trees (not deployed). The French persona names are **proposed
|
|
45
|
+
> translations that still need Compass business sign-off** — brand/sector proper nouns (Eurest,
|
|
46
|
+
> Chartwells, ESFM, Foodbuy) must be confirmed before the seed is run. Deploy order:
|
|
47
|
+
> **table → record → acl → French backfill.**
|
|
44
48
|
|
|
45
49
|
## Key files / entry points
|
|
46
50
|
|
|
47
51
|
- `_underscore/Model/Client/PersonaTranslation.php` — the sidecar model
|
|
48
52
|
(`_Model_Client_PersonaTranslation`), a declarative copy of `AssortmentTranslation.php`.
|
|
49
|
-
- `dbchanges2/Client/2026-07-
|
|
53
|
+
- `dbchanges2/Client/2026-07-22b - PersonaTranslations.sql` — the sidecar table (every client DB).
|
|
50
54
|
- `dbchanges2/Core/2026-07-22a - PersonaTranslationsRecord.sql` — Core record + fields + the field link.
|
|
51
|
-
- `dbchanges2/Client/2026-07-
|
|
52
|
-
- `dbchanges2/Client_CompassCanada/2026-07-
|
|
55
|
+
- `dbchanges2/Client/2026-07-22c - PersonaTranslationsAcl.sql` — Base-role ACL grant (every client DB).
|
|
56
|
+
- `dbchanges2/Client_CompassCanada/2026-07-22 - PersonaTranslationsFrench.sql` — fr-CA seed.
|
|
53
57
|
|
|
54
58
|
## How it works
|
|
55
59
|
|
|
@@ -64,9 +68,12 @@ The mechanism itself is the data-driven translation layer documented in
|
|
|
64
68
|
2. **Model.** `_Model_Client_PersonaTranslation` is the ORM model for the sidecar (declarative copy
|
|
65
69
|
of `AssortmentTranslation.php`).
|
|
66
70
|
3. **Core record registration.** `Core.Records` registers route `persona-translations` →
|
|
67
|
-
model `\_Model_Client_PersonaTranslation` at record **id
|
|
68
|
-
`Core.RecordFields` rows **
|
|
69
|
-
`UPDATE ... JOIN` that sets `translationRecordFieldId`
|
|
71
|
+
model `\_Model_Client_PersonaTranslation` at record **id 348** (verified next-free after
|
|
72
|
+
`NotificationTranslations` = 347), with `Core.RecordFields` rows **2469–2473** for `id`, `uuid`,
|
|
73
|
+
`personaId`, `languageId`, `name`, and an `UPDATE ... JOIN` that sets `translationRecordFieldId`
|
|
74
|
+
on the `Personas.name` RecordField (source route `personas`, field `name`). These ids are
|
|
75
|
+
**env-specific** — they follow prod's confirmed maxima at capture time (`Records` 346 /
|
|
76
|
+
`RecordFields` 2462) and the intervening `NotificationTranslations` record (347 / 2463–2468).
|
|
70
77
|
4. **The field link (what makes it translate automatically).** The translatable `Personas.name`
|
|
71
78
|
RecordField gets its `Core.RecordFields.translationRecordFieldId` set to the sidecar's `name`
|
|
72
79
|
RecordField. The api2 V2 layer reads `translationRecordFieldId` for every field and automatically
|
|
@@ -76,57 +83,84 @@ The mechanism itself is the data-driven translation layer documented in
|
|
|
76
83
|
(Contrast the assortment sidecar, which additionally needed a `join=`/joined-select fix — persona
|
|
77
84
|
names are not surfaced via a joined-select read path, so that 5th-serialization-site concern does
|
|
78
85
|
not apply here.)
|
|
79
|
-
5. **ACL.** A full ACL chain grants the **Base** role
|
|
80
|
-
record's `aclDatabase = 'CLIENT'`, the grant lives in
|
|
81
|
-
[ACL Permission Chain](./acl-permission-chain.md).
|
|
86
|
+
5. **ACL.** A full ACL chain grants the **Base** role CRUD on the new record (field perms: `id`/`uuid`
|
|
87
|
+
read-only, the rest writable). Because the record's `aclDatabase = 'CLIENT'`, the grant lives in
|
|
88
|
+
each client DB — see [ACL Permission Chain](./acl-permission-chain.md). The chain binds
|
|
89
|
+
`AclLogicGroups`/`AclLogicGroupExpressions` by **UUID** rather than `MAX(id)` — the newer, safer
|
|
90
|
+
pattern that avoids cross-wiring when ACL inserts are interleaved.
|
|
82
91
|
|
|
83
92
|
## Data model
|
|
84
93
|
|
|
85
94
|
- `Client.PersonaTranslations` — `id, uuid, dtCreated, dtUpdated, personaId (FK Personas),
|
|
86
95
|
languageId (FK Languages), name`; `UNIQUE(personaId, languageId)`; `utf8mb4_unicode_ci`. English
|
|
87
96
|
stays in `Personas.name`; sidecar holds only non-English overlays (null → English fallback).
|
|
88
|
-
- `Core.Records` **
|
|
89
|
-
`Core.RecordFields` **
|
|
97
|
+
- `Core.Records` **348** = `persona-translations` (`aclDatabase = 'CLIENT'`);
|
|
98
|
+
`Core.RecordFields` **2469–2473** for id/uuid/personaId/languageId/name (env-specific — follow
|
|
99
|
+
`NotificationTranslations` 347 / 2463–2468, which follows prod max `Records` 346 / `RecordFields` 2462).
|
|
90
100
|
- `Personas.name` RecordField → `translationRecordFieldId` points at the sidecar `name` RecordField.
|
|
101
|
+
Only `Personas.name` is translatable (`Personas.number` is a code, left as-is).
|
|
91
102
|
|
|
92
103
|
## Rollout / pre-run verification (do this BEFORE executing)
|
|
93
104
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
2. **
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
3. **Confirm the glossary is complete.** The business-glossary screenshot the fr-CA text came from was
|
|
107
|
-
cut off after "Managers/VIPs → Gestionnaires/VIP" — confirm there are no additional personas below
|
|
108
|
-
that line before treating the seed as complete.
|
|
105
|
+
Before running:
|
|
106
|
+
|
|
107
|
+
1. **Core ids are re-based (env-specific).** Ids were confirmed against a prod snapshot this session —
|
|
108
|
+
`MAX(Core.Records) = 346`, `MAX(Core.RecordFields) = 2462` — so the sidecar takes Record **348** /
|
|
109
|
+
RecordFields **2469–2473**, immediately after the `NotificationTranslations` sidecar (347 /
|
|
110
|
+
2463–2468). If the target env's maxima differ, renumber the record/field ids in
|
|
111
|
+
`PersonaTranslationsRecord.sql` and the ACL SQL.
|
|
112
|
+
2. **French names need Compass business sign-off.** The fr-CA seed backfills **all 34 Canada personas**,
|
|
113
|
+
but the French text is **proposed**, not approved. Brand/sector proper nouns (Eurest, Chartwells,
|
|
114
|
+
ESFM, Foodbuy) in particular must be confirmed with Compass before running the backfill.
|
|
115
|
+
3. **Confirm no duplicate persona names** if any rows are matched by name rather than uuid (a name-match
|
|
116
|
+
would double-seed).
|
|
109
117
|
|
|
110
118
|
fr-CA is `languageId 2` (same as the item-feature translations — see the Compass Canada profile and
|
|
111
119
|
`UserGlobalSettings.settingId = 2`).
|
|
112
120
|
|
|
113
121
|
## Frontend consumption (toga2-commerce)
|
|
114
122
|
|
|
115
|
-
|
|
116
|
-
switch
|
|
117
|
-
`
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
Getting the translated name to appear on the storefront **without a full reload** after a language
|
|
124
|
+
switch is subtler than cache-invalidation alone. The displayed persona name comes from
|
|
125
|
+
`user._personaOptions` in the **persisted Zustand `user` store** (localStorage), which is populated
|
|
126
|
+
**once at login** (`getLoginSettings` → `NavIconList`; `LoginSettingsSwitcher` only fetches
|
|
127
|
+
`if (!user._personaOptions)`) and is **not** driven by a live React Query. So adding the persona
|
|
128
|
+
query keys to `LANGUAGE_SENSITIVE_QUERY_KEYS` in `src/queries/queries.ts` is **not sufficient** —
|
|
129
|
+
those keys don't exist for `_personaOptions`, and invalidation cannot refresh a value that no live
|
|
130
|
+
query owns. (Before the fix, switching language in My Settings left the old-language persona name
|
|
131
|
+
on screen until a full re-login.)
|
|
132
|
+
|
|
133
|
+
**The fix (surgical, language-branch only, safe for all clients).** In
|
|
134
|
+
`src/pages/Account/view/MySettingsView.tsx`, `saveAllChanges` `case 'language'`: after
|
|
135
|
+
`invalidateLanguageSensitiveQueries` and after `refreshAuthTokens` (so the new JWT carries the new
|
|
136
|
+
`id.language`), explicitly **refetch `fetchUserPersona(user)`** with the refreshed token and assign
|
|
137
|
+
`userUpdates._personaOptions` (applied by the existing `setUser` spread). The refetch is wrapped in
|
|
138
|
+
its **own try/catch** so a persona-refresh failure never rolls back the just-saved language. The
|
|
139
|
+
canonical `_personaOptions` shape is `{ number, name }[]` (`globalTypes.ts`).
|
|
140
|
+
|
|
141
|
+
The **same latent gap still exists** in `components/LoginSettingsSwitcher/LoginSettingsSwitcher.tsx`
|
|
142
|
+
(the login-modal switcher), left unchanged this session by request. See
|
|
143
|
+
[Client Fields](../../toga2-commerce/features/client-fields.md) for the storefront language-switch
|
|
144
|
+
machinery. This frontend fix shipped this session; the backend sidecar above did not.
|
|
121
145
|
|
|
122
146
|
## Client variations
|
|
123
147
|
|
|
124
|
-
- **Compass Canada** is the intended first consumer: `2026-07-
|
|
125
|
-
seeds fr-CA names for
|
|
126
|
-
see the Compass Canada profile.
|
|
148
|
+
- **Compass Canada** is the intended first consumer: `2026-07-22 - PersonaTranslationsFrench.sql`
|
|
149
|
+
seeds fr-CA names for all **34** Canada personas. Not yet run; the French text is proposed and
|
|
150
|
+
needs Compass business sign-off — see the Compass Canada profile.
|
|
127
151
|
|
|
128
152
|
## Change history
|
|
129
153
|
|
|
154
|
+
- 2026-07-23 — Re-based the Core ids against a confirmed prod snapshot (max `Records` 346 /
|
|
155
|
+
`RecordFields` 2462): the sidecar now takes Record **348** / RecordFields **2469–2473**, following
|
|
156
|
+
the `NotificationTranslations` sidecar (347 / 2463–2468). ACL chain now binds
|
|
157
|
+
`AclLogicGroups`/`AclLogicGroupExpressions` by **UUID** (not `MAX(id)`). fr-CA seed expanded to all
|
|
158
|
+
**34** Canada personas (French text still **proposed**, needs Compass sign-off). Fixed the
|
|
159
|
+
storefront language switch: `_personaOptions` lives in the persisted Zustand `user` store (not a
|
|
160
|
+
live React Query), so `LANGUAGE_SENSITIVE_QUERY_KEYS` alone can't refresh it — `MySettingsView.tsx`
|
|
161
|
+
`case 'language'` now explicitly refetches `fetchUserPersona(user)` with the refreshed JWT (own
|
|
162
|
+
try/catch). The `LoginSettingsSwitcher` modal has the same gap, left unchanged by request. Backend
|
|
163
|
+
sidecar still not run. (bala)
|
|
130
164
|
- 2026-07-22 — Drafted the `PersonaTranslations` sidecar end-to-end following the
|
|
131
165
|
`AssortmentTranslations` pattern exactly: sidecar table + `_Model_Client_PersonaTranslation` model +
|
|
132
166
|
Core record 346 / RecordFields 2458–2462 registration + `translationRecordFieldId` link on
|
|
@@ -137,12 +137,37 @@ no extra model load, output unchanged). This is why a metadata-only sidecar (e.g
|
|
|
137
137
|
still needed an api2 code change to translate on the `join=` read path even though FK/top-level reads
|
|
138
138
|
worked with no code change.
|
|
139
139
|
|
|
140
|
+
**Sidecar source-FK derivation.** `loadTranslationSidecar()` figures out which sidecar column holds
|
|
141
|
+
the source row's id **generically** — it takes the sidecar record's FK field that is **not**
|
|
142
|
+
`languageId` as the source FK, then loads the sidecar row by `(sourceFkField = sourceId, languageId)`
|
|
143
|
+
via `_Model::load()` (no JOIN), cached per source row. This is why a new sidecar needs no per-table
|
|
144
|
+
wiring in api2: as long as the sidecar has exactly `{sourceFkId, languageId, <translated fields>}`,
|
|
145
|
+
the layer discovers the source FK from metadata.
|
|
146
|
+
|
|
140
147
|
**Write path.** On create and update, `extractTranslationWrites()` pulls translatable fields out of
|
|
141
148
|
the write set for a non-base language (so the English source is never overwritten), and after the
|
|
142
149
|
source row saves, `saveTranslationWrites()` upserts them into the sidecar for the current language.
|
|
143
150
|
Nested-child translatable writes are NOT auto-redirected — use the dedicated `/v2/item-translations`
|
|
144
151
|
endpoint for those.
|
|
145
152
|
|
|
153
|
+
**Adding a new translatable field is DB-metadata-only (no api2 edit) for top-level/FK reads.** The
|
|
154
|
+
whole layer is data-driven, so making a new field translatable requires **no PHP change in api2** when
|
|
155
|
+
the field is emitted at a top-level or FK serialization site (only the `join=`/joined-select read path,
|
|
156
|
+
site 5, ever needed a code change — see the joined-select note above). The recipe:
|
|
157
|
+
|
|
158
|
+
1. Set `Core.RecordFields.translationRecordFieldId` on the source field's RecordField to point at the
|
|
159
|
+
sidecar's translated-field RecordField (an `UPDATE … JOIN` matching source route + field name).
|
|
160
|
+
2. Create the `<Entity>Translations` sidecar table (`{ …, <entity>Id FK, languageId FK, <fields> }`,
|
|
161
|
+
`UNIQUE(<entity>Id, languageId)`) and its `_Model_Client_<Entity>Translation` model.
|
|
162
|
+
3. Register the Core `Record` + `RecordFields` for the sidecar route.
|
|
163
|
+
4. Grant the Base-role ACL chain on the new record (client DB).
|
|
164
|
+
5. Backfill the non-English overlay rows.
|
|
165
|
+
|
|
166
|
+
`getTranslatedFieldValue()`/`shouldTranslate()` then substitute the sidecar value on read for
|
|
167
|
+
non-base languages automatically; English/API/public traffic is byte-identical. The
|
|
168
|
+
[Persona Name Translation](../../_underscore/features/persona-name-translation.md) sidecar is a
|
|
169
|
+
worked example of this recipe (and confirmed it needed **zero** api2 changes).
|
|
170
|
+
|
|
146
171
|
## Data model
|
|
147
172
|
|
|
148
173
|
- `Client.ItemTranslations` — `id, uuid, dtCreated, dtUpdated, itemId (FK Items, RESTRICT),
|
|
@@ -239,6 +264,12 @@ None — uniform across all clients. The sidecar table + ACL ship via `dbchanges
|
|
|
239
264
|
|
|
240
265
|
## Change history
|
|
241
266
|
|
|
267
|
+
- 2026-07-23 — Documented (no code change) that the layer is fully generic: `loadTranslationSidecar()`
|
|
268
|
+
derives the source FK as "the sidecar FK that is not `languageId`" and loads by `(sourceFk, languageId)`
|
|
269
|
+
via `_Model::load()`, so adding a new translatable field is **DB-metadata-only** (set
|
|
270
|
+
`translationRecordFieldId` + sidecar table/model + Core record/fields + ACL + backfill) with **no api2
|
|
271
|
+
edit** for top-level/FK reads — only the `join=` read path (site 5) ever needed code. Confirmed via the
|
|
272
|
+
`PersonaTranslations` sidecar, which required zero api2 changes. (bala)
|
|
242
273
|
- 2026-07-21 — Wired translation into the **joined-select-fields** path (the 5th and final read-path
|
|
243
274
|
serialization site) in `V2.php` `processRoutePairs` (joined-select SQL build ~L3700-3800, serialization
|
|
244
275
|
~L4038-4060). `join=` fields (e.g. `Assortments.name`) previously copied the raw SQL column straight into
|
|
@@ -285,3 +316,5 @@ None — uniform across all clients. The sidecar table + ACL ship via `dbchanges
|
|
|
285
316
|
|
|
286
317
|
- [ACL Permission Chain](./acl-permission-chain.md)
|
|
287
318
|
- [api2 Architecture](../architecture.md)
|
|
319
|
+
- [Persona Name Translation](../../_underscore/features/persona-name-translation.md) — metadata-only
|
|
320
|
+
worked example (no api2 change).
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
| [Cart Bundle Submission & the bundleUuid Identity Contract](features/cart-bundle-submission-and-identity.md) | How cart **bundles** (kits) are turned into `SalesOrderItems` when a cart is submitted or an existing order is edited, and the **identity-field contract** every | src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts, src/utils/formatSalesOrderBundlesFromApi.ts, src/stores/useCartStoreZu.ts, src/pages/OrderDetails/helpers/formatSalesOrderDataFromLocalStorage.ts, src/pages/OrderDetails/view/components/OrderItems.tsx |
|
|
7
7
|
| [Cart Notification Emails — duplicate prevention](features/cart-notification-emails.md) | On the cart "Notifications" section a user can add CC email addresses to an order. | src/pages/Cart/CartPage.tsx, src/pages/Cart/view/cartForm/CartForm.tsx, src/stores/useEmailOptionsStore.ts, src/stores/useCartSalesQuoteZu.ts, src/pages/Cart/viewModel/FIELDS/*/*/*/CARTPAGE.ts |
|
|
8
8
|
| [Cart Page — config-driven form architecture (current state + planned refactor)](features/cart-page-config-architecture.md) | The Cart page (`src/pages/Cart/`) is the most config-heavy page in `toga2-commerce`. | src/pages/Cart/CartPage.tsx, src/pages/Cart/view/cartForm/CartForm.tsx, src/pages/Cart/view/cartForm/CartFormSection.tsx, src/pages/Cart/view/cartForm/CartFormRenderer.tsx, src/pages/Cart/view/EditCart.tsx, src/pages/Cart/view/EditOrder.tsx, src/pages/Cart/viewModel/useEditOrderOrEditCartViewModel.ts, src/pages/Cart/viewModel/FIELDS/*/*/*/CARTPAGE.ts, src/hooks/useAssignClientFields.ts |
|
|
9
|
-
| [Client Fields — per-tenant / language / role content & config](features/client-fields.md) | Almost no user-facing text, field layout, or page config is hard-coded in `toga2-commerce`. | src/fieldsConfig/index.ts, src/fieldsConfig/getClientLoginFields.ts, src/fieldsConfig/clientFields/COMPASS.json, src/fieldsConfig/clientFields/COMPASSCANADA.json, src/fieldsConfig/clientFields/QUAD.json, src/pages/Cart/api/CartApi.ts, src/hooks/useAuthenticationFlow.ts, src/contexts/AuthContext.tsx, src/pages/Login/viewModel/useLoginPageViewModel.ts, src/hooks/useAssignClientFields.ts, src/hooks/useDynamicConditionalFieldOptions.ts, src/stores/useFieldsStore.ts, src/components/BaseDetailField/BaseDetailField.tsx, src/components/NavIcons/NavIconItem.tsx, src/components/Submenus/AlertSubmenu.tsx, src/components/Submenus/types.ts, src/pages/Account/AccountPage.tsx, src/pages/Account/view/MyOrdersView.tsx, src/pages/GetSupport/GetSupportPage.tsx, src/pages/GetSupport/viewModel/useGetSupportViewModel.ts, src/queries/queries.ts |
|
|
9
|
+
| [Client Fields — per-tenant / language / role content & config](features/client-fields.md) | Almost no user-facing text, field layout, or page config is hard-coded in `toga2-commerce`. | src/pages/Account/view/MySettingsView.tsx, src/fieldsConfig/index.ts, src/fieldsConfig/getClientLoginFields.ts, src/fieldsConfig/clientFields/COMPASS.json, src/fieldsConfig/clientFields/COMPASSCANADA.json, src/fieldsConfig/clientFields/QUAD.json, src/pages/Cart/api/CartApi.ts, src/hooks/useAuthenticationFlow.ts, src/contexts/AuthContext.tsx, src/pages/Login/viewModel/useLoginPageViewModel.ts, src/hooks/useAssignClientFields.ts, src/hooks/useDynamicConditionalFieldOptions.ts, src/stores/useFieldsStore.ts, src/components/BaseDetailField/BaseDetailField.tsx, src/components/NavIcons/NavIconItem.tsx, src/components/Submenus/AlertSubmenu.tsx, src/components/Submenus/types.ts, src/pages/Account/AccountPage.tsx, src/pages/Account/view/MyOrdersView.tsx, src/pages/GetSupport/GetSupportPage.tsx, src/pages/GetSupport/viewModel/useGetSupportViewModel.ts, src/queries/queries.ts |
|
|
10
10
|
| [Config-Driven Expedited Shipping Gating (Cart)](features/expedited-shipping-gating.md) | On the toga2-commerce **Cart** page, expedited shipping options (**"2nd Day EOB"** and **"Next Day Air"**) are only offered in the *Shipping Method* dropdown wh | toga2-commerce/src/pages/Cart/helpers/shippingOptionGates.ts, toga2-commerce/src/pages/Cart/viewModel/FIELDS/shared/shippingOptionGates.ts, toga2-commerce/src/pages/Cart/view/cartForm/CartForm.tsx, toga2-commerce/src/pages/Cart/CartPage.tsx |
|
|
11
11
|
| [Multi-Tenant Resolution & Theming](features/multi-tenant-theming.md) | `toga2-commerce` serves multiple clients from one codebase. | src/themeConfig/themes.json, src/themeConfig/ThemeContext.tsx, src/themeConfig/types.ts, src/components/ThemeSwitcher/ThemeSwitcher.tsx, src/components/AuthLayout/AuthLayout.tsx, src/api/axiosInstance.ts, src/contexts/AuthContext.tsx, tailwind.config.js |
|
|
12
12
|
| [AWS Amplify Build & Deploy (non-prod environments)](workflows/amplify-build-and-deploy.md) | How `toga2-commerce` (React + Vite, "commerce2-react") builds and deploys on **AWS Amplify**. | toga2-commerce/amplify.yml, toga2-commerce/.gitattributes, toga2-commerce/package.json, toga2-commerce/.github/workflows/sync-stage-environments.yml |
|
|
@@ -6,9 +6,10 @@ project: TOGa Commerce
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
10
|
-
owners: ["apeterson", "tcox"]
|
|
9
|
+
updated: 2026-07-23
|
|
10
|
+
owners: ["apeterson", "tcox", "bala"]
|
|
11
11
|
files:
|
|
12
|
+
- src/pages/Account/view/MySettingsView.tsx
|
|
12
13
|
- src/fieldsConfig/index.ts
|
|
13
14
|
- src/fieldsConfig/getClientLoginFields.ts
|
|
14
15
|
- src/fieldsConfig/clientFields/COMPASS.json
|
|
@@ -292,9 +293,21 @@ via the [Persona Name Translation](../_underscore/features/persona-name-translat
|
|
|
292
293
|
cached by React Query and does **not** refetch on a language switch by default. `src/queries/queries.ts`
|
|
293
294
|
keeps a `LANGUAGE_SENSITIVE_QUERY_KEYS` allow-list of query keys to invalidate/refetch when the
|
|
294
295
|
language changes. The persona query keys `userPersonaDataOnLogin` and `userPersonaDataOnViewAs` were
|
|
295
|
-
added to it 2026-07-22
|
|
296
|
-
|
|
297
|
-
|
|
296
|
+
added to it 2026-07-22. When you add a new API-driven string that must respond to the language
|
|
297
|
+
switcher, add its query key here.
|
|
298
|
+
|
|
299
|
+
**Caveat — invalidation only refreshes values a live query actually owns.** This allow-list is
|
|
300
|
+
necessary but **not sufficient** for the persona *name*: the displayed name comes from
|
|
301
|
+
`user._personaOptions` in the **persisted Zustand `user` store** (localStorage), populated once at
|
|
302
|
+
login and **not** driven by a live React Query — so no key in `LANGUAGE_SENSITIVE_QUERY_KEYS` refreshes
|
|
303
|
+
it. Switching language must **explicitly refetch** the persona list:
|
|
304
|
+
`src/pages/Account/view/MySettingsView.tsx` `saveAllChanges` `case 'language'` now calls
|
|
305
|
+
`fetchUserPersona(user)` (with the post-`refreshAuthTokens` JWT) and reassigns `_personaOptions` via
|
|
306
|
+
`setUser`, in its own try/catch so a persona-refetch failure can't roll back the saved language. The
|
|
307
|
+
login-modal `LoginSettingsSwitcher.tsx` still has this gap. Before this fix the persona name stayed in
|
|
308
|
+
the old language until a full re-login. Takeaway: for translated data held in a **persisted store**
|
|
309
|
+
rather than a live query, a `LANGUAGE_SENSITIVE_QUERY_KEYS` entry does nothing — refetch it explicitly
|
|
310
|
+
on switch.
|
|
298
311
|
|
|
299
312
|
## Gotchas
|
|
300
313
|
|
|
@@ -326,6 +339,13 @@ key here.
|
|
|
326
339
|
slip. Do not "reconcile" the two lists.
|
|
327
340
|
|
|
328
341
|
## Change history
|
|
342
|
+
- 2026-07-23 — **Correction:** clarified that `LANGUAGE_SENSITIVE_QUERY_KEYS` alone does **not**
|
|
343
|
+
refresh the persona name on language switch — `user._personaOptions` lives in the persisted Zustand
|
|
344
|
+
`user` store (populated once at login), not a live React Query, so nothing in the allow-list owns it.
|
|
345
|
+
`MySettingsView.tsx` `case 'language'` now explicitly refetches `fetchUserPersona(user)` with the
|
|
346
|
+
refreshed JWT and reassigns `_personaOptions` (own try/catch); the `LoginSettingsSwitcher` modal
|
|
347
|
+
still has the gap. General rule recorded: translated data in a persisted store must be refetched
|
|
348
|
+
explicitly on switch. (bala)
|
|
329
349
|
- 2026-07-22 — **Correction:** the registry French key for COMPASSCANADA is **`fr-CA`, not `fr`**
|
|
330
350
|
(verified `src/fieldsConfig/index.ts`); documented language persistence (zustand `persist` key
|
|
331
351
|
`"fields-key"`, `setLanguage` stores the option uuid `en`/`fr-CA`) and the `startsWith("fr")` rule —
|
|
@@ -14,7 +14,7 @@ project: _Underscore
|
|
|
14
14
|
client: compass-canada
|
|
15
15
|
type: profile
|
|
16
16
|
status: active
|
|
17
|
-
updated: 2026-07-
|
|
17
|
+
updated: 2026-07-23
|
|
18
18
|
owners: [jcardinal, bala, tcox, apeterson]
|
|
19
19
|
files: []
|
|
20
20
|
related:
|
|
@@ -70,11 +70,14 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
|
|
|
70
70
|
(16 rows seeded). French was extracted from the old bilingual `"English/French"` `Assortments.name`
|
|
71
71
|
values, which were then cleaned to English-only. See
|
|
72
72
|
[Assortment Name Translation](../../2.0/apps/_underscore/features/assortment-name-translation.md).
|
|
73
|
-
- **Persona names (fr-CA) —
|
|
74
|
-
the assortment pattern (Core record
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
- **Persona names (fr-CA) — backend finalized, NOT yet run; French text needs business sign-off.** A
|
|
74
|
+
`PersonaTranslations` sidecar copying the assortment pattern (Core record **348** / RecordFields
|
|
75
|
+
2469–2473, after `NotificationTranslations` 347; ids re-based against a confirmed prod snapshot) with
|
|
76
|
+
a fr-CA seed for all **34** Canada personas is fully built in working trees but **not deployed**. The
|
|
77
|
+
French persona names are **proposed** and still need Compass business sign-off (brand/sector proper
|
|
78
|
+
nouns: Eurest, Chartwells, ESFM, Foodbuy). The storefront side is fixed: `MySettingsView` now
|
|
79
|
+
explicitly refetches the persona list on language switch (the persisted-store `_personaOptions` isn't
|
|
80
|
+
refreshed by cache invalidation alone). See
|
|
78
81
|
[Persona Name Translation](../../2.0/apps/_underscore/features/persona-name-translation.md).
|
|
79
82
|
- **Storefront French localization** (labels, submenus, orders-for-others, Get Support illustration) is
|
|
80
83
|
driven by the COMPASSCANADA FIELDS JSON; the registry language key is **`fr-CA`** (not `fr`). See
|
package/package.json
CHANGED