toga-ai 1.0.553 → 1.0.554

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.
@@ -47,7 +47,18 @@ controller.
47
47
  - **Pagination is `page` + `recordsPerPage`. There is no `limit`/`offset`.** Defaults:
48
48
  `recordsPerPage` **25**, `depth` **3**. Max `recordsPerPage` **10000** — exceeding it returns
49
49
  **`EV-13`** (HTTP 400).
50
- - **Sort:** `-field` = DESC, bare `field` = ASC. **Do not send `+`** for ascending.
50
+ - **Sort:** `-field` = DESC, `+field` = ASC, bare `field` = ASC. All three are handled
51
+ (`V2.php` ~L3673–3682). **Prefer bare for ascending:** the query string is parsed *without*
52
+ urldecoding (see Encoding rules), so a client that percent-encodes the `+` as `%2B` sends a
53
+ field literally named `%2Bfield` and the sort is silently dropped. Bare has no such failure mode.
54
+ - **A LIST select is `SELECT SQL_CALC_FOUND_ROWS DISTINCT <table>.<primaryKey>`** (`V2.php:3884`;
55
+ the aggregate-fields path is the one exception). Two consequences: (a) join fan-out collapses to
56
+ one row per record, so **duplicate child rows are invisible in the response** — a duplicate join
57
+ row is not a rendering bug you can see; (b) when the collapsed duplicates carry **different**
58
+ values in the sorted column, `ORDER BY` has more than one candidate value for a single output row
59
+ and that record's **position is non-deterministic**. Sorting on a joined column is only stable if
60
+ the join is genuinely 1:1, or the duplicates agree. Worked example:
61
+ [category tile sort order](../../toga2-commerce/features/category-tile-sort-order.md).
51
62
 
52
63
  ### ⚠ `depth` is not free — the default 3 re-reads child rows and races concurrent deletes
53
64
 
@@ -158,6 +169,15 @@ where=(field:op:value,LOGIC,field:op:value,(nested,OR,nested))
158
169
  invisible; re-request with `fields=` to force `EZ-2` and see the denied list.
159
170
 
160
171
  ## Change history
172
+ - 2026-08-11 — **Corrected the sort contract and documented the `DISTINCT` LIST select.** The old
173
+ "do not send `+`" was wrong as an absolute: `V2.php` ~L3673–3682 explicitly handles `+` as ASC.
174
+ The real caveat is encoding — the query string is parsed without urldecoding, so a
175
+ percent-encoded `+` (`%2B`) becomes part of the field name and the sort is silently dropped;
176
+ bare remains the recommended ascending form. Also recorded that a LIST select is
177
+ `SELECT SQL_CALC_FOUND_ROWS DISTINCT <table>.<primaryKey>` (`V2.php:3884`), which hides duplicate
178
+ join rows entirely **and** makes `ORDER BY` non-deterministic for a record whose collapsed
179
+ duplicates disagree on the sorted column. Found while diagnosing storefront category tile
180
+ ordering (duplicate `AssortmentItems` rows for one item). (bala)
161
181
  - 2026-08-11 — Recorded that **`depth` (default 3) makes a response race concurrent deletes**:
162
182
  `getFullModelData()` re-loads each child by primary key via `_Model::initialize()`, whose build
163
183
  **throws** when the row is gone (*"Exactly 1 row was expected to be returned but 0 were"*),
@@ -6,6 +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
+ | [Category Tile Order (AssortmentItems.sortOrder) — merchandising a storefront category](features/category-tile-sort-order.md) | **"Move item X to the front of category Y" is a DATA change, not a code change.** The order of item tiles on a storefront category page is driven by exactly one | src/pages/Filter/api/FilterApi.ts, src/pages/Filter/viewModel/useFilterViewModel.ts, api2/Component/Api/V2/V2.php, toga2-supply/src/pages/Items/api/itemsApi.ts |
9
10
  | [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/contexts/helpers/getLoginSettings.ts, src/pages/Account/viewModel/useAccountViewModel.ts, 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, src/App.tsx, src/pages/Filter/FilterPage.tsx, src/pages/Filter/viewModel/FIELDS/COMPASS/ENGLISH/USER/FILTERPAGEFIELDS.json |
10
11
  | [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
12
  | [Filter / Search-Results Page & the Two Search Entry Points](features/filter-search-results-page.md) | The storefront has **two distinct search entry points that render the same card component through completely different code paths and different FIELDS files**. | src/pages/Filter/FilterPage.tsx, src/pages/Filter/viewModel/useFilterViewModel.ts, src/pages/Filter/viewModel/FIELDS/COMPASS/ENGLISH/USER/FILTERPAGEFIELDS.json, src/pages/Filter/viewModel/FIELDS/COMPASSCANADA/FRENCH/USER/FILTERPAGEFIELDS.json, src/pages/Filter/viewModel/FIELDS/QUAD/ENGLISH/BUYER/FILTERPAGEFIELDS.json, src/components/Header/Header.tsx, src/pages/Home/view/components/BundlesSection.tsx, src/pages/Home/viewModel/FIELDS/COMPASS/ENGLISH/USER/HOMEPAGEFIELDS.json, src/components/Cards/BundleViewCard.tsx, src/utils/renderBadge.tsx, src/hooks/useAssignClientFields.ts |
@@ -0,0 +1,164 @@
1
+ ---
2
+ title: Category Tile Order (AssortmentItems.sortOrder) — merchandising a storefront category
3
+ framework: "2.0"
4
+ repo: toga2-commerce
5
+ project: TOGa Commerce
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-11
10
+ owners: [bala]
11
+ files:
12
+ - src/pages/Filter/api/FilterApi.ts
13
+ - src/pages/Filter/viewModel/useFilterViewModel.ts
14
+ - api2/Component/Api/V2/V2.php
15
+ - toga2-supply/src/pages/Items/api/itemsApi.ts
16
+ related:
17
+ - filter-search-results-page.md
18
+ - ../architecture.md
19
+ - ../../api2/features/v2-rest-query-contract.md
20
+ ---
21
+
22
+ ## Summary
23
+
24
+ **"Move item X to the front of category Y" is a DATA change, not a code change.** The order of
25
+ item tiles on a storefront category page is driven by exactly one column —
26
+ **`Client_<Client>.AssortmentItems.sortOrder`, ascending** — and nothing else. There is no
27
+ merchandising UI, no per-tenant config key, and no client-side re-sort to override.
28
+
29
+ The trap is that the obvious one-row `UPDATE` **does not work**: in MySQL `ORDER BY … ASC`,
30
+ `NULL` sorts **before** every number, and these columns are normally all `NULL`. Setting only the
31
+ target item to `1` leaves the still-`NULL` siblings ahead of it — the opposite of the request. You
32
+ must give **every active item in that assortment** an explicit `sortOrder`.
33
+
34
+ ## How it works
35
+
36
+ 1. **Category browse** is the `/filter` page with a `category` query param and no `search` text.
37
+ `useFilterViewModel.ts` runs the `["assortmentItems", categoryUuid, currentPage]` query, which
38
+ calls `fetchAssortmentItems()` in `src/pages/Filter/api/FilterApi.ts`.
39
+ 2. That request joins `Items → AssortmentItems → Assortments` (plus `Personas_Items → Personas`
40
+ for non-admins), filters to the requested assortment uuid with `Items.isActive = 1` and
41
+ `Items.catalogId = 1`, pages at `recordsPerPage: 12`, and sorts with exactly:
42
+
43
+ ```ts
44
+ sort: ["AssortmentItems.sortOrder"]
45
+ ```
46
+
47
+ 3. **api2 treats a bare (unprefixed) sort field as ASC.** (`-` = DESC, `+` = ASC — see
48
+ [v2-rest-query-contract](../../api2/features/v2-rest-query-contract.md).)
49
+ 4. **The frontend never re-sorts.** The view model does a straight
50
+ `setSortedItems(assortmentItems.data.items)` — despite the `sortedItems` name, no sorting
51
+ happens there. **The API's row order is the final on-screen order.**
52
+
53
+ So the only lever is the column. Ties (equal or all-`NULL` values) fall back to whatever MySQL
54
+ returns, which is not a documented or stable order.
55
+
56
+ ## Making the change (the procedure)
57
+
58
+ Assign an explicit `sortOrder` to **every active item** in the assortment — the two pinned ones,
59
+ then the rest. Pattern (`<assortmentId>` from `Assortments`, item ids from `Items`):
60
+
61
+ ```sql
62
+ # 1. pin the intended leaders
63
+ UPDATE AssortmentItems
64
+ SET sortOrder = 1
65
+ WHERE
66
+ assortmentId = <assortmentId> AND
67
+ itemId = <firstItemId>;
68
+
69
+ UPDATE AssortmentItems
70
+ SET sortOrder = 2
71
+ WHERE
72
+ assortmentId = <assortmentId> AND
73
+ itemId = <secondItemId>;
74
+
75
+ # 2. every remaining ACTIVE item must get a value, or a NULL row jumps to the front
76
+ UPDATE AssortmentItems ai
77
+ INNER JOIN Items i ON i.id = ai.itemId
78
+ SET ai.sortOrder = 10
79
+ WHERE
80
+ ai.assortmentId = <assortmentId> AND
81
+ ai.sortOrder IS NULL AND
82
+ i.isActive = 1;
83
+ ```
84
+
85
+ Verify with the read the storefront actually performs — ordered, not just spot-checked:
86
+
87
+ ```sql
88
+ SELECT
89
+ ai.id,
90
+ ai.itemId,
91
+ ai.sortOrder,
92
+ i.itemNumber,
93
+ i.isActive
94
+ FROM AssortmentItems ai
95
+ INNER JOIN Items i ON i.id = ai.itemId
96
+ WHERE ai.assortmentId = <assortmentId>
97
+ ORDER BY ai.sortOrder ASC;
98
+ ```
99
+
100
+ **Recommended, not required:** park **inactive** rows at a high value (e.g. `100`) as well. They
101
+ are filtered out today by `Items.isActive = 1`, so they cost nothing now — but if someone
102
+ reactivates one later it comes back with `sortOrder = NULL` and silently takes first position.
103
+
104
+ ## Gotchas
105
+
106
+ - **`NULL` sorts FIRST in ASC.** The single-row `UPDATE` that "obviously" pins an item produces the
107
+ opposite result while looking correct in isolation. Always set the whole active set.
108
+ - **Duplicate `AssortmentItems` rows exist and make order non-deterministic.** api2's LIST query
109
+ selects `SELECT SQL_CALC_FOUND_ROWS DISTINCT <table>.<primaryKey>` (`V2.php:3884`), so two
110
+ `AssortmentItems` rows for the same `itemId` collapse to **one visible tile** — which is why
111
+ nobody notices the duplicate. But if the duplicates carry **different** `sortOrder` values, the
112
+ `ORDER BY` has two candidate values for one output row and the position is unpredictable. When
113
+ duplicates exist, give them the **same** value or delete the extras. Check first:
114
+
115
+ ```sql
116
+ SELECT itemId, COUNT(*) AS rowCount
117
+ FROM AssortmentItems
118
+ WHERE assortmentId = <assortmentId>
119
+ GROUP BY itemId
120
+ HAVING rowCount > 1;
121
+ ```
122
+
123
+ - **⚠ Editing an item's assortments in TOGa Supply WIPES `sortOrder`.** See Known open items —
124
+ any ordering you set is one supply edit away from being erased. Re-verify order after anyone
125
+ edits that item in supply.
126
+ - **A refresh will NOT show the change.** The storefront persists its whole React Query cache to
127
+ `localStorage["commerce"]` with a 24h `staleTime`, so a warm browser replays the cached
128
+ response and the (correct) data fix looks like it failed — then gets "re-fixed". The reliable
129
+ check is **log out and back in** (logout removes the `commerce` key), or
130
+ `localStorage.removeItem('commerce')`; otherwise it appears within 24h. See
131
+ [architecture](../architecture.md) gotchas.
132
+ - **This is data, so it does not ride a deploy.** No build, no release notes — which also means no
133
+ audit trail. Record the assortment id and the values you set in the ticket.
134
+
135
+ ## Known open items
136
+
137
+ - **⚠ TOGa Supply resets `sortOrder` to `NULL` on any item-assortment edit — unfixed, no ticket
138
+ filed yet.** In `toga2-supply/src/pages/Items/api/itemsApi.ts`, `updateItemAssortments()`
139
+ (L645) reads the item's existing rows via `getAssortmentItems()` (L434), which requests only
140
+ `["assortment.name", "assortment.uuid", "uuid"]` — **never `sortOrder`** — then `DELETE`s every
141
+ row and `POST`s new ones whose payload carries only `item.uuid` + `assortment.uuid`. The
142
+ merchandising order is therefore silently destroyed and cannot be restored, because the old
143
+ values were never read. A fix must select `sortOrder`, and either preserve it on the re-POST or
144
+ stop the delete-then-recreate in favor of diffing.
145
+ - **Same function: the re-POSTs are never awaited.** `updateItemAssortments()` builds
146
+ `addPromises` and returns without `await Promise.all(addPromises)` (the `DELETE`s above it *are*
147
+ awaited). Failures surface as unhandled rejections and the caller cannot know the writes landed.
148
+ Fire-and-forget `POST`s racing an awaited `DELETE` burst is also the most likely origin of the
149
+ duplicate rows described in Gotchas. (Related: parallel deletes on a collection can 500 the
150
+ response of a concurrent read — see
151
+ [v2-rest-query-contract](../../api2/features/v2-rest-query-contract.md).)
152
+ - **Compass USA "Docking Station" reorder — SQL delivered, NOT executed.** See
153
+ [Compass USA profile](../../../clients/compass-usa/profile.md).
154
+
155
+ ## Change history
156
+ - 2026-08-11 — Initial doc, from a read-only prod investigation of a Compass USA request to move
157
+ one item to the front of a category. Recorded that tile order comes solely from
158
+ `AssortmentItems.sortOrder ASC` (`FilterApi.ts:56`) with no client-side re-sort in
159
+ `useFilterViewModel.ts`; the **`NULL`-sorts-first** trap that makes a single-row `UPDATE`
160
+ backfire (the real assortment had `sortOrder = NULL` on all 16 rows); api2's
161
+ `DISTINCT <primaryKey>` LIST select (`V2.php:3884`) hiding duplicate join rows while making
162
+ `ORDER BY` non-deterministic when duplicates disagree; and that toga2-supply's
163
+ `updateItemAssortments()` wipes `sortOrder` (and never awaits its re-POSTs), making any fix
164
+ non-durable. (bala)
@@ -7,7 +7,7 @@ client: shared
7
7
  type: feature
8
8
  status: active
9
9
  updated: 2026-07-28
10
- owners: ["tcox"]
10
+ owners: ["tcox", "bala"]
11
11
  files:
12
12
  - src/pages/Filter/FilterPage.tsx
13
13
  - src/pages/Filter/viewModel/useFilterViewModel.ts
@@ -24,6 +24,7 @@ related:
24
24
  - 2.0/apps/toga2-commerce/architecture.md
25
25
  - 2.0/apps/toga2-commerce/features/client-fields.md
26
26
  - 2.0/apps/toga2-commerce/features/multi-tenant-theming.md
27
+ - 2.0/apps/toga2-commerce/features/category-tile-sort-order.md
27
28
  ---
28
29
 
29
30
  ## Summary
@@ -53,6 +54,10 @@ bug (see Change history).
53
54
  tenant × language × role (see [client-fields](client-fields.md) for the resolver). `FilterPage`
54
55
  `renderBundles` maps each result to a `BundleViewCard`.
55
56
 
57
+ > **Third mode, same page:** with a `category` param and **no** `search` text, `/filter` is the
58
+ > category *browse* page and runs a different query (`fetchAssortmentItems`) whose row order is
59
+ > data-driven — see [category tile sort order](category-tile-sort-order.md).
60
+
56
61
  ### Home search → in-place filter
57
62
 
58
63
  The Home page's own lower search bar never leaves `/home`. `BundlesSection.tsx` reads
@@ -29,7 +29,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
29
29
  - **talos** (TOGa IQ) — 7 doc(s) → [2.0/apps/talos/INDEX.md](2.0/apps/talos/INDEX.md)
30
30
  - **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
31
31
  - **ai-bdr** (AI-BDR) — 9 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
32
- - **toga2-commerce** (TOGa Commerce) — 12 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
32
+ - **toga2-commerce** (TOGa Commerce) — 13 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
33
33
  - **toga25-supply** (TOGa 2.5 Supply) — 11 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
34
34
  - **toga-blox** (TOGa Blox) — 8 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
35
35
  - **bdr** (BDR) — 0 doc(s) → [2.0/apps/bdr/INDEX.md](2.0/apps/bdr/INDEX.md)
@@ -17,7 +17,7 @@ project: _Underscore
17
17
  client: compass-usa
18
18
  type: profile
19
19
  status: active
20
- updated: 2026-08-06
20
+ updated: 2026-08-11
21
21
  owners: [jcardinal, bala, tcox, apeterson, dfranks]
22
22
  files: []
23
23
  related:
@@ -32,6 +32,7 @@ related:
32
32
  - workflows/odp-duplicate-po-line-cleanup.md
33
33
  - ../../2.0/apps/worker2/features/compass-vip-support-importer.md
34
34
  - ../../2.0/apps/toga2-commerce/features/expedited-shipping-gating.md
35
+ - ../../2.0/apps/toga2-commerce/features/category-tile-sort-order.md
35
36
  - ../../2.0/apps/toga2-commerce/features/cart-bundle-submission-and-identity.md
36
37
  - ../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
37
38
  ---
@@ -61,6 +62,17 @@ separate, related client (see its own profile).
61
62
  **Known data issue:** many Compass MacBooks are categorized `APPLE LAPTOP` /
62
63
  `MAC & ACCESSORIES`, not `COMPUTERS`, so those kits do **not** qualify for expedited — a
63
64
  catalog-data normalization matter, not a code gap.
65
+ - **Category tile order is data, not code** — `Client_Compass.AssortmentItems.sortOrder ASC` is the
66
+ only lever; see
67
+ [Category Tile Order](../../2.0/apps/toga2-commerce/features/category-tile-sort-order.md) for the
68
+ `NULL`-sorts-first trap and the procedure.
69
+ **⚠ Pending prod data change (2026-08-11, delivered but NOT executed):** for
70
+ `Assortments.id = 9` ("Docking Station", all 16 rows had `sortOrder = NULL`), pin
71
+ `itemId 2712` / `9X3V1UT` → `1` and `itemId 2812` / `AW5M5UT-1` → `2`, and give the remaining
72
+ active rows an explicit value. Handed off for a DBA / the TOGa UI to run because session DB
73
+ access was read-only. Also outstanding for that assortment: park the 13 inactive rows at a high
74
+ `sortOrder`, and remove the duplicate `AssortmentItems` row `id 2982` (duplicate of `2981`, same
75
+ `itemId 2712`). Confirm whether this was applied before re-diagnosing tile order for Compass.
64
76
  - **Cross-kit bundle corruption (edit-order):** a `toga2-commerce` submit bug attributed kit
65
77
  line items and shared fees/warranties to the wrong kit; 55 Compass orders / 298 line items are
66
78
  corrupted in `Client_Compass.SalesOrderItems` (Canada and Quad: zero). The code fix is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.553",
3
+ "version": "1.0.554",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",