← back to index
V0.4 — Product Variants tab (Odoo-shape list, inline-edit 4 cells)PROD
Second half of the V0.3 split. Narong's Telegram, 2026-05-29 mid-V0.3: split Products
from Product Variants — Products page becomes template-only; variants need their own
surface because some properties are unique to variants (SKU, barcode, cost, on-hand). Shipped
same day after V0.3. Hard constraint: no archive on variants — to retire
a variant, admins go into the parent template and delete it.
Summary
- Status
- 9/9 prod · 51/51 regression = 60/60 · shipped
- Commit
- nix-cafe
f4175a0
- Files
- 4 new + 3 modified · ~700 LOC net · no migration · no schema · no backend change
- Surfaces
-
New
/cafe/products/variants route. Shared ProductsTabs
strip atop both Products + Variants pages. Shared FiltersMenu extracted
from V0.3 with a basePath prop so both pages reuse it. Inline-editable
cells: Internal Reference, Barcode,
Sales Price, Cost. Save-on-blur via
updateVariantCellsAction; optimistic UI with revert on failure; lazy
initializer per row (avoids the U6 hydration trap). Inventory columns
(On Hand / Forecasted / Unit) scaffolded as —
placeholders for the eventual inventory model.
9/9 prod checks ✓
| ✓ | Login via Commerce SSO |
| ✓ | Products page renders tab strip including Variants tab (testids products-tabs, products-tab-products, products-tab-variants) |
| ✓ | Click Variants tab → URL /cafe/products/variants → list renders with V0.4 testids (variants-title, variants-search-input, variants-list, per-row testid keyed by variantId) |
| ✓ | Search input narrows the visible row count (substring match on name / SKU / barcode / variant values) |
| ✓ | Inline-edit priceUsd: focus cell → type new value → Tab blur → action fires → success toast → DB confirms the new value on prod Supabase |
| ✓ | Reload /products/variants: cell still shows the new value (Hyperdrive-tx wrap on the DAO works as designed — same pattern as V0.3's listProductTemplatesForAdmin) |
| ✓ | Click variant Name link → existing /products/[templateId]/variants/[variantId] detail page loads (click-through preserved) |
| ✓ | Restore: revert priceUsd to snapshot via direct UPDATE (DB cleanup) |
| ✓ | No 5xx observed during the suite |
Regression sweep — 51/51 ✓
9/9 V0.4 + 51/51 regression = 60/60 prod tests green on karouna-dev.
| test-phase1-prod.mjs | 11/11 (solo-retry — 3rd consecutive demo-SSO cold-isolate flake; rule validated) |
| test-phase2-sso-outdoor-prod.mjs | 6/6 |
| test-m1-prod.mjs | 10/10 |
| test-r7-prod.mjs | 14/14 |
| test-r8-prod.mjs | 4/4 |
| test-phase2-cafe-multishop-prod.mjs (parallel) | 6/6 (19th consecutive first-attempt parallel green) |
Mid-Gate-2 finds
prodSql helper mangled multi-line SQL.
First prod-test run exited at the variant-snapshot SELECT — the SQL was split across
multiple lines for readability, which broke the
bash → node -e "…" → knex.raw escape chain (newlines inside the double-quoted
argument terminate the command). Documented gotcha in nix-cafe/AGENTS.md:
"the prodSql helper escape limits — for complex payloads, write a temp .mjs script that
uses knex directly". For one-shot SELECTs, single-line the SQL.
phase1 demo-SSO cold-isolate flake — 3rd consecutive burn.
Sweep hit 9/11 with the same "Navigate to Cafe (SSO works)" check bouncing to
/auth/login on demo. Solo retry: 11/11. The
feedback_phase1_demo_sso_solo_retry rule saved during V0.3 covers the
response shape; no new memo needed.
Architectural notes
- Shared FiltersMenu — extracted from V0.3's
products-list-client.tsx into app/(authed)/products/filters-menu.tsx
with a basePath prop. The variants page reuses it with customised labels
("From archived templates", "From hidden templates") since variants don't have their own
archive/hidden state — they inherit it from the parent template.
- Shared ProductsTabs —
products | variants tab strip
lives at app/(authed)/products/products-tabs.tsx and mounts atop both
pages. active prop drives the styling.
- Variant Values column assembled via a correlated subquery
(
string_agg(attr.name || ': ' || val.value, ', ' ORDER BY attr.name)) so
we don't have to GROUP BY every selected column on the variant row. Lands as
"Sleeves: Long, Size: M" Odoo-style.
- Hyperdrive-tx wrap on
listProductVariantsForAdmin
mirrors V0.3's lesson — inline cell edits need their own value back on the next
render, not 60s later.
- Lazy initializer (
useState(() => initial)) on every
InlineCell to avoid the U6 hydration trap (feedback_playwright_useState_dom_swap).
Pre-emptive given inline-edit lands new state-initialised-from-props patterns by
default.
- No bulk actions / no checkbox column for v1, per the scoped design
— Narong banned variant archive; the other Odoo bulk actions (Delete, Duplicate) are
destructive without per-row context. Add when a real bulk use case lands.
Followups
- Inventory model — On Hand / Forecasted / Unit currently render
as
—. When the inventory mirror lands (likely tied to the Sunmi POS shell
roadmap with integrated scanners), wire the columns up. Sort by On Hand should also be
enabled at that point.
- Bulk price update / barcode-regen actions — if cafés request them.
The V0.3 floating-bar component can be reused with a Variants-specific BulkActionsMenu.
- Compute Price from BoM — Odoo has it; NIX doesn't have a BoM model.
Defer indefinitely.