From f7c2067db5ec475bfef067fd86a033103599578d Mon Sep 17 00:00:00 2001 From: M Nindra Zaka Date: Thu, 1 Oct 2026 16:57:31 +0700 Subject: [PATCH 01/15] docs: add PRD for product tags feature (#665) * docs: add PRD for product tags and order app highlight sections Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFf8Zw5nWptg6TQFcXnQ6X * docs: settle product tags open questions Highlighted tags become filter chips, highlight entries sort newest-tagged first, and sections stay uncapped carousels. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DFf8Zw5nWptg6TQFcXnQ6X --------- Co-authored-by: Claude --- docs/prd-product-tags.md | 736 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 736 insertions(+) create mode 100644 docs/prd-product-tags.md diff --git a/docs/prd-product-tags.md b/docs/prd-product-tags.md new file mode 100644 index 00000000..b654ceae --- /dev/null +++ b/docs/prd-product-tags.md @@ -0,0 +1,736 @@ +# PRD: Product Tags — "Best Seller", "New", and Highlighted Menu Sections + +## Problem Statement + +The shop regularly has menu items it wants customers to notice first — a new Pancong topping, a +new seasonal drink, the coffee that sells the most. Today there is no way to say so anywhere in +the system: + +- **No tag concept exists.** A `Product` (`apps/api/domain/product_entity.go`) carries a single + `Category`, a `Status` (draft/published) and availability fields; a `Variant` + (`apps/api/domain/variant_entity.go`) carries price, materials, option values and availability. + Nothing can mark either as "new" or "best seller". +- **The order app menu is category-only.** `MenuListHandler.groupByCategory` + (`libs/ui/src/presentation/handlers/order/MenuListHandler.tsx`) turns the fetched products into + one group per category, and `MenuListScreen` renders those groups top to bottom. The customer + has no recommendation and cannot tell which items are new. +- **The product cards have nowhere to say it.** `MenuProductCard` + (`libs/ui/src/presentation/views/components/menu/MenuProductCard.tsx`) shows image, name, a + sold-out badge, search-match chips, description and starting price. The POS item picker's + `ProductListItem` (`libs/ui/src/presentation/views/components/products/ProductListItem.tsx`) + shows category, sale type, status and availability badges. Staff can't see it either. +- **A variant has no image.** `variants` (`000001_initial_schema.up.sql`) has no `image_url`; + only `products.image_url` exists. A "New: Pancong Ice Cream" card can only show the generic + Pancong photo. + +### Root cause + +"New" and "best seller" are facts about **what the customer can order**, and in this system what +the customer orders is a **variant** — the cart and the transaction both hold `variantId`, and a +product must have at least one variant to be sold at all. But the way people *talk* about these +facts is at whatever level is natural: "Coffee Latte is a best seller" (product), "the Ice Cream +Pancong is new" (one variant), "Salted Caramel Macchiato is new" (a product whose single +`Original` variant only exists because the system requires one). + +Any design has to store the fact at one level and *display* it at the level that is true. The +three cases from the brief are the test: + +| # | Item | Variants | What must be true on screen | +| --- | --- | --- | --- | +| 1 | **Pancong** | Choco, Matcha, Vanilla, **Ice Cream** | Pancong is **not** new. The Ice Cream variant **is** new, and the "New" section shows a *Pancong · Ice Cream* card with the Ice Cream image. | +| 2 | **Salted Caramel Macchiato** | Original (only one) | The **product** is new. The "New" section shows the product card, not a variant card. The admin never has to think about "Original". | +| 3 | **Coffee Latte** | Hot, Iced | The **product** is a best seller (both temperatures). The "Best Seller" section shows one Coffee Latte card, not two. | + +--- + +## How the Industry Handles This + +Observed patterns in food-ordering products (from product use, not independently re-verified for +this PRD — treat as background, not as a spec): + +1. **Delivery marketplaces put a curated/popular strip above the category menu.** GoFood, + GrabFood and ShopeeFood merchant pages open with a "Terlaris / Best seller / Popular" block + before the categories. Items in that block are *duplicated*, not moved — they still appear + under their own category. +2. **Badges are short, coloured pills on the item card**, usually one or two ("Baru", "Terlaris", + "Pedas"). More than two becomes noise. +3. **Merchant-side, tags are a small managed list** (Square Online item labels, Shopify product + tags/collections): the merchant creates the label once and applies it to many items. +4. **Marketplaces badge the orderable unit.** When one flavour of a dish is promoted, the + promoted card is that flavour, with its own photo and price. + +Pattern 1 and 4 together are exactly case 1: the highlighted card is the variant, the regular +menu still shows the product. + +--- + +## Alternatives Considered + +### Option A — Tags on products only ❌ + +`product_tags(product_id, tag_id)`. + +- ✅ Simplest possible model and UI; covers cases 2 and 3 directly. +- ❌ **Cannot express case 1.** Tagging Pancong "New" would claim Choco/Matcha/Vanilla are new — + the exact thing the brief says must not happen. Workaround (split Ice Cream into its own + product) breaks the option picker, availability counters and reporting. + +### Option B — Tags on products *and* variants (two join tables) ❌ + +`product_tags` means "the whole product, including future variants"; `variant_tags` means "only +this variant". + +- ✅ Stores the admin's words literally ("the product is new"). +- ❌ **Two sources of truth for one fact.** Coffee Latte could be tagged at product level *and* + Hot at variant level; every reader needs a precedence rule, and the admin UI needs to explain + the difference between "tag the product" and "tag all its variants" — which look identical today + and diverge silently later. +- ❌ **Inheritance is wrong half the time.** A product-level "New" inherited by a variant added + next month is right; a product-level "Best Seller" inherited by a brand-new Oat Milk variant is + a false claim shown to customers. + +### Option C — Tags on option values (e.g. the "Ice Cream" topping value) ❌ + +- ✅ Matches how the customer picks. +- ❌ Breaks for multi-option products — "Ice Cream" × "Large" is one variant, two option values; + which one carries "New"? +- ❌ Does nothing for cases 2 and 3 without a product-level table as well (→ Option B's problems). + +### Option D — Tags on variants only; product-level is *derived* ✅ **Recommended** + +One join table, `variant_tags(variant_id, tag_id)`. A product "has" a tag at product scope when +**every live variant** carries it; otherwise it has the tag at variant scope, naming which +variants. The derivation is a pure function on the server, exposed on every product read. + +- ✅ **One source of truth**, stored at the level the system actually sells. +- ✅ All three cases fall out with **no special-casing**: Ice Cream only → variant scope; one of + one → product scope; two of two → product scope (see the table below). +- ✅ **Never shows a claim nobody made.** A variant added later carries no tag until someone + gives it one (D4) — and the create-variant form pre-fills the product's current product-scope + tags so the admin makes that call consciously, in one glance. +- ✅ The admin UI can still *feel* product-level: a tri-state product checkbox ticks all variants, + and single-variant products never show their variant at all (D14). +- ⚠ Trade-off: "product scope" is a computed state, so adding an untagged variant to Coffee Latte + demotes it from one product card to per-variant cards in the Best Seller section. Accepted — + that is the truthful display, and D4's pre-fill makes it the admin's explicit choice. + +--- + +## Proposed Solution + +### High-level system design + +```mermaid +flowchart LR + subgraph POS["POS (pos-web / pos-mobile)"] + TM["Tag management
list · create · edit · delete"] + TA["Tag assignment
per tag: product ▸ variant checklist"] + VF["Variant form
+ image URL, + tags"] + PC1["Item picker / product list
tag badges"] + end + + subgraph API["apps/api (Go)"] + TagUC["TagUsecase
CRUD + SetTagVariants"] + Resolve["ResolveProductTags(variants)
pure domain fn"] + ProdUC["ProductUsecase / VariantUsecase
(existing reads)"] + end + + subgraph DB["MySQL"] + tags[("tags")] + vt[("variant_tags")] + variants[("variants
+ image_url")] + end + + subgraph ORDER["Order app (order-web)"] + HL["Highlight sections
(one per highlighted tag)"] + CAT["Category sections
(unchanged grouping)"] + PC2["Menu cards
tag badges"] + end + + TM --> TagUC + TA --> TagUC + VF --> ProdUC + TagUC --> tags + TagUC --> vt + ProdUC --> variants + ProdUC --> vt + ProdUC --> Resolve + Resolve -- "Product.tags[] (scope + variantIds)
Variant.tags[]" --> PC1 + Resolve -- "GET /public/products, /public/variants" --> HL + Resolve --> PC2 + HL --> PC2 + CAT --> PC2 +``` + +**In one paragraph:** admins manage a small list of **tags** (name, colour, "show as a section on +the order app", sort order) and attach them to **variants**. Every product read runs a pure +`ResolveProductTags` over the product's variants and returns `Product.tags[]`, each entry saying +whether the tag applies to the **whole product** or only to **specific variants**. Both apps +render badges from that one field. The order app additionally builds one **highlight section per +highlighted tag**, above the category sections, from data it already fetches — a product-scope +entry becomes a product card, a variant-scope entry becomes a card for that variant, with its own +image and price, which opens the detail sheet with that variant's options pre-selected. + +### Data model + +```mermaid +erDiagram + products ||--o{ variants : has + variants ||--o{ variant_tags : "tagged with" + tags ||--o{ variant_tags : "applied to" + + tags { + BIGINT id PK + VARCHAR name "unique, case-insensitive" + VARCHAR color "palette enum" + TINYINT is_highlighted "show section on order app" + INT sort_order "section + badge order" + DATETIME created_at + } + variant_tags { + BIGINT variant_id PK,FK + BIGINT tag_id PK,FK "ON DELETE CASCADE" + DATETIME created_at + } + variants { + VARCHAR image_url "NEW, nullable; falls back to product image" + } +``` + +| Table | Change | +| --- | --- | +| `tags` | **New.** `id`, `name VARCHAR(100) NOT NULL` + `UNIQUE (name)` (the default utf8mb4 collation makes it case-insensitive), `color VARCHAR(20) NOT NULL DEFAULT 'gray'`, `is_highlighted TINYINT(1) NOT NULL DEFAULT 0`, `sort_order INT NOT NULL DEFAULT 0`, `created_at`. | +| `variant_tags` | **New.** `PRIMARY KEY (variant_id, tag_id)`, `KEY (tag_id)`, FK to `variants`, FK to `tags` `ON DELETE CASCADE`, `created_at`. | +| `variants` | **New column** `image_url VARCHAR(255) NULL`. | + +### The three cases, expressed + +| Item | `variant_tags` rows | Resolved `Product.tags` | Order app "highlight" card | +| --- | --- | --- | --- | +| **Pancong** | (Ice Cream, New) | `[{ tag: New, scope: variant, variantIds: [IceCream] }]` | **Pancong · Ice Cream** — Ice Cream image, Ice Cream price; opens detail with *Ice Cream* pre-selected | +| **Salted Caramel Macchiato** | (Original, New) | `[{ tag: New, scope: product, variantIds: [Original] }]` | **Salted Caramel Macchiato** — product image, starting price | +| **Coffee Latte** | (Hot, Best Seller), (Iced, Best Seller) | `[{ tag: Best Seller, scope: product, variantIds: [Hot, Iced] }]` | **Coffee Latte** — one card, product image, starting price | + +And in the regular category list: + +| Item | Card badges | +| --- | --- | +| **Pancong** | `New · Ice Cream` (variant-scope pill naming the variant) — Pancong itself is *not* badged "New" | +| **Salted Caramel Macchiato** | `New` | +| **Coffee Latte** | `Best Seller` | + +### Resolution rule + +``` +ResolveProductTags(variants []Variant) []ProductTag + live := variants where DeletedAt == nil + for each tag T appearing on any live variant: + tagged := live variants carrying T + scope := "product" if len(tagged) == len(live) else "variant" + emit { Tag: T, Scope: scope, VariantIds: ids(tagged), + TaggedAt: max(variant_tags.created_at over tagged) } + sort by T.SortOrder, then T.Name +``` + +Coverage counts **live** variants, not **sellable** ones (D2): Coffee Latte stays a product-scope +best seller while Iced is sold out. + +--- + +## Feature Requirements + +### FR-1 — Tag management (POS) + +A **Tags** entry in the POS sidebar, beside Categories, opens a list screen with create, edit and +delete, shaped exactly like the existing Category screens (`CategoryListScreen`, +`CategoryCreateScreen`, `CategoryUpdateScreen`). Fields: + +| Field | Input | Rule | +| --- | --- | --- | +| Name | text | required, 1–100 chars, unique ignoring case | +| Colour | swatch picker | one of `red, orange, yellow, green, blue, purple, pink, gray` (D5) | +| Show as section on order app | switch | `is_highlighted` | +| Sort order | number | lower first; orders both sections and badges | + +Delete asks for confirmation and states how many variants carry the tag; it removes the tag and +its assignments (D9). + +API: `GET /tags`, `POST /tags`, `GET /tags/{tagId}`, `PUT /tags/{tagId}`, `DELETE /tags/{tagId}`, +all under `CheckAuth`. `Tag` schema: `id, name, color, isHighlighted, sortOrder, createdAt`. + +### FR-2 — Tag assignment (POS) + +From a tag's row, **Assign products** opens one screen per tag: every product, grouped by +category, searchable, with a checkbox per product and — for products with ≥ 2 variants — an +expandable checklist of its variants (D14). + +- The product checkbox is **tri-state**: checked (all live variants tagged), indeterminate (some), + unchecked (none). Ticking it ticks every variant; unticking clears them. +- A **single-variant product shows only the product checkbox** — the admin tags "Salted Caramel + Macchiato", never "Original". +- One **Save** sends the full set: `PUT /tags/{tagId}/variants` with `{ variantIds: [...] }`, + replacing that tag's assignments (D10). + +Data for the screen comes from the existing `GET /variants` (no `limit` = all rows, +`GetLimit` in `apps/api/presentation/restapi/base_transformers.go`), each variant now carrying +`tags` and its `product`; the screen groups by product client-side. + +### FR-3 — Variant form: image and tags (POS) + +`VariantFormView` gains: + +- **Image URL** (optional) — same text field as the product form's `imageUrl` + (`ProductFormView.tsx:126`). Empty means "use the product image". +- **Tags** — multi-select of all tags. On **create**, pre-filled with the tags that are currently + **product-scope** on the parent product (D4), so adding "Oat Milk" to a best-selling Coffee Latte + asks the admin, visibly, whether it is a best seller too. + +`VariantRequest` gains optional `imageUrl` and `tagIds`; **omitted `tagIds` leaves assignments +alone** (D10), so older clients and other edit paths never wipe tags. + +### FR-4 — Tags on every product and variant read (API) + +| Schema | New field | +| --- | --- | +| `Variant` | `tags: VariantTag[]` where `VariantTag = { tag: Tag, taggedAt: date-time }`, `imageUrl?: string` | +| `Product` | `tags: ProductTag[]` where `ProductTag = { tag: Tag, scope: 'product' \| 'variant', variantIds: int64[], taggedAt: date-time }` | + +`taggedAt` is `variant_tags.created_at` — for a `ProductTag`, the most recent one among its tagged +variants. It orders highlight entries newest first (D17). + +Populated on the authenticated and the public endpoints alike, computed by +`ResolveProductTags` (D3) at the same point `ProductUsecase.resolveAvailability` +(`apps/api/domain/product_usecase.go:16`) already loads the product's variants — the variant query +preloads `Tags`, adding no extra round trip per product. + +### FR-5 — Badges on product cards (POS and order app) + +One shared `TagBadge` component (coloured pill, colour from the tag's palette value, readable in +light and dark). Rules, identical in both apps: + +- Product-scope tags → pill with the tag name (`Best Seller`). +- Variant-scope tags → pill with tag and variant name (`New · Ice Cream`); if more than one + variant, `New · 2 varian`. +- At most **2** pills, in tag sort order, then `+N` (D12). +- Placement: POS `ProductListItem` subtitle (next to the sold-out/remaining badges — used by both + the product list and the transaction item picker); order app `MenuProductCard` under the name. +- In the order app detail sheet, once a variant resolves, its own tags show beside the price. + +### FR-6 — Highlight sections on the order app menu + +Above the category groups, one section per tag with `isHighlighted = true` that has at least one +entry, ordered by `sortOrder`: + +- **Header**: tag name (e.g. "Best Seller", "New"). +- **Body**: a horizontal carousel of compact cards (image on top, name, optional variant name, + price), so two sections don't push the regular menu off the first screen (D7). +- **Entries**, built from the `products` and `variants` the menu already loads + (`ApiMenuRepository.fetchMenu`), no new endpoint (D15): + - product-scope → one **product card**: product image, product name, starting price; + - variant-scope → one card **per tagged variant**: variant image (fallback: product image), + "Pancong" + "Ice Cream", that variant's price. +- **Entries are ordered most recently tagged first** (`taggedAt` descending, then menu order), + with **no per-section cap** — the carousel scrolls (D17, D18). +- **Only sellable entries** appear (D8); an empty section is not rendered. +- **Tapping** a product card opens the detail sheet as today; tapping a variant card opens it + with that variant's option values pre-selected — reusing `preselectedOptionValueIds` on + `menuItemDetail`'s `SELECT_PRODUCT` action (D6). +- Sections render **only when the search box is empty and no category or tag chip is + selected** (D7). Items still appear in their normal category below — sections duplicate, never + move. + +### FR-7 — Highlighted tags as chips on the order app menu + +`CategoryChipList` gains one chip per highlighted tag that has entries, placed after **Semua** and +before the category chips, in tag `sortOrder`, rendered in the tag's colour so they read as +different from categories (D16). + +- Tapping a tag chip **filters** the menu to that tag's entries, exactly the way a category chip + filters to its category today (`selectedCategoryId` in `MenuListUsecase`); the entries render + as a vertical list of full-width cards, in the same order as the section (D17). +- A tag chip and a category chip are **mutually exclusive**: selecting one clears the other. + **Semua** clears both and brings the highlight sections back. +- A tag chip only exists while its section would have at least one entry, so it never leads to an + empty list. + +--- + +## Design decisions + +**D1 — Tags attach to variants; product scope is derived.** A single `variant_tags` table; a +tag is product-scope for a product when every live variant carries it. *Alternative rejected:* +product + variant tag tables (Option B) — two sources of truth and an inheritance rule that is +wrong for "Best Seller". + +**D2 — Coverage is computed over live (non-deleted) variants, not sellable ones.** Availability +changes by the hour; a sold-out Iced must not flip Coffee Latte from one product card to a lone +"Hot" variant card. Sold-out is shown by the existing availability badges instead. + +**D3 — Resolution runs server-side in a pure domain function.** `ResolveProductTags` lives in +`apps/api/domain/tag_entity.go` next to the model, like `ResolveProductAvailability`, is +table-tested there, and is exposed on every product read. POS and the order app both get the +same answer without re-implementing the rule or fetching variants just to draw a badge. +*Alternative rejected:* resolve in `libs/ui` — the POS product list doesn't load variants, and two +implementations would drift. + +**D4 — New variants inherit nothing; the create form pre-fills instead.** Storage never infers a +claim. The create-variant form pre-selects the parent's current product-scope tags so the admin +confirms or clears them in the same form. This keeps "New" right (the admin leaves it ticked) and +"Best Seller" right (the admin unticks it). + +**D5 — Colour is a fixed palette, not free hex.** Eight named colours mapped to Tamagui `$5` +background / `$11` text tokens — the same pairing `SoldOutBadge` uses — so every tag is +legible in light and dark themes and nobody ships yellow-on-white. + +**D6 — A variant highlight card opens the product, pre-selected.** `MenuListUsecase`'s +`SELECT_ITEM` gains an optional `variantId`; `MenuListHandler` translates it into the variant's +option value ids and passes them as `preselectedOptionValueIds` to `SELECT_PRODUCT`, which already +exists for search matches. The customer lands on "Pancong, Ice Cream selected" and can still switch +topping. The URL keeps only `selectedProductId`, so a shared link opens the product without +pre-selection — acceptable. + +**D7 — Sections sit above categories, only in the default view, as carousels.** Search results and +a category filter are the customer asking for something specific; recommendations would get in the +way. Horizontal carousels keep the first screen from being all highlights. + +**D8 — Non-sellable entries are hidden from highlight sections.** Recommending something the +customer cannot order is worse than not recommending. They keep their badge in the category list, +where they already render disabled. + +**D9 — Tags are hard-deleted, assignments cascade.** Unlike categories (soft-deleted because +products point at them historically), nothing historical references a tag — transactions store +variants, not tags (D11). `ON DELETE CASCADE` on `variant_tags.tag_id` keeps deletion one +statement. + +**D10 — Two write paths, both explicit about scope.** `PUT /tags/{tagId}/variants` replaces the +full set for one tag (what the assignment screen edits). `VariantRequest.tagIds` replaces the set +for one variant, and **omitted means unchanged** — the same rule as availability's partial update +(`docs/prd-product-availability.md` D10), so no existing caller can wipe tags by not knowing about +them. + +**D11 — Tags are merchandising only.** Not snapshotted onto cart items or transactions, not on +receipts, KDS tickets or statistics. Removing "New" next month rewrites nothing. + +**D12 — At most two badges per card, then `+N`.** Beyond two, badges crowd out name and price on a +phone-width card. + +**D13 — Single-variant products need no special code.** One of one tagged is 100 % coverage → +product scope. Case 2 is a consequence of D1, not a branch. + +**D14 — The assignment screen hides the variant layer when it adds nothing.** Single-variant +products show only a product checkbox; multi-variant products show a tri-state product checkbox +with an expandable variant list. The admin works in product terms until a variant genuinely +differs. + +**D15 — No new public endpoint.** Tags ride on the existing `/public/products` and +`/public/variants` responses (with `isHighlighted` and `sortOrder` on the embedded `Tag`), and the +order app already holds both lists in `MenuListUsecase`'s context — highlight sections are a pure +function of state the handler already has. + +**D16 — Highlighted tags are also chips, and chips filter.** One chip per non-empty highlighted +tag, between **Semua** and the categories, in the tag's colour. A tag chip filters like a category +chip already does (`selectedCategoryId` filters `visibleGroups` in `MenuListHandler`; nothing on +the menu scrolls to an anchor today) — `MenuListUsecase` gains `selectedTagId`, mutually exclusive +with `selectedCategoryId`. *Alternative rejected:* scroll-to-section — it needs per-section layout +refs in a `ScrollView` the screen does not track today, and behaves differently from the category +chips right next to it. + +**D17 — Highlight entries are ordered most recently tagged first.** `variant_tags.created_at` is +exposed as `taggedAt` on `VariantTag` and `ProductTag` (latest across the product's tagged +variants); `buildTagHighlights` sorts by it descending, then by menu order. This is only true if +`created_at` means *when this variant got this tag*, so **both write paths diff instead of +delete-and-reinsert**: `PUT /tags/{tagId}/variants` and `VariantRequest.tagIds` insert only the +new pairs and delete only the removed ones, leaving untouched rows' `created_at` alone. Re-saving +the assignment screen must never make every item "newest". Untagging and re-tagging a variant does +restamp it — that is a deliberate re-promotion. + +**D18 — No per-section cap.** The horizontal carousel (D7) already bounds the vertical space a +section takes, however many entries it has; the tag chip (D16) gives the full list. A cap would +hide tagged items with no way to reach them from the section. + +--- + +## Settled in review + +Answers to the PRD's original open questions, recorded here rather than silently edited in: + +| # | Question | Answer | Recorded as | +| --- | --- | --- | --- | +| 1 | Should highlighted tags also appear as chips in `CategoryChipList`? | **Yes.** Implemented as a filter, matching how category chips behave. | FR-7, D16, Phase 13 | +| 2 | Menu order, or most recently tagged first? | **Most recently tagged first.** | FR-4 `taggedAt`, FR-6, D17, Phases 3, 4, 9 | +| 3 | Cap per section, or is the carousel enough? | **Carousel is enough** — no cap. | FR-6, D18 | + +--- + +## Phased plan + +Each phase is one PR, leaves `main` green and the product shippable on its own, and names its own +acceptance check. + +| # | Phase | Layer | Depends on | +| --- | --- | --- | --- | +| 1 | Tag CRUD API (`tags` table) | API | — | +| 2 | Variant image, end to end | API + POS | — | +| 3 | Variant ↔ tag assignment API (`variant_tags`) | API | 1 | +| 4 | Product tag resolution on reads | API | 3 | +| 5 | Frontend tag slice: entity, repository, CRUD use cases | libs/ui | 1 | +| 6 | POS Tag management screens + sidebar entry | POS | 5 | +| 7 | POS Tag assignment screen | POS | 3, 6 | +| 8 | POS variant form: tag multi-select with pre-fill | POS | 3, 5 | +| 9 | Frontend: tags on Product/Variant, `TagBadge`, `buildTagHighlights` | libs/ui | 4, 5 | +| 10 | POS product cards show tag badges | POS | 9 | +| 11 | Order app menu cards show tag badges | order | 9 | +| 12 | Order app highlight sections | order | 2, 9 | +| 13 | Order app highlighted-tag chips | order | 12 | +| 14 | Docs site page + e2e coverage | docs, e2e | 7, 10, 11, 13 | + +### What can run in parallel + +```mermaid +flowchart LR + P1["1 · Tag CRUD API"] --> P3["3 · Assignment API"] + P1 --> P5["5 · FE tag slice"] + P3 --> P4["4 · Product tag resolution"] + P3 --> P7 + P3 --> P8["8 · Variant form tags"] + P5 --> P6["6 · POS Tag screens"] + P5 --> P8 + P5 --> P9 + P6 --> P7["7 · POS assignment screen"] + P4 --> P9["9 · FE tags + TagBadge + highlights util"] + P9 --> P10["10 · POS badges"] + P9 --> P11["11 · Order badges"] + P9 --> P12["12 · Order highlight sections"] + P2["2 · Variant image"] --> P12 + P12 --> P13["13 · Order tag chips"] + P7 --> P14["14 · Docs + e2e"] + P10 --> P14 + P11 --> P14 + P13 --> P14 +``` + +| Wave | Phases that can be open at the same time | +| --- | --- | +| 1 | **1**, **2** | +| 2 | **3**, **5** | +| 3 | **4**, **6**, **8** | +| 4 | **7**, **9** | +| 5 | **10**, **11**, **12** | +| 6 | **13** (10 and 11 may still be open) | +| 7 | **14** | + +Phase 2 is fully independent and can land any time before 12. Admins can create tags after 6 and +tag products after 7 (or 8) — **before any customer sees anything**. Nothing customer-visible +changes until 11–13, so the shop can pre-load "New" and "Best Seller" and reveal them in one +deploy. + +> Every phase that touches `libs/api-contract/src/api.yaml` regenerates both clients +> (`npx nx run api-contract:generate:go`, `npx nx run api-contract:generate:ts`) and may need the +> new symbol added to `libs/ui/src/__mocks__/api-contract.ts`, which Jest substitutes wholesale +> for the generated package. + +--- + +### Phase 1 — Tag CRUD API + +Migration `000045_create_tags` (next free number; `000044_add_payment_verification` is the latest) +creating `tags` as in the data model, with a `down` that drops it. `Tag` entity and `TagColor` +string enum in `apps/api/domain/tag_entity.go`; `TagRepository` (`//go:generate mockgen`), +`TagUsecase` returning `(T, *domain.Error)` with a `BadRequest` on a duplicate name and an +unknown colour; MySQL repo/entity/transformer in `apps/api/data/mysql/`; handler, transformer and +routes under `CheckAuth` in `apps/api/presentation/restapi/`. Contract: `/tags`, `/tags/{tagId}`, +`Tag`, `TagRequest`, list/find/create/update responses. + +**Acceptance:** `tag_usecase_test.go` covers create, duplicate name differing only in case +(rejected), unknown colour (rejected), update, delete; `npx nx run api:test` green; +`MIGRATIONS_DIR=data/mysql/migrations make migrate-up && make migrate-down` clean both ways. + +### Phase 2 — Variant image, end to end + +Migration `000046_add_variant_image_url` (renumber if phase 3 lands first) adding +`variants.image_url VARCHAR(255) NULL`. `ImageUrl *string` on the Go `Variant` and its MySQL +entity/transformer; `imageUrl` on `Variant` and `VariantRequest` in `api.yaml`; `imageUrl` on +`libs/ui/src/domain/entities/Variant.ts`, `variant.transformer.ts` and the variant form schema; +an optional **Image URL** field in `VariantFormView`. No reader uses it yet. + +**Acceptance:** a variant round-trips through `PUT /variants/{id}` with and without `imageUrl`; +`VariantFormView` story shows the field; `npx nx run api:test` and `npx nx run ui:test` green. + +### Phase 3 — Variant ↔ tag assignment API + +Migration creating `variant_tags` as in the data model. `Tags []VariantTag` (tag + `TaggedAt`) on +the Go `Variant`, preloaded by every variant read in `variant_repo.go`. +`TagUsecase.SetTagVariants(ctx, tagId, variantIds)` replacing the set in one DB transaction by +**diffing** — insert added pairs, delete removed pairs, leave the rest (D17) — rejecting unknown or +deleted variant ids; `PUT /tags/{tagId}/variants`. `VariantRequest.tagIds` optional — +`VariantUsecase.CreateVariant` / `UpdateVariantById` apply the same diff only when it is present +(D10). `Tag.variantCount` on the tag list response for the delete confirmation. + +**Acceptance:** `tag_usecase_test.go` covers replace, empty set, unknown variant id (rejected); +`variant_usecase_test.go` covers update with `tagIds` omitted (tags untouched), empty (cleared), +and a list (replaced); a repo test shows re-saving an unchanged set keeps every row's +`created_at`; deleting a tag removes its `variant_tags` rows; `npx nx run api:test` green. + +### Phase 4 — Product tag resolution on reads + +`ProductTag` + `ResolveProductTags(variants []Variant) []ProductTag` in `tag_entity.go`, pure, +with `tag_entity_test.go` table-driving the three cases (Pancong, Salted Caramel, Coffee Latte), +a deleted variant ignored for coverage, a sold-out variant still counted (D2), `taggedAt` taking +the latest of the tagged variants (D17), and sort order. +Called from `ProductUsecase.resolveAvailability` (rename to `resolveDerivedFields`) where the +variants are already loaded; `tags: ProductTag[]` on the `Product` schema for authenticated and +public endpoints. + +**Acceptance:** the three-case table is a test; `GET /public/products` returns `tags: []` for +every untagged product and the expected scope for a seeded tagged one; `npx nx run api:test` green. + +### Phase 5 — Frontend tag slice + +No UI. `Tag` entity and `tagFormSchema` in `libs/ui/src/domain/entities/Tag.ts`; +`TagRepository` in `domain/repositories/`, implemented in `data/api/tag.ts` (+ transformer) and +`data/mock/tag.ts`; `TagListUsecase`, `TagCreateUsecase`, `TagUpdateUsecase`, `TagDeleteUsecase` +as `extends Usecase` state machines mirroring the `category*` use cases, +each with a `.test.ts` using `UsecaseTester` + `flushPromises` and +`MockTagRepository.setShouldFail(true)` for the error branch. Barrel exports. + +**Acceptance:** `npx nx run ui:test` green; use cases importable from `@gatherloop-pos/ui/pos`. + +### Phase 6 — POS Tag management screens + +`TagListScreen`, `TagCreateScreen`, `TagUpdateScreen` + `TagFormView` (owns `useForm` and the zod +resolver, `docs/forms.md`), `TagListItem` (shows colour pill, highlighted flag, variant count), +`TagDeleteAlert`; `TagListHandler` / `TagCreateHandler` / `TagUpdateHandler`; composition roots in +`libs/ui/src/app/pos/`; pages under `apps/pos-web/src/pages/tags/`; a **Tags** sidebar entry next +to Categories. Stories and handler tests for each screen. + +**Acceptance:** handler tests with real use cases over `MockTagRepository` cover create → list, +edit, delete-with-confirm; Storybook renders loaded/empty/error; `npx nx affected -t test lint` +green. + +### Phase 7 — POS Tag assignment screen + +`TagAssignmentUsecase` (load all variants via `VariantRepository`, `TOGGLE_VARIANT`, +`TOGGLE_PRODUCT` with tri-state semantics, `SEARCH`, `SAVE` → `TagRepository.setTagVariants`), +`TagAssignmentScreen` grouped by category → product → variants, single-variant products collapsed +to one checkbox (D14); `TagAssignmentHandler`; page `apps/pos-web/src/pages/tags/[tagId]/assign`; +"Assign products" action on `TagListItem`. + +**Acceptance:** use case test covers ticking a product (all variants), unticking one variant +(indeterminate), a single-variant product, and save sending exactly the ticked ids; handler test +asserts the checkbox states by accessible role; story for the Pancong/Salted Caramel/Coffee Latte +fixture. + +### Phase 8 — POS variant form: tag multi-select + +`tagIds` in the variant form schema and a multi-select in `VariantFormView`; `VariantCreate` / +`VariantUpdate` handlers load tags via `TagListUsecase`; on create, pre-fill with the parent +product's product-scope tags (D4) — the product's `tags` are already on the product the create +screen fetches once phase 4 is in; before that, pre-fill is empty and the phase still ships. + +**Acceptance:** handler test: creating a variant under a product with a product-scope "Best +Seller" shows it pre-ticked; unticking and saving sends `tagIds: []`; updating without touching +the field sends the existing ids. + +### Phase 9 — Frontend: tags on Product/Variant, `TagBadge`, highlight builder + +`tags: ProductTag[]` on `Product`, `tags: VariantTag[]` on `Variant`, and their transformers. +`TagBadge` component in `presentation/views/components/tags/` (palette → token map, D5) with +stories for every colour in both themes. `buildTagHighlights(products, variants)` in +`libs/ui/src/utils/` → `{ tag, entries: ({ kind: 'product', product } | { kind: 'variant', +product, variant })[] }[]`, filtering to highlighted tags and sellable entries (D8); sections ordered by tag `sortOrder`, +entries within a section by `taggedAt` descending, then menu order (D17). No screen changes. + +**Acceptance:** `buildTagHighlights.test.ts` covers the three cases, a non-highlighted tag +(no section), a sold-out variant entry (dropped), an empty section (dropped), and a newer tagged +variant sorting ahead of an older tagged product; `npx nx run ui:test` green. + +### Phase 10 — POS product cards show tag badges + +`ProductListItem` gains an optional `tags: ProductTag[]` prop rendered with `TagBadge` per FR-5 +(max 2 + `+N`, variant-scope pill naming the variant). Passed from `ProductList` and +`TransactionItemSelect`. + +**Acceptance:** `ProductListItem` stories for product-scope, variant-scope and overflow; the +transaction item picker story shows badges; `npx nx run ui:test` green. + +### Phase 11 — Order app menu cards show tag badges + +`MenuProductCard` renders `product.tags` with `TagBadge` under the name, per FR-5. +`MenuItemDetailScreen` shows the resolved variant's `tags` beside the price. + +**Acceptance:** `MenuProductCard` stories for the three cases (Pancong shows `New · Ice Cream`, not +`New`); `MenuListHandler.test.tsx` asserts the badge text for a tagged product. + +### Phase 12 — Order app highlight sections + +`MenuHighlightSection` + `MenuHighlightCard` components (horizontal carousel, variant image → +product image fallback, variant price or starting price). `MenuListScreen` gains a +`highlightSections` prop rendered above the category groups. `MenuListHandler` builds it with +`buildTagHighlights` only when `query` is empty and `selectedCategoryId` is `null` (D7). +`MenuListUsecase`'s `SELECT_ITEM` gains optional `variantId` kept in context; the handler maps it +to that variant's option value ids for `SELECT_PRODUCT.preselectedOptionValueIds` (D6). + +**Acceptance:** `menuList.test.ts` covers `SELECT_ITEM` with and without `variantId`; +`MenuListHandler.test.tsx` covers: sections rendered in `sortOrder` above categories, hidden while +searching or filtering, entries newest-tagged first, and tapping *Pancong · Ice Cream* opens the +sheet with Ice Cream pre-selected; `MenuListScreen` story with two sections. + +### Phase 13 — Order app highlighted-tag chips + +`CategoryChipList` gains an optional `tags` list rendered between **Semua** and the categories, +each chip in its tag colour, with `selectedTagId` / `onSelectTag` props. `MenuListUsecase` gains +`selectedTagId` in context and on `CHANGE_PARAMS`; the reducer clears `selectedCategoryId` when a +tag is selected and vice versa (D16). `MenuListHandler` passes only highlighted tags whose section +is non-empty, and when a tag is selected renders that tag's `buildTagHighlights` entries as a +vertical list of full-width `MenuHighlightCard`s instead of the category groups. +`MenuListScreen`'s `variant` gains a `{ type: 'tagLoaded'; tag; entries }` arm for that view. + +**Acceptance:** `menuList.test.ts` covers selecting a tag clears the category, selecting a category +clears the tag, and **Semua** clears both; `MenuListHandler.test.tsx` asserts the tag chip appears +only for a non-empty highlighted tag and that tapping it shows exactly that tag's entries, newest +first; `CategoryChipList` story with tag chips. + +### Phase 14 — Docs site page + e2e coverage + +A "Tags" page in `docs-site/` (managing tags, assigning to products vs. single variants, how the +order app shows them). `pos-web-e2e`: create a tag, assign it to a product, see the badge in the +transaction item picker. `order-web-e2e`: a highlighted tag's section appears above categories, a +variant card pre-selects its option, and the tag chip filters to that tag. Run locally — e2e runs +post-merge only. + +**Acceptance:** both e2e specs pass locally; docs site builds. + +--- + +## Risks + +| Risk | Mitigation | +| --- | --- | +| Adding an untagged variant to a product-scope-tagged product silently demotes it to per-variant cards. | D4 pre-fill makes it an explicit admin choice in the same form; the assignment screen shows the indeterminate state. | +| Stale "New" tags linger for months. | Manual removal is one untick on the assignment screen; auto-expiry is listed under Deferred. | +| Badge and section clutter if many tags are created. | Max 2 badges per card (D12); only `isHighlighted` tags get sections; carousels (D7). | +| Variant cards without an image look generic. | Fallback to the product image; the image field is in the variant form from phase 2. | +| Extra load on product list reads. | Tags are preloaded on the variant query `resolveAvailability` already runs — one batched preload, no new per-product query. | +| Public API now exposes tag names. | Only name, colour, highlight flag and sort order — nothing internal. Staff-only tags are Deferred. | + +## Out of Scope + +- Automatically computing "Best Seller" from transaction statistics. +- Tag-based discounts, coupons or pricing. +- Tags on receipts, KDS tickets, or transaction statistics (D11). +- Filtering the POS product list by tag. + +## Deferred + +- **Assignment expiry** (`variant_tags.expires_at`, e.g. "New for 30 days"). Worth it once "New" + tags demonstrably go stale; additive column, no model change. +- **Staff-only tags** (`tags.is_public`) for internal markers like "Seasonal stock". +- **Auto best seller**: a scheduled job writing `variant_tags` from sales rank — fits D1 unchanged, + because it only needs to write the same table. + +## Success Criteria + +- The three brief cases render exactly as in the "three cases" tables, in both apps. +- An admin can create a tag and apply it to a product without opening a variant form. +- Share of order-app orders containing at least one highlighted item, measured over the first + month after phase 12, is reported alongside the pre-launch baseline. From 238edf97b2c328e84499d5ae34aa5e704802177f Mon Sep 17 00:00:00 2001 From: M Nindra Zaka Date: Thu, 1 Oct 2026 17:29:36 +0700 Subject: [PATCH 02/15] feat(api): add tag CRUD endpoints (#666) Phase 1 of docs/prd-product-tags.md: tags table migration, Tag domain entity with colour palette validation, case-insensitive unique name check, MySQL repository, REST handler/routes under CheckAuth, and the /tags contract. Claude-Session: https://claude.ai/code/session_01TvErk6zGfrEZmJLbQVmtLA Co-authored-by: Claude --- apps/api/data/mock/tag_repository.go | 130 ++++++++ .../migrations/000045_create_tags.down.sql | 1 + .../migrations/000045_create_tags.up.sql | 10 + apps/api/data/mysql/tag_entity.go | 12 + apps/api/data/mysql/tag_repo.go | 54 ++++ apps/api/data/mysql/tag_repo_test.go | 81 +++++ apps/api/data/mysql/tag_transformer.go | 33 ++ apps/api/domain/tag_entity.go | 34 ++ apps/api/domain/tag_repository.go | 16 + apps/api/domain/tag_usecase.go | 72 +++++ apps/api/domain/tag_usecase_test.go | 300 ++++++++++++++++++ apps/api/main.go | 4 + apps/api/presentation/restapi/tag_handler.go | 110 +++++++ .../presentation/restapi/tag_handler_test.go | 277 ++++++++++++++++ apps/api/presentation/restapi/tag_route.go | 23 ++ .../presentation/restapi/tag_transformer.go | 44 +++ libs/api-contract/src/api.yaml | 197 ++++++++++++ 17 files changed, 1398 insertions(+) create mode 100644 apps/api/data/mock/tag_repository.go create mode 100644 apps/api/data/mysql/migrations/000045_create_tags.down.sql create mode 100644 apps/api/data/mysql/migrations/000045_create_tags.up.sql create mode 100644 apps/api/data/mysql/tag_entity.go create mode 100644 apps/api/data/mysql/tag_repo.go create mode 100644 apps/api/data/mysql/tag_repo_test.go create mode 100644 apps/api/data/mysql/tag_transformer.go create mode 100644 apps/api/domain/tag_entity.go create mode 100644 apps/api/domain/tag_repository.go create mode 100644 apps/api/domain/tag_usecase.go create mode 100644 apps/api/domain/tag_usecase_test.go create mode 100644 apps/api/presentation/restapi/tag_handler.go create mode 100644 apps/api/presentation/restapi/tag_handler_test.go create mode 100644 apps/api/presentation/restapi/tag_route.go create mode 100644 apps/api/presentation/restapi/tag_transformer.go diff --git a/apps/api/data/mock/tag_repository.go b/apps/api/data/mock/tag_repository.go new file mode 100644 index 00000000..071bb311 --- /dev/null +++ b/apps/api/data/mock/tag_repository.go @@ -0,0 +1,130 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: tag_repository.go +// +// Generated by this command: +// +// mockgen -source=tag_repository.go -destination=../data/mock/tag_repository.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + domain "apps/api/domain" + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockTagRepository is a mock of TagRepository interface. +type MockTagRepository struct { + ctrl *gomock.Controller + recorder *MockTagRepositoryMockRecorder + isgomock struct{} +} + +// MockTagRepositoryMockRecorder is the mock recorder for MockTagRepository. +type MockTagRepositoryMockRecorder struct { + mock *MockTagRepository +} + +// NewMockTagRepository creates a new mock instance. +func NewMockTagRepository(ctrl *gomock.Controller) *MockTagRepository { + mock := &MockTagRepository{ctrl: ctrl} + mock.recorder = &MockTagRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTagRepository) EXPECT() *MockTagRepositoryMockRecorder { + return m.recorder +} + +// BeginTransaction mocks base method. +func (m *MockTagRepository) BeginTransaction(ctx context.Context, callback func(context.Context) *domain.Error) *domain.Error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BeginTransaction", ctx, callback) + ret0, _ := ret[0].(*domain.Error) + return ret0 +} + +// BeginTransaction indicates an expected call of BeginTransaction. +func (mr *MockTagRepositoryMockRecorder) BeginTransaction(ctx, callback any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BeginTransaction", reflect.TypeOf((*MockTagRepository)(nil).BeginTransaction), ctx, callback) +} + +// CreateTag mocks base method. +func (m *MockTagRepository) CreateTag(ctx context.Context, tag domain.Tag) (domain.Tag, *domain.Error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateTag", ctx, tag) + ret0, _ := ret[0].(domain.Tag) + ret1, _ := ret[1].(*domain.Error) + return ret0, ret1 +} + +// CreateTag indicates an expected call of CreateTag. +func (mr *MockTagRepositoryMockRecorder) CreateTag(ctx, tag any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTag", reflect.TypeOf((*MockTagRepository)(nil).CreateTag), ctx, tag) +} + +// DeleteTagById mocks base method. +func (m *MockTagRepository) DeleteTagById(ctx context.Context, id int64) *domain.Error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteTagById", ctx, id) + ret0, _ := ret[0].(*domain.Error) + return ret0 +} + +// DeleteTagById indicates an expected call of DeleteTagById. +func (mr *MockTagRepositoryMockRecorder) DeleteTagById(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTagById", reflect.TypeOf((*MockTagRepository)(nil).DeleteTagById), ctx, id) +} + +// GetTagById mocks base method. +func (m *MockTagRepository) GetTagById(ctx context.Context, id int64) (domain.Tag, *domain.Error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTagById", ctx, id) + ret0, _ := ret[0].(domain.Tag) + ret1, _ := ret[1].(*domain.Error) + return ret0, ret1 +} + +// GetTagById indicates an expected call of GetTagById. +func (mr *MockTagRepositoryMockRecorder) GetTagById(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTagById", reflect.TypeOf((*MockTagRepository)(nil).GetTagById), ctx, id) +} + +// GetTagList mocks base method. +func (m *MockTagRepository) GetTagList(ctx context.Context) ([]domain.Tag, *domain.Error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTagList", ctx) + ret0, _ := ret[0].([]domain.Tag) + ret1, _ := ret[1].(*domain.Error) + return ret0, ret1 +} + +// GetTagList indicates an expected call of GetTagList. +func (mr *MockTagRepositoryMockRecorder) GetTagList(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTagList", reflect.TypeOf((*MockTagRepository)(nil).GetTagList), ctx) +} + +// UpdateTagById mocks base method. +func (m *MockTagRepository) UpdateTagById(ctx context.Context, tag domain.Tag, id int64) (domain.Tag, *domain.Error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTagById", ctx, tag, id) + ret0, _ := ret[0].(domain.Tag) + ret1, _ := ret[1].(*domain.Error) + return ret0, ret1 +} + +// UpdateTagById indicates an expected call of UpdateTagById. +func (mr *MockTagRepositoryMockRecorder) UpdateTagById(ctx, tag, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTagById", reflect.TypeOf((*MockTagRepository)(nil).UpdateTagById), ctx, tag, id) +} diff --git a/apps/api/data/mysql/migrations/000045_create_tags.down.sql b/apps/api/data/mysql/migrations/000045_create_tags.down.sql new file mode 100644 index 00000000..67cf648c --- /dev/null +++ b/apps/api/data/mysql/migrations/000045_create_tags.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `tags`; diff --git a/apps/api/data/mysql/migrations/000045_create_tags.up.sql b/apps/api/data/mysql/migrations/000045_create_tags.up.sql new file mode 100644 index 00000000..226d4522 --- /dev/null +++ b/apps/api/data/mysql/migrations/000045_create_tags.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS `tags` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `name` VARCHAR(100) NOT NULL, + `color` VARCHAR(20) NOT NULL DEFAULT 'gray', + `is_highlighted` TINYINT(1) NOT NULL DEFAULT 0, + `sort_order` INT NOT NULL DEFAULT 0, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_tags_name` (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/apps/api/data/mysql/tag_entity.go b/apps/api/data/mysql/tag_entity.go new file mode 100644 index 00000000..81c7bb16 --- /dev/null +++ b/apps/api/data/mysql/tag_entity.go @@ -0,0 +1,12 @@ +package mysql + +import "time" + +type Tag struct { + Id int64 + Name string + Color string + IsHighlighted bool + SortOrder int + CreatedAt time.Time +} diff --git a/apps/api/data/mysql/tag_repo.go b/apps/api/data/mysql/tag_repo.go new file mode 100644 index 00000000..2be6478f --- /dev/null +++ b/apps/api/data/mysql/tag_repo.go @@ -0,0 +1,54 @@ +package mysql + +import ( + "apps/api/domain" + "context" + + "gorm.io/gorm" +) + +func NewTagRepository(db *gorm.DB) domain.TagRepository { + return Repository{db: db} +} + +func (repo Repository) GetTagList(ctx context.Context) ([]domain.Tag, *domain.Error) { + db := GetDbFromCtx(ctx, repo.db) + var tags []Tag + result := db.Table("tags").Order("sort_order ASC, name ASC").Find(&tags) + return ToTagListDomain(tags), ToErrorCtx(ctx, result.Error, "GetTagList") +} + +func (repo Repository) GetTagById(ctx context.Context, id int64) (domain.Tag, *domain.Error) { + db := GetDbFromCtx(ctx, repo.db) + var tag Tag + result := db.Table("tags").Where("id = ?", id).First(&tag) + return ToTagDomain(tag), ToErrorCtx(ctx, result.Error, "GetTagById") +} + +func (repo Repository) CreateTag(ctx context.Context, tag domain.Tag) (domain.Tag, *domain.Error) { + db := GetDbFromCtx(ctx, repo.db) + tagPayload := ToTagDB(tag) + result := db.Table("tags").Create(&tagPayload) + return ToTagDomain(tagPayload), ToErrorCtx(ctx, result.Error, "CreateTag") +} + +func (repo Repository) UpdateTagById(ctx context.Context, tag domain.Tag, id int64) (domain.Tag, *domain.Error) { + db := GetDbFromCtx(ctx, repo.db) + tagPayload := ToTagDB(tag) + result := db.Table("tags").Where("id = ?", id). + Select("name", "color", "is_highlighted", "sort_order"). + Updates(&tagPayload) + if result.Error != nil { + return domain.Tag{}, ToErrorCtx(ctx, result.Error, "UpdateTagById") + } + + var updatedTag Tag + fetchResult := db.Table("tags").Where("id = ?", id).First(&updatedTag) + return ToTagDomain(updatedTag), ToErrorCtx(ctx, fetchResult.Error, "UpdateTagById") +} + +func (repo Repository) DeleteTagById(ctx context.Context, id int64) *domain.Error { + db := GetDbFromCtx(ctx, repo.db) + result := db.Table("tags").Where("id = ?", id).Delete(&Tag{}) + return ToErrorCtx(ctx, result.Error, "DeleteTagById") +} diff --git a/apps/api/data/mysql/tag_repo_test.go b/apps/api/data/mysql/tag_repo_test.go new file mode 100644 index 00000000..9d40d7ef --- /dev/null +++ b/apps/api/data/mysql/tag_repo_test.go @@ -0,0 +1,81 @@ +package mysql_test + +import ( + "apps/api/data/mysql" + "apps/api/domain" + "context" + "testing" + + sqlmock "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" + gormmysql "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func newMockTagRepository(t *testing.T) (domain.TagRepository, sqlmock.Sqlmock) { + t.Helper() + + sqlDB, mock, err := sqlmock.New() + require.NoError(t, err) + t.Cleanup(func() { sqlDB.Close() }) + + gormDB, err := gorm.Open(gormmysql.New(gormmysql.Config{ + Conn: sqlDB, + SkipInitializeWithVersion: true, + }), &gorm.Config{}) + require.NoError(t, err) + + return mysql.NewTagRepository(gormDB), mock +} + +func TestTagRepository_Update_WritesFalseAndZeroValues(t *testing.T) { + repo, mock := newMockTagRepository(t) + + mock.ExpectBegin() + mock.ExpectExec("UPDATE `tags` SET `name`=\\?,`color`=\\?,`is_highlighted`=\\?,`sort_order`=\\? WHERE id = \\?"). + WithArgs("New", "green", false, 0, int64(1)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + mock.ExpectQuery("SELECT \\* FROM `tags` WHERE id = \\? ORDER BY `tags`.`id` LIMIT \\?"). + WithArgs(int64(1), 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "color", "is_highlighted", "sort_order"}). + AddRow(1, "New", "green", false, 0)) + + tag, err := repo.UpdateTagById(context.Background(), domain.Tag{Name: "New", Color: domain.TagColorGreen}, 1) + + require.Nil(t, err) + require.Equal(t, domain.Tag{Id: 1, Name: "New", Color: domain.TagColorGreen}, tag) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestTagRepository_Delete_RemovesTheRow(t *testing.T) { + repo, mock := newMockTagRepository(t) + + mock.ExpectBegin() + mock.ExpectExec("DELETE FROM `tags` WHERE id = \\?"). + WithArgs(int64(1)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + err := repo.DeleteTagById(context.Background(), 1) + + require.Nil(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestTagRepository_GetList_OrdersBySortOrderThenName(t *testing.T) { + repo, mock := newMockTagRepository(t) + + mock.ExpectQuery("SELECT \\* FROM `tags` ORDER BY sort_order ASC, name ASC"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "color", "is_highlighted", "sort_order"}). + AddRow(2, "Best Seller", "orange", true, 1). + AddRow(1, "New", "green", true, 2)) + + tags, err := repo.GetTagList(context.Background()) + + require.Nil(t, err) + require.Len(t, tags, 2) + require.Equal(t, "Best Seller", tags[0].Name) + require.True(t, tags[0].IsHighlighted) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/apps/api/data/mysql/tag_transformer.go b/apps/api/data/mysql/tag_transformer.go new file mode 100644 index 00000000..15dabc20 --- /dev/null +++ b/apps/api/data/mysql/tag_transformer.go @@ -0,0 +1,33 @@ +package mysql + +import "apps/api/domain" + +func ToTagDB(domainTag domain.Tag) Tag { + return Tag{ + Id: domainTag.Id, + Name: domainTag.Name, + Color: string(domainTag.Color), + IsHighlighted: domainTag.IsHighlighted, + SortOrder: domainTag.SortOrder, + CreatedAt: domainTag.CreatedAt, + } +} + +func ToTagDomain(dbTag Tag) domain.Tag { + return domain.Tag{ + Id: dbTag.Id, + Name: dbTag.Name, + Color: domain.TagColor(dbTag.Color), + IsHighlighted: dbTag.IsHighlighted, + SortOrder: dbTag.SortOrder, + CreatedAt: dbTag.CreatedAt, + } +} + +func ToTagListDomain(dbTags []Tag) []domain.Tag { + var domainTags []domain.Tag + for _, dbTag := range dbTags { + domainTags = append(domainTags, ToTagDomain(dbTag)) + } + return domainTags +} diff --git a/apps/api/domain/tag_entity.go b/apps/api/domain/tag_entity.go new file mode 100644 index 00000000..621f156e --- /dev/null +++ b/apps/api/domain/tag_entity.go @@ -0,0 +1,34 @@ +package domain + +import "time" + +type TagColor string + +const ( + TagColorRed TagColor = "red" + TagColorOrange TagColor = "orange" + TagColorYellow TagColor = "yellow" + TagColorGreen TagColor = "green" + TagColorBlue TagColor = "blue" + TagColorPurple TagColor = "purple" + TagColorPink TagColor = "pink" + TagColorGray TagColor = "gray" +) + +func (color TagColor) IsValid() bool { + switch color { + case TagColorRed, TagColorOrange, TagColorYellow, TagColorGreen, + TagColorBlue, TagColorPurple, TagColorPink, TagColorGray: + return true + } + return false +} + +type Tag struct { + Id int64 + Name string + Color TagColor + IsHighlighted bool + SortOrder int + CreatedAt time.Time +} diff --git a/apps/api/domain/tag_repository.go b/apps/api/domain/tag_repository.go new file mode 100644 index 00000000..87dab852 --- /dev/null +++ b/apps/api/domain/tag_repository.go @@ -0,0 +1,16 @@ +//go:generate mockgen -source=tag_repository.go -destination=../data/mock/tag_repository.go -package=mock + +package domain + +import ( + "context" +) + +type TagRepository interface { + BeginTransaction(ctx context.Context, callback func(ctxWithTx context.Context) *Error) *Error + GetTagList(ctx context.Context) ([]Tag, *Error) + GetTagById(ctx context.Context, id int64) (Tag, *Error) + CreateTag(ctx context.Context, tag Tag) (Tag, *Error) + UpdateTagById(ctx context.Context, tag Tag, id int64) (Tag, *Error) + DeleteTagById(ctx context.Context, id int64) *Error +} diff --git a/apps/api/domain/tag_usecase.go b/apps/api/domain/tag_usecase.go new file mode 100644 index 00000000..5ccfeebe --- /dev/null +++ b/apps/api/domain/tag_usecase.go @@ -0,0 +1,72 @@ +package domain + +import ( + "context" + "strings" + "unicode/utf8" +) + +const tagNameMaxLength = 100 + +type TagUsecase struct { + repository TagRepository +} + +func NewTagUsecase(repository TagRepository) TagUsecase { + return TagUsecase{repository: repository} +} + +func (usecase TagUsecase) GetTagList(ctx context.Context) ([]Tag, *Error) { + return usecase.repository.GetTagList(ctx) +} + +func (usecase TagUsecase) GetTagById(ctx context.Context, id int64) (Tag, *Error) { + return usecase.repository.GetTagById(ctx, id) +} + +func (usecase TagUsecase) CreateTag(ctx context.Context, tag Tag) (Tag, *Error) { + tag.Name = strings.TrimSpace(tag.Name) + if err := usecase.validateTag(ctx, tag, nil); err != nil { + return Tag{}, err + } + return usecase.repository.CreateTag(ctx, tag) +} + +func (usecase TagUsecase) UpdateTagById(ctx context.Context, tag Tag, id int64) (Tag, *Error) { + tag.Name = strings.TrimSpace(tag.Name) + if err := usecase.validateTag(ctx, tag, &id); err != nil { + return Tag{}, err + } + return usecase.repository.UpdateTagById(ctx, tag, id) +} + +func (usecase TagUsecase) DeleteTagById(ctx context.Context, id int64) *Error { + if _, err := usecase.repository.GetTagById(ctx, id); err != nil { + return err + } + return usecase.repository.DeleteTagById(ctx, id) +} + +func (usecase TagUsecase) validateTag(ctx context.Context, tag Tag, excludeId *int64) *Error { + nameLength := utf8.RuneCountInString(tag.Name) + if nameLength < 1 || nameLength > tagNameMaxLength { + return &Error{Type: BadRequest, Message: "tag name must be between 1 and 100 characters"} + } + + if !tag.Color.IsValid() { + return &Error{Type: BadRequest, Message: "tag color is not supported"} + } + + tags, err := usecase.repository.GetTagList(ctx) + if err != nil { + return err + } + for _, existing := range tags { + isSameTag := excludeId != nil && existing.Id == *excludeId + if !isSameTag && strings.EqualFold(existing.Name, tag.Name) { + return &Error{Type: BadRequest, Message: "tag name already exists"} + } + } + + return nil +} diff --git a/apps/api/domain/tag_usecase_test.go b/apps/api/domain/tag_usecase_test.go new file mode 100644 index 00000000..54ae792a --- /dev/null +++ b/apps/api/domain/tag_usecase_test.go @@ -0,0 +1,300 @@ +package domain_test + +import ( + "apps/api/data/mock" + "apps/api/domain" + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +var existingTags = []domain.Tag{ + {Id: 1, Name: "New", Color: domain.TagColorGreen}, + {Id: 2, Name: "Best Seller", Color: domain.TagColorOrange}, +} + +func TestTagUsecase_GetTagList(t *testing.T) { + tests := []struct { + name string + setupMock func(r *mock.MockTagRepository) + expectedLen int + expectedError *domain.Error + }{ + { + name: "success", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(existingTags, nil) + }, + expectedLen: 2, + }, + { + name: "repository error", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(nil, &domain.Error{Type: domain.InternalServerError, Message: "db error"}) + }, + expectedError: &domain.Error{Type: domain.InternalServerError}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + + tags, err := domain.NewTagUsecase(mockRepo).GetTagList(context.Background()) + + if tt.expectedError != nil { + assert.NotNil(t, err) + assert.Equal(t, tt.expectedError.Type, err.Type) + } else { + assert.Nil(t, err) + assert.Len(t, tags, tt.expectedLen) + } + }) + } +} + +func TestTagUsecase_GetTagById(t *testing.T) { + tests := []struct { + name string + setupMock func(r *mock.MockTagRepository) + expectedError *domain.Error + }{ + { + name: "success", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(existingTags[0], nil) + }, + }, + { + name: "not found", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(domain.Tag{}, &domain.Error{Type: domain.NotFound, Message: "not found"}) + }, + expectedError: &domain.Error{Type: domain.NotFound}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + + tag, err := domain.NewTagUsecase(mockRepo).GetTagById(context.Background(), 1) + + if tt.expectedError != nil { + assert.NotNil(t, err) + assert.Equal(t, tt.expectedError.Type, err.Type) + } else { + assert.Nil(t, err) + assert.Equal(t, "New", tag.Name) + } + }) + } +} + +func TestTagUsecase_CreateTag(t *testing.T) { + tests := []struct { + name string + input domain.Tag + setupMock func(r *mock.MockTagRepository) + expectedName string + expectedError *domain.Error + }{ + { + name: "success trims the name", + input: domain.Tag{Name: " Vegan ", Color: domain.TagColorBlue, IsHighlighted: true, SortOrder: 3}, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(existingTags, nil) + r.EXPECT().CreateTag(gomock.Any(), domain.Tag{Name: "Vegan", Color: domain.TagColorBlue, IsHighlighted: true, SortOrder: 3}). + Return(domain.Tag{Id: 3, Name: "Vegan", Color: domain.TagColorBlue, IsHighlighted: true, SortOrder: 3}, nil) + }, + expectedName: "Vegan", + }, + { + name: "duplicate name differing only in case", + input: domain.Tag{Name: "best seller", Color: domain.TagColorRed}, + setupMock: func(r *mock.MockTagRepository) { r.EXPECT().GetTagList(gomock.Any()).Return(existingTags, nil) }, + expectedError: &domain.Error{Type: domain.BadRequest}, + }, + { + name: "unknown color", + input: domain.Tag{Name: "Vegan", Color: "teal"}, + setupMock: func(r *mock.MockTagRepository) {}, + expectedError: &domain.Error{Type: domain.BadRequest}, + }, + { + name: "blank name", + input: domain.Tag{Name: " ", Color: domain.TagColorRed}, + setupMock: func(r *mock.MockTagRepository) {}, + expectedError: &domain.Error{Type: domain.BadRequest}, + }, + { + name: "name longer than 100 characters", + input: domain.Tag{Name: strings.Repeat("a", 101), Color: domain.TagColorRed}, + setupMock: func(r *mock.MockTagRepository) {}, + expectedError: &domain.Error{Type: domain.BadRequest}, + }, + { + name: "list error", + input: domain.Tag{Name: "Vegan", Color: domain.TagColorRed}, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(nil, &domain.Error{Type: domain.InternalServerError, Message: "db error"}) + }, + expectedError: &domain.Error{Type: domain.InternalServerError}, + }, + { + name: "create error", + input: domain.Tag{Name: "Vegan", Color: domain.TagColorRed}, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(existingTags, nil) + r.EXPECT().CreateTag(gomock.Any(), gomock.Any()).Return(domain.Tag{}, &domain.Error{Type: domain.InternalServerError, Message: "db error"}) + }, + expectedError: &domain.Error{Type: domain.InternalServerError}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + + tag, err := domain.NewTagUsecase(mockRepo).CreateTag(context.Background(), tt.input) + + if tt.expectedError != nil { + assert.NotNil(t, err) + assert.Equal(t, tt.expectedError.Type, err.Type) + } else { + assert.Nil(t, err) + assert.Equal(t, tt.expectedName, tag.Name) + } + }) + } +} + +func TestTagUsecase_UpdateTagById(t *testing.T) { + tests := []struct { + name string + id int64 + input domain.Tag + setupMock func(r *mock.MockTagRepository) + expectedError *domain.Error + }{ + { + name: "success keeping its own name", + id: 1, + input: domain.Tag{Name: "new", Color: domain.TagColorPurple, SortOrder: 5}, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(existingTags, nil) + r.EXPECT().UpdateTagById(gomock.Any(), domain.Tag{Name: "new", Color: domain.TagColorPurple, SortOrder: 5}, int64(1)). + Return(domain.Tag{Id: 1, Name: "new", Color: domain.TagColorPurple, SortOrder: 5}, nil) + }, + }, + { + name: "name taken by another tag", + id: 1, + input: domain.Tag{Name: "BEST SELLER", Color: domain.TagColorPurple}, + setupMock: func(r *mock.MockTagRepository) { r.EXPECT().GetTagList(gomock.Any()).Return(existingTags, nil) }, + expectedError: &domain.Error{Type: domain.BadRequest}, + }, + { + name: "unknown color", + id: 1, + input: domain.Tag{Name: "New", Color: "teal"}, + setupMock: func(r *mock.MockTagRepository) {}, + expectedError: &domain.Error{Type: domain.BadRequest}, + }, + { + name: "not found", + id: 99, + input: domain.Tag{Name: "Vegan", Color: domain.TagColorGreen}, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(existingTags, nil) + r.EXPECT().UpdateTagById(gomock.Any(), gomock.Any(), int64(99)).Return(domain.Tag{}, &domain.Error{Type: domain.NotFound, Message: "not found"}) + }, + expectedError: &domain.Error{Type: domain.NotFound}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + + tag, err := domain.NewTagUsecase(mockRepo).UpdateTagById(context.Background(), tt.input, tt.id) + + if tt.expectedError != nil { + assert.NotNil(t, err) + assert.Equal(t, tt.expectedError.Type, err.Type) + } else { + assert.Nil(t, err) + assert.Equal(t, tt.id, tag.Id) + } + }) + } +} + +func TestTagUsecase_DeleteTagById(t *testing.T) { + tests := []struct { + name string + setupMock func(r *mock.MockTagRepository) + expectedError *domain.Error + }{ + { + name: "success", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(existingTags[0], nil) + r.EXPECT().DeleteTagById(gomock.Any(), int64(1)).Return(nil) + }, + }, + { + name: "not found", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(domain.Tag{}, &domain.Error{Type: domain.NotFound, Message: "not found"}) + }, + expectedError: &domain.Error{Type: domain.NotFound}, + }, + { + name: "repository error", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(existingTags[0], nil) + r.EXPECT().DeleteTagById(gomock.Any(), int64(1)).Return(&domain.Error{Type: domain.InternalServerError, Message: "db error"}) + }, + expectedError: &domain.Error{Type: domain.InternalServerError}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + + err := domain.NewTagUsecase(mockRepo).DeleteTagById(context.Background(), 1) + + if tt.expectedError != nil { + assert.NotNil(t, err) + assert.Equal(t, tt.expectedError.Type, err.Type) + } else { + assert.Nil(t, err) + } + }) + } +} diff --git a/apps/api/main.go b/apps/api/main.go index fd607bea..118076b4 100644 --- a/apps/api/main.go +++ b/apps/api/main.go @@ -116,6 +116,7 @@ func main() { supplierRepository := mysql.NewSupplierRepository(db) expenseRepository := mysql.NewExpenseRepository(db) categoryRepository := mysql.NewCategoryRepository(db) + tagRepository := mysql.NewTagRepository(db) couponRepository := mysql.NewCouponRepository(db) ticketRepository := mysql.NewTicketRepository(db) tableRepository := mysql.NewTableRepository(db) @@ -149,6 +150,7 @@ func main() { supplierUsecase := domain.NewSupplierUsecase(supplierRepository) expenseUsecase := domain.NewExpenseUsecase(expenseRepository, budgetRepository, walletRepository) categoryUsecase := domain.NewCategoryUsecase(categoryRepository) + tagUsecase := domain.NewTagUsecase(tagRepository) couponUsecase := domain.NewCouponUsecase(couponRepository) ticketUsecase := domain.NewTicketUsecase(ticketRepository) tableUsecase := domain.NewTableUsecase(tableRepository) @@ -174,6 +176,7 @@ func main() { supplierHandler := restapi.NewSupplierHandler(supplierUsecase) expenseHandler := restapi.NewExpenseHandler(expenseUsecase) categoryHandler := restapi.NewCategoryHandler(categoryUsecase) + tagHandler := restapi.NewTagHandler(tagUsecase) couponHandler := restapi.NewCouponHandler(couponUsecase) ticketHandler := restapi.NewTicketHandler(ticketUsecase) tableHandler := restapi.NewTableHandler(tableUsecase) @@ -195,6 +198,7 @@ func main() { restapi.NewAuthRouter(authHandler).AddRouter(router) restapi.NewBudgetRouter(budgetHandler).AddRouter(router) restapi.NewCategoryRouter(categoryHandler).AddRouter(router) + restapi.NewTagRouter(tagHandler).AddRouter(router) restapi.NewCouponRouter(couponHandler).AddRouter(router) restapi.NewTicketRouter(ticketHandler).AddRouter(router) restapi.NewTableRouter(tableHandler).AddRouter(router) diff --git a/apps/api/presentation/restapi/tag_handler.go b/apps/api/presentation/restapi/tag_handler.go new file mode 100644 index 00000000..2dd3a8a8 --- /dev/null +++ b/apps/api/presentation/restapi/tag_handler.go @@ -0,0 +1,110 @@ +package restapi + +import ( + "apps/api/domain" + apiContract "libs/api-contract" + "net/http" +) + +type TagHandler struct { + usecase domain.TagUsecase +} + +func NewTagHandler(usecase domain.TagUsecase) TagHandler { + return TagHandler{usecase: usecase} +} + +func (handler TagHandler) GetTagList(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + tags, err := handler.usecase.GetTagList(ctx) + if err != nil { + WriteError(ctx, w, apiContract.Error{Code: ToErrorCode(err.Type), Message: err.Message}) + return + } + + apiTags := []apiContract.Tag{} + for _, tag := range tags { + apiTags = append(apiTags, ToApiTag(tag)) + } + + WriteResponse(w, apiContract.TagListResponse{Data: apiTags}) +} + +func (handler TagHandler) GetTagById(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + id, err := GetTagId(r) + if err != nil { + WriteError(ctx, w, apiContract.Error{Code: apiContract.BAD_REQUEST, Message: err.Error()}) + return + } + + tag, usecaseErr := handler.usecase.GetTagById(ctx, id) + if usecaseErr != nil { + WriteError(ctx, w, apiContract.Error{Code: ToErrorCode(usecaseErr.Type), Message: usecaseErr.Message}) + return + } + + WriteResponse(w, apiContract.TagFindByIdResponse{Data: ToApiTag(tag)}) +} + +func (handler TagHandler) CreateTag(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + tagRequest, err := GetTagRequest(r) + if err != nil { + WriteError(ctx, w, apiContract.Error{Code: apiContract.BAD_REQUEST, Message: err.Error()}) + return + } + + tag, usecaseErr := handler.usecase.CreateTag(ctx, ToTag(tagRequest)) + if usecaseErr != nil { + WriteError(ctx, w, apiContract.Error{Code: ToErrorCode(usecaseErr.Type), Message: usecaseErr.Message}) + return + } + + WriteResponse(w, apiContract.TagCreateResponse{Data: ToApiTag(tag)}) +} + +func (handler TagHandler) UpdateTagById(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + id, err := GetTagId(r) + if err != nil { + WriteError(ctx, w, apiContract.Error{Code: apiContract.BAD_REQUEST, Message: err.Error()}) + return + } + + tagRequest, err := GetTagRequest(r) + if err != nil { + WriteError(ctx, w, apiContract.Error{Code: apiContract.BAD_REQUEST, Message: err.Error()}) + return + } + + tag, usecaseErr := handler.usecase.UpdateTagById(ctx, ToTag(tagRequest), id) + + if usecaseErr != nil { + WriteError(ctx, w, apiContract.Error{Code: ToErrorCode(usecaseErr.Type), Message: usecaseErr.Message}) + return + } + + WriteResponse(w, apiContract.TagUpdateByIdResponse{Data: ToApiTag(tag)}) +} + +func (handler TagHandler) DeleteTagById(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + id, err := GetTagId(r) + if err != nil { + WriteError(ctx, w, apiContract.Error{Code: apiContract.BAD_REQUEST, Message: err.Error()}) + return + } + + if err := handler.usecase.DeleteTagById(ctx, id); err != nil { + WriteError(ctx, w, apiContract.Error{Code: ToErrorCode(err.Type), Message: err.Message}) + return + } + + WriteResponse(w, apiContract.SuccessResponse{Success: true}) +} diff --git a/apps/api/presentation/restapi/tag_handler_test.go b/apps/api/presentation/restapi/tag_handler_test.go new file mode 100644 index 00000000..ef739a44 --- /dev/null +++ b/apps/api/presentation/restapi/tag_handler_test.go @@ -0,0 +1,277 @@ +package restapi_test + +import ( + "apps/api/data/mock" + "apps/api/domain" + "apps/api/presentation/restapi" + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestTagHandler_GetTagList(t *testing.T) { + tests := []struct { + name string + setupMock func(r *mock.MockTagRepository) + expectedStatus int + }{ + { + name: "success", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return([]domain.Tag{{Id: 1, Name: "New", Color: domain.TagColorGreen}}, nil) + }, + expectedStatus: http.StatusOK, + }, + { + name: "repo error", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(nil, &domain.Error{Type: domain.InternalServerError, Message: "db error"}) + }, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + handler := restapi.NewTagHandler(domain.NewTagUsecase(mockRepo)) + req := httptest.NewRequest(http.MethodGet, "/tags", nil) + w := httptest.NewRecorder() + handler.GetTagList(w, req) + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestTagHandler_GetTagById(t *testing.T) { + tests := []struct { + name string + tagId string + setupMock func(r *mock.MockTagRepository) + expectedStatus int + }{ + { + name: "success", + tagId: "1", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(domain.Tag{Id: 1, Name: "New", Color: domain.TagColorGreen}, nil) + }, + expectedStatus: http.StatusOK, + }, + { + name: "not found", + tagId: "99", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagById(gomock.Any(), int64(99)).Return(domain.Tag{}, &domain.Error{Type: domain.NotFound, Message: "not found"}) + }, + expectedStatus: http.StatusNotFound, + }, + { + name: "invalid id", + tagId: "abc", + setupMock: func(r *mock.MockTagRepository) {}, + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + handler := restapi.NewTagHandler(domain.NewTagUsecase(mockRepo)) + req := httptest.NewRequest(http.MethodGet, "/tags/"+tt.tagId, nil) + req = mux.SetURLVars(req, map[string]string{"tagId": tt.tagId}) + w := httptest.NewRecorder() + handler.GetTagById(w, req) + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestTagHandler_CreateTag(t *testing.T) { + existing := []domain.Tag{{Id: 1, Name: "New", Color: domain.TagColorGreen}} + + tests := []struct { + name string + body string + setupMock func(r *mock.MockTagRepository) + expectedStatus int + }{ + { + name: "success", + body: `{"name": "Best Seller", "color": "orange", "isHighlighted": true, "sortOrder": 2}`, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(existing, nil) + r.EXPECT().CreateTag(gomock.Any(), domain.Tag{Name: "Best Seller", Color: domain.TagColorOrange, IsHighlighted: true, SortOrder: 2}). + Return(domain.Tag{Id: 2, Name: "Best Seller", Color: domain.TagColorOrange, IsHighlighted: true, SortOrder: 2}, nil) + }, + expectedStatus: http.StatusOK, + }, + { + name: "invalid JSON body", + body: `{invalid json`, + setupMock: func(r *mock.MockTagRepository) {}, + expectedStatus: http.StatusBadRequest, + }, + { + name: "duplicate name", + body: `{"name": "new", "color": "orange", "isHighlighted": false, "sortOrder": 0}`, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(existing, nil) + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "unknown color", + body: `{"name": "Vegan", "color": "teal", "isHighlighted": false, "sortOrder": 0}`, + setupMock: func(r *mock.MockTagRepository) {}, + expectedStatus: http.StatusBadRequest, + }, + { + name: "repo error", + body: `{"name": "Vegan", "color": "green", "isHighlighted": false, "sortOrder": 0}`, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(existing, nil) + r.EXPECT().CreateTag(gomock.Any(), gomock.Any()).Return(domain.Tag{}, &domain.Error{Type: domain.InternalServerError, Message: "db error"}) + }, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + handler := restapi.NewTagHandler(domain.NewTagUsecase(mockRepo)) + req := httptest.NewRequest(http.MethodPost, "/tags", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.CreateTag(w, req) + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestTagHandler_UpdateTagById(t *testing.T) { + existing := []domain.Tag{{Id: 1, Name: "New", Color: domain.TagColorGreen}} + + tests := []struct { + name string + tagId string + body string + setupMock func(r *mock.MockTagRepository) + expectedStatus int + }{ + { + name: "success", + tagId: "1", + body: `{"name": "Brand New", "color": "pink", "isHighlighted": false, "sortOrder": 0}`, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(existing, nil) + r.EXPECT().UpdateTagById(gomock.Any(), domain.Tag{Name: "Brand New", Color: domain.TagColorPink}, int64(1)). + Return(domain.Tag{Id: 1, Name: "Brand New", Color: domain.TagColorPink}, nil) + }, + expectedStatus: http.StatusOK, + }, + { + name: "invalid id", + tagId: "abc", + body: `{"name": "Brand New", "color": "pink", "isHighlighted": false, "sortOrder": 0}`, + setupMock: func(r *mock.MockTagRepository) {}, + expectedStatus: http.StatusBadRequest, + }, + { + name: "invalid JSON body", + tagId: "1", + body: `{invalid json`, + setupMock: func(r *mock.MockTagRepository) {}, + expectedStatus: http.StatusBadRequest, + }, + { + name: "not found", + tagId: "99", + body: `{"name": "Brand New", "color": "pink", "isHighlighted": false, "sortOrder": 0}`, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagList(gomock.Any()).Return(existing, nil) + r.EXPECT().UpdateTagById(gomock.Any(), gomock.Any(), int64(99)).Return(domain.Tag{}, &domain.Error{Type: domain.NotFound, Message: "not found"}) + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + handler := restapi.NewTagHandler(domain.NewTagUsecase(mockRepo)) + req := httptest.NewRequest(http.MethodPut, "/tags/"+tt.tagId, bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + req = mux.SetURLVars(req, map[string]string{"tagId": tt.tagId}) + w := httptest.NewRecorder() + handler.UpdateTagById(w, req) + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestTagHandler_DeleteTagById(t *testing.T) { + tests := []struct { + name string + tagId string + setupMock func(r *mock.MockTagRepository) + expectedStatus int + }{ + { + name: "success", + tagId: "1", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(domain.Tag{Id: 1}, nil) + r.EXPECT().DeleteTagById(gomock.Any(), int64(1)).Return(nil) + }, + expectedStatus: http.StatusOK, + }, + { + name: "invalid id", + tagId: "abc", + setupMock: func(r *mock.MockTagRepository) {}, + expectedStatus: http.StatusBadRequest, + }, + { + name: "not found", + tagId: "99", + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().GetTagById(gomock.Any(), int64(99)).Return(domain.Tag{}, &domain.Error{Type: domain.NotFound, Message: "not found"}) + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + handler := restapi.NewTagHandler(domain.NewTagUsecase(mockRepo)) + req := httptest.NewRequest(http.MethodDelete, "/tags/"+tt.tagId, nil) + req = mux.SetURLVars(req, map[string]string{"tagId": tt.tagId}) + w := httptest.NewRecorder() + handler.DeleteTagById(w, req) + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} diff --git a/apps/api/presentation/restapi/tag_route.go b/apps/api/presentation/restapi/tag_route.go new file mode 100644 index 00000000..31df1888 --- /dev/null +++ b/apps/api/presentation/restapi/tag_route.go @@ -0,0 +1,23 @@ +package restapi + +import ( + "net/http" + + "github.com/gorilla/mux" +) + +type TagRouter struct { + handler TagHandler +} + +func NewTagRouter(handler TagHandler) TagRouter { + return TagRouter{handler: handler} +} + +func (tagRouter TagRouter) AddRouter(router *mux.Router) { + router.HandleFunc("/tags", CheckAuth(tagRouter.handler.GetTagList)).Methods(http.MethodGet) + router.HandleFunc("/tags/{tagId}", CheckAuth(tagRouter.handler.GetTagById)).Methods(http.MethodGet) + router.HandleFunc("/tags/{tagId}", CheckAuth(tagRouter.handler.DeleteTagById)).Methods(http.MethodDelete) + router.HandleFunc("/tags/{tagId}", CheckAuth(tagRouter.handler.UpdateTagById)).Methods(http.MethodPut, http.MethodOptions) + router.HandleFunc("/tags", CheckAuth(tagRouter.handler.CreateTag)).Methods(http.MethodPost, http.MethodOptions) +} diff --git a/apps/api/presentation/restapi/tag_transformer.go b/apps/api/presentation/restapi/tag_transformer.go new file mode 100644 index 00000000..89f72b9f --- /dev/null +++ b/apps/api/presentation/restapi/tag_transformer.go @@ -0,0 +1,44 @@ +package restapi + +import ( + "apps/api/domain" + "encoding/json" + apiContract "libs/api-contract" + "net/http" + "strconv" + + "github.com/gorilla/mux" +) + +func GetTagId(r *http.Request) (int64, error) { + vars := mux.Vars(r) + idParam := vars["tagId"] + id, err := strconv.ParseInt(idParam, 10, 32) + return id, err +} + +func GetTagRequest(r *http.Request) (apiContract.TagRequest, error) { + var tagRequest apiContract.TagRequest + err := json.NewDecoder(r.Body).Decode(&tagRequest) + return tagRequest, err +} + +func ToApiTag(tag domain.Tag) apiContract.Tag { + return apiContract.Tag{ + Id: tag.Id, + Name: tag.Name, + Color: string(tag.Color), + IsHighlighted: tag.IsHighlighted, + SortOrder: int32(tag.SortOrder), + CreatedAt: tag.CreatedAt, + } +} + +func ToTag(tagRequest apiContract.TagRequest) domain.Tag { + return domain.Tag{ + Name: tagRequest.Name, + Color: domain.TagColor(tagRequest.Color), + IsHighlighted: tagRequest.IsHighlighted, + SortOrder: int(tagRequest.SortOrder), + } +} diff --git a/libs/api-contract/src/api.yaml b/libs/api-contract/src/api.yaml index 36995745..ef8965ad 100644 --- a/libs/api-contract/src/api.yaml +++ b/libs/api-contract/src/api.yaml @@ -587,6 +587,128 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /tags: + get: + summary: List all tags + operationId: tagList + tags: + - tag + responses: + '200': + description: success response + content: + application/json: + schema: + $ref: '#/components/schemas/TagListResponse' + '500': + description: server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + summary: Create tag + operationId: tagCreate + tags: + - tag + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TagRequest' + responses: + '200': + description: success response + content: + application/json: + schema: + $ref: '#/components/schemas/TagCreateResponse' + '403': + description: validation error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /tags/{tagId}: + get: + summary: Get tag by id + operationId: tagFindById + tags: + - tag + parameters: + - $ref: '#/components/parameters/TagId' + responses: + '200': + description: success response + content: + application/json: + schema: + $ref: '#/components/schemas/TagFindByIdResponse' + '404': + description: not found error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + put: + summary: Update tag by id + operationId: tagUpdateById + tags: + - tag + parameters: + - $ref: '#/components/parameters/TagId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TagRequest' + responses: + '200': + description: success response + content: + application/json: + schema: + $ref: '#/components/schemas/TagUpdateByIdResponse' + '404': + description: not found error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: validation error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + delete: + summary: Delete tag by id + operationId: tagDeleteById + tags: + - tag + parameters: + - $ref: '#/components/parameters/TagId' + requestBody: + required: false + content: + application/json: + schema: + type: object + responses: + '200': + description: success response + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + '404': + description: not found error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /materials: get: summary: List all materials @@ -3333,6 +3455,75 @@ components: properties: data: $ref: '#/components/schemas/Category' + Tag: + type: object + required: + - id + - name + - color + - isHighlighted + - sortOrder + - createdAt + properties: + id: + type: integer + format: int64 + name: + type: string + color: + type: string + enum: [red, orange, yellow, green, blue, purple, pink, gray] + isHighlighted: + type: boolean + sortOrder: + type: integer + createdAt: + type: string + format: date-time + TagRequest: + required: + - name + - color + - isHighlighted + - sortOrder + properties: + name: + type: string + color: + type: string + enum: [red, orange, yellow, green, blue, purple, pink, gray] + isHighlighted: + type: boolean + sortOrder: + type: integer + TagCreateResponse: + required: + - data + properties: + data: + $ref: '#/components/schemas/Tag' + TagUpdateByIdResponse: + required: + - data + properties: + data: + $ref: '#/components/schemas/Tag' + TagFindByIdResponse: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Tag' + TagListResponse: + type: object + required: + - data + properties: + data: + type: array + items: + $ref: '#/components/schemas/Tag' MaterialSupplier: type: object required: @@ -6242,6 +6433,12 @@ components: schema: type: integer required: true + TagId: + name: tagId + in: path + schema: + type: integer + required: true MaterialId: name: materialId in: path From 5d5ec79e4716c7f0b16e89803a31f5046ceb4305 Mon Sep 17 00:00:00 2001 From: M Nindra Zaka Date: Thu, 1 Oct 2026 17:30:35 +0700 Subject: [PATCH 03/15] feat(variants): add optional image URL to variants (#667) Phase 2 of product tags (docs/prd-product-tags.md). Adds variants.image_url (migration 000045), threads imageUrl through the Go domain, MySQL and REST layers, the OpenAPI contract, and the POS variant form. No reader uses it yet. Claude-Session: https://claude.ai/code/session_01EqQBJZutniFv8dssVNZE1t Co-authored-by: Claude --- .../000045_add_variant_image_url.down.sql | 2 + .../000045_add_variant_image_url.up.sql | 2 + apps/api/data/mysql/variant_entity.go | 1 + apps/api/data/mysql/variant_transformer.go | 2 + .../data/mysql/variant_transformer_test.go | 25 +++++++++ apps/api/domain/variant_entity.go | 1 + .../restapi/variant_handler_test.go | 56 +++++++++++++++++++ .../restapi/variant_transformer.go | 2 + libs/api-contract/src/api.yaml | 4 ++ libs/ui/src/data/api/variant.transformer.ts | 2 + libs/ui/src/data/mock/variant.ts | 2 + libs/ui/src/domain/entities/Variant.ts | 3 + .../src/domain/usecases/variantCreate.test.ts | 30 ++++++++++ libs/ui/src/domain/usecases/variantCreate.ts | 1 + .../src/domain/usecases/variantUpdate.test.ts | 56 +++++++++++++++++++ libs/ui/src/domain/usecases/variantUpdate.ts | 2 + .../variants/VariantFormView.stories.tsx | 2 + .../components/variants/VariantFormView.tsx | 6 ++ .../pos/VariantCreateScreen.stories.tsx | 1 + .../pos/VariantUpdateScreen.stories.tsx | 1 + 20 files changed, 201 insertions(+) create mode 100644 apps/api/data/mysql/migrations/000045_add_variant_image_url.down.sql create mode 100644 apps/api/data/mysql/migrations/000045_add_variant_image_url.up.sql diff --git a/apps/api/data/mysql/migrations/000045_add_variant_image_url.down.sql b/apps/api/data/mysql/migrations/000045_add_variant_image_url.down.sql new file mode 100644 index 00000000..4c341285 --- /dev/null +++ b/apps/api/data/mysql/migrations/000045_add_variant_image_url.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE `variants` + DROP COLUMN `image_url`; diff --git a/apps/api/data/mysql/migrations/000045_add_variant_image_url.up.sql b/apps/api/data/mysql/migrations/000045_add_variant_image_url.up.sql new file mode 100644 index 00000000..bf15b609 --- /dev/null +++ b/apps/api/data/mysql/migrations/000045_add_variant_image_url.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE `variants` + ADD COLUMN `image_url` VARCHAR(255) NULL AFTER `recipe`; diff --git a/apps/api/data/mysql/variant_entity.go b/apps/api/data/mysql/variant_entity.go index 9fddbc19..5834c508 100644 --- a/apps/api/data/mysql/variant_entity.go +++ b/apps/api/data/mysql/variant_entity.go @@ -35,6 +35,7 @@ type Variant struct { Price float32 Description *string Recipe *string + ImageUrl *string Materials []VariantMaterial DeletedAt *time.Time CreatedAt time.Time diff --git a/apps/api/data/mysql/variant_transformer.go b/apps/api/data/mysql/variant_transformer.go index ec29affc..39eeec3d 100644 --- a/apps/api/data/mysql/variant_transformer.go +++ b/apps/api/data/mysql/variant_transformer.go @@ -46,6 +46,7 @@ func ToVariantDB(domainVariant domain.Variant) Variant { Price: domainVariant.Price, Description: domainVariant.Description, Recipe: domainVariant.Recipe, + ImageUrl: domainVariant.ImageUrl, DeletedAt: domainVariant.DeletedAt, CreatedAt: domainVariant.CreatedAt, Product: ToProductDB(domainVariant.Product), @@ -65,6 +66,7 @@ func ToVariantDomain(dbVariant Variant) domain.Variant { Price: dbVariant.Price, Description: dbVariant.Description, Recipe: dbVariant.Recipe, + ImageUrl: dbVariant.ImageUrl, DeletedAt: dbVariant.DeletedAt, CreatedAt: dbVariant.CreatedAt, Product: ToProductDomain(dbVariant.Product), diff --git a/apps/api/data/mysql/variant_transformer_test.go b/apps/api/data/mysql/variant_transformer_test.go index 1576a7d3..cb264b52 100644 --- a/apps/api/data/mysql/variant_transformer_test.go +++ b/apps/api/data/mysql/variant_transformer_test.go @@ -42,3 +42,28 @@ func TestVariantTransformerRoundTrip_AvailabilityDefaults(t *testing.T) { assert.Equal(t, false, roundTripped.IsAvailable) assert.Nil(t, roundTripped.AvailableQuantity) } + +func TestVariantTransformerRoundTrip_ImageUrl(t *testing.T) { + imageUrl := "https://example.com/ice-cream.jpg" + variant := domain.Variant{ + Id: 1, + ProductId: 2, + Name: "Ice Cream", + Price: 15000, + ImageUrl: &imageUrl, + } + + dbVariant := mysql.ToVariantDB(variant) + assert.Equal(t, &imageUrl, dbVariant.ImageUrl) + + roundTripped := mysql.ToVariantDomain(dbVariant) + assert.Equal(t, variant.ImageUrl, roundTripped.ImageUrl) +} + +func TestVariantTransformerRoundTrip_ImageUrlOmitted(t *testing.T) { + variant := domain.Variant{Id: 1, ProductId: 2, Name: "Choco", Price: 15000} + + roundTripped := mysql.ToVariantDomain(mysql.ToVariantDB(variant)) + + assert.Nil(t, roundTripped.ImageUrl) +} diff --git a/apps/api/domain/variant_entity.go b/apps/api/domain/variant_entity.go index 2aac42a9..3f9671e3 100644 --- a/apps/api/domain/variant_entity.go +++ b/apps/api/domain/variant_entity.go @@ -35,6 +35,7 @@ type Variant struct { Price float32 Description *string Recipe *string + ImageUrl *string Materials []VariantMaterial DeletedAt *time.Time CreatedAt time.Time diff --git a/apps/api/presentation/restapi/variant_handler_test.go b/apps/api/presentation/restapi/variant_handler_test.go index ddcb9b3e..3cdff242 100644 --- a/apps/api/presentation/restapi/variant_handler_test.go +++ b/apps/api/presentation/restapi/variant_handler_test.go @@ -6,6 +6,7 @@ import ( "apps/api/presentation/restapi" "bytes" "context" + "encoding/json" "net/http" "net/http/httptest" "testing" @@ -223,6 +224,61 @@ func TestVariantHandler_UpdateVariantById(t *testing.T) { } } +func TestVariantHandler_UpdateVariantById_ImageUrl(t *testing.T) { + imageUrl := "https://example.com/ice-cream.jpg" + + tests := []struct { + name string + body string + expectedImageUrl *string + }{ + { + name: "with imageUrl", + body: `{"productId": 1, "name": "Ice Cream", "price": 20000, "imageUrl": "https://example.com/ice-cream.jpg", "materials": [], "values": []}`, + expectedImageUrl: &imageUrl, + }, + { + name: "without imageUrl", + body: `{"productId": 1, "name": "Ice Cream", "price": 20000, "materials": [], "values": []}`, + expectedImageUrl: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + variantRepo := mock.NewMockVariantRepository(ctrl) + productRepo := mock.NewMockProductRepository(ctrl) + variantRepo.EXPECT().BeginTransaction(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, cb func(context.Context) *domain.Error) *domain.Error { return cb(ctx) }) + variantRepo.EXPECT().GetVariantById(gomock.Any(), int64(1)).Return(domain.Variant{Id: 1, ProductId: 1}, nil) + productRepo.EXPECT().GetProductById(gomock.Any(), int64(1)).Return(domain.Product{Id: 1, SaleType: domain.SaleTypePurchase}, nil) + variantRepo.EXPECT().UpdateVariantById(gomock.Any(), gomock.Any(), int64(1)).DoAndReturn( + func(ctx context.Context, variant domain.Variant, id int64) (domain.Variant, *domain.Error) { + assert.Equal(t, tt.expectedImageUrl, variant.ImageUrl) + return variant, nil + }) + + handler := newTestVariantHandler(ctrl, variantRepo, productRepo) + req := httptest.NewRequest(http.MethodPut, "/variants/1", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + req = mux.SetURLVars(req, map[string]string{"variantId": "1"}) + w := httptest.NewRecorder() + handler.UpdateVariantById(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var response struct { + Data struct { + ImageUrl *string `json:"imageUrl"` + } `json:"data"` + } + assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + assert.Equal(t, tt.expectedImageUrl, response.Data.ImageUrl) + }) + } +} + func TestVariantHandler_DeleteVariantById(t *testing.T) { tests := []struct { name string diff --git a/apps/api/presentation/restapi/variant_transformer.go b/apps/api/presentation/restapi/variant_transformer.go index fe8c3a2c..fc5e2d31 100644 --- a/apps/api/presentation/restapi/variant_transformer.go +++ b/apps/api/presentation/restapi/variant_transformer.go @@ -99,6 +99,7 @@ func ToApiVariant(variant domain.Variant) apiContract.Variant { CreatedAt: variant.CreatedAt, Description: variant.Description, Recipe: variant.Recipe, + ImageUrl: variant.ImageUrl, Values: apiVariantValues, PricingTiers: apiPricingTiers, IsAvailable: variant.IsAvailable, @@ -149,6 +150,7 @@ func ToVariant(variantRequest apiContract.VariantRequest) domain.Variant { Materials: variantMaterials, Description: variantRequest.Description, Recipe: variantRequest.Recipe, + ImageUrl: variantRequest.ImageUrl, VariantValues: variantValues, PricingTiers: pricingTiers, } diff --git a/libs/api-contract/src/api.yaml b/libs/api-contract/src/api.yaml index ef8965ad..fe3197e5 100644 --- a/libs/api-contract/src/api.yaml +++ b/libs/api-contract/src/api.yaml @@ -4217,6 +4217,8 @@ components: type: string recipe: type: string + imageUrl: + type: string materials: type: array items: @@ -4265,6 +4267,8 @@ components: type: string recipe: type: string + imageUrl: + type: string materials: type: array items: diff --git a/libs/ui/src/data/api/variant.transformer.ts b/libs/ui/src/data/api/variant.transformer.ts index db758947..ba616db8 100644 --- a/libs/ui/src/data/api/variant.transformer.ts +++ b/libs/ui/src/data/api/variant.transformer.ts @@ -17,6 +17,7 @@ export function toVariant(variant: ApiVariant): Variant { })), description: variant.description ?? '', recipe: variant.recipe ?? '', + imageUrl: variant.imageUrl || undefined, product: { category: { createdAt: variant.product.category.createdAt, @@ -71,6 +72,7 @@ export function toApiVariant(form: VariantForm) { })), description: form.description, recipe: form.recipe, + imageUrl: form.imageUrl, values: form.values.map(({ id, optionValueId }) => ({ id, optionValueId, diff --git a/libs/ui/src/data/mock/variant.ts b/libs/ui/src/data/mock/variant.ts index 2d043bb6..beb774e1 100644 --- a/libs/ui/src/data/mock/variant.ts +++ b/libs/ui/src/data/mock/variant.ts @@ -100,6 +100,7 @@ export class MockVariantRepository implements VariantRepository { price: formValues.price, description: formValues.description, recipe: formValues.recipe, + imageUrl: formValues.imageUrl || undefined, materials: [], product: mockProduct, createdAt: new Date().toISOString(), @@ -123,6 +124,7 @@ export class MockVariantRepository implements VariantRepository { price: formValues.price, description: formValues.description, recipe: formValues.recipe, + imageUrl: formValues.imageUrl || undefined, }; } diff --git a/libs/ui/src/domain/entities/Variant.ts b/libs/ui/src/domain/entities/Variant.ts index 92cfb7df..7e8bde9a 100644 --- a/libs/ui/src/domain/entities/Variant.ts +++ b/libs/ui/src/domain/entities/Variant.ts @@ -13,6 +13,7 @@ export type Variant = { price: number; description?: string; recipe?: string; + imageUrl?: string; materials: { id: number; materialId: number; @@ -44,6 +45,7 @@ export type VariantForm = { price: number; description?: string; recipe?: string; + imageUrl?: string; materials: { id?: number; materialId: number; @@ -63,6 +65,7 @@ export const variantFormSchema = z.object({ name: z.string().min(1), description: z.string(), recipe: z.string(), + imageUrl: z.string().optional(), materials: z.array( z.lazy(() => z.object({ materialId: z.number(), amount: z.number() })) ), diff --git a/libs/ui/src/domain/usecases/variantCreate.test.ts b/libs/ui/src/domain/usecases/variantCreate.test.ts index da789994..0672f6c2 100644 --- a/libs/ui/src/domain/usecases/variantCreate.test.ts +++ b/libs/ui/src/domain/usecases/variantCreate.test.ts @@ -115,4 +115,34 @@ describe('VariantCreateUsecase', () => { expect(tester.state.type).toBe('submitSuccess'); expect(variantRepository.variants.at(-1)?.recipe).toBe('Shake well before serving'); }); + + it('starts with an empty imageUrl and persists one when submitting', async () => { + const variantRepository = new MockVariantRepository(); + const productRepository = new MockProductRepository(); + const usecase = new VariantCreateUsecase(variantRepository, productRepository, { + productId: 1, + product: productRepository.products[0], + }); + const tester = new UsecaseTester(usecase); + expect(tester.state.values.imageUrl).toBe(''); + + tester.dispatch({ + type: 'SUBMIT', + values: { + productId: 1, + name: 'Ice Cream', + price: 50000, + description: '', + recipe: '', + imageUrl: 'https://example.com/ice-cream.jpg', + materials: [], + values: [], + pricingTiers: [], + }, + }); + + await flushPromises(); + expect(tester.state.type).toBe('submitSuccess'); + expect(variantRepository.variants.at(-1)?.imageUrl).toBe('https://example.com/ice-cream.jpg'); + }); }); diff --git a/libs/ui/src/domain/usecases/variantCreate.ts b/libs/ui/src/domain/usecases/variantCreate.ts index 77ea1647..b8e16924 100644 --- a/libs/ui/src/domain/usecases/variantCreate.ts +++ b/libs/ui/src/domain/usecases/variantCreate.ts @@ -62,6 +62,7 @@ export class VariantCreateUsecase extends Usecase< price: 0, description: '', recipe: '', + imageUrl: '', values: [], pricingTiers: [], }; diff --git a/libs/ui/src/domain/usecases/variantUpdate.test.ts b/libs/ui/src/domain/usecases/variantUpdate.test.ts index 013e3bec..d6607028 100644 --- a/libs/ui/src/domain/usecases/variantUpdate.test.ts +++ b/libs/ui/src/domain/usecases/variantUpdate.test.ts @@ -147,4 +147,60 @@ describe('VariantUpdateUsecase', () => { expect(tester.state.type).toBe('submitSuccess'); expect(variantRepository.variants.find((v) => v.id === existing.id)?.recipe).toBe('Shake well before serving'); }); + + it('pre-fills imageUrl from the fetched variant, empty when absent', () => { + const variantRepository = new MockVariantRepository(); + const productRepository = new MockProductRepository(); + const buildTester = (variant: (typeof variantRepository.variants)[number]) => + new UsecaseTester( + new VariantUpdateUsecase(variantRepository, productRepository, { + variantId: 1, + variant, + productId: 1, + product: productRepository.products[0], + }) + ); + + const withImage = buildTester({ ...variantRepository.variants[0], imageUrl: 'https://example.com/ice-cream.jpg' }); + const withoutImage = buildTester({ ...variantRepository.variants[0], imageUrl: undefined }); + + expect(withImage.state.values.imageUrl).toBe('https://example.com/ice-cream.jpg'); + expect(withoutImage.state.values.imageUrl).toBe(''); + }); + + it.each([ + ['persists an updated imageUrl', undefined, 'https://example.com/ice-cream.jpg', 'https://example.com/ice-cream.jpg'], + ['clears the imageUrl when emptied', 'https://example.com/ice-cream.jpg', '', undefined], + ])('%s', async (_name, initialImageUrl, submittedImageUrl, expectedImageUrl) => { + const variantRepository = new MockVariantRepository(); + const productRepository = new MockProductRepository(); + const existing = { ...variantRepository.variants[0], imageUrl: initialImageUrl }; + variantRepository.variants[0] = existing; + const usecase = new VariantUpdateUsecase(variantRepository, productRepository, { + variantId: existing.id, + variant: existing, + productId: 1, + product: productRepository.products[0], + }); + const tester = new UsecaseTester(usecase); + + tester.dispatch({ + type: 'SUBMIT', + values: { + productId: 1, + name: existing.name, + price: existing.price, + description: '', + recipe: '', + imageUrl: submittedImageUrl, + materials: [], + values: [], + pricingTiers: [], + }, + }); + + await flushPromises(); + expect(tester.state.type).toBe('submitSuccess'); + expect(variantRepository.variants.find((v) => v.id === existing.id)?.imageUrl).toBe(expectedImageUrl); + }); }); diff --git a/libs/ui/src/domain/usecases/variantUpdate.ts b/libs/ui/src/domain/usecases/variantUpdate.ts index 7e730ec2..b2f8646d 100644 --- a/libs/ui/src/domain/usecases/variantUpdate.ts +++ b/libs/ui/src/domain/usecases/variantUpdate.ts @@ -71,6 +71,7 @@ export class VariantUpdateUsecase extends Usecase< price: this.params.variant?.price ?? 0, description: this.params.variant?.description ?? '', recipe: this.params.variant?.recipe ?? '', + imageUrl: this.params.variant?.imageUrl ?? '', values: this.params.variant?.values.map((value) => ({ id: value.id, @@ -178,6 +179,7 @@ export class VariantUpdateUsecase extends Usecase< productId: variant.product.id, description: variant.description, recipe: variant.recipe, + imageUrl: variant.imageUrl ?? '', values: variant.values.map((value) => ({ id: value.id, optionValueId: value.optionValueId, diff --git a/libs/ui/src/presentation/views/components/variants/VariantFormView.stories.tsx b/libs/ui/src/presentation/views/components/variants/VariantFormView.stories.tsx index 99522939..e6b34d30 100644 --- a/libs/ui/src/presentation/views/components/variants/VariantFormView.stories.tsx +++ b/libs/ui/src/presentation/views/components/variants/VariantFormView.stories.tsx @@ -10,6 +10,7 @@ const defaultValues: VariantForm = { price: 0, description: '', recipe: '', + imageUrl: '', materials: [], productId: 1, values: [], @@ -49,6 +50,7 @@ export const Populated: Story = { description: 'Iced version, regular size', recipe: '- Use regular ice, not crushed.\n- Standard 250ml cup.\n- No extra syrup unless requested.', + imageUrl: 'https://example.com/iced-regular.jpg', materials: [{ materialId: 1, amount: 0.015, material: mockMaterial }], productId: 1, values: [{ optionValueId: 1 }], diff --git a/libs/ui/src/presentation/views/components/variants/VariantFormView.tsx b/libs/ui/src/presentation/views/components/variants/VariantFormView.tsx index 640884f3..69be0d3c 100644 --- a/libs/ui/src/presentation/views/components/variants/VariantFormView.tsx +++ b/libs/ui/src/presentation/views/components/variants/VariantFormView.tsx @@ -119,6 +119,12 @@ export const VariantFormView = (props: VariantFormViewProps) => { One short line shown to customers in the order app. + + + + Optional. Leave empty to use the product image. + + diff --git a/libs/ui/src/presentation/views/screens/pos/VariantCreateScreen.stories.tsx b/libs/ui/src/presentation/views/screens/pos/VariantCreateScreen.stories.tsx index 85557db3..cff63e45 100644 --- a/libs/ui/src/presentation/views/screens/pos/VariantCreateScreen.stories.tsx +++ b/libs/ui/src/presentation/views/screens/pos/VariantCreateScreen.stories.tsx @@ -9,6 +9,7 @@ const defaultValues: VariantForm = { price: 0, description: '', recipe: '', + imageUrl: '', materials: [], productId: mockProduct.id, values: [], diff --git a/libs/ui/src/presentation/views/screens/pos/VariantUpdateScreen.stories.tsx b/libs/ui/src/presentation/views/screens/pos/VariantUpdateScreen.stories.tsx index f25e93d9..e4872904 100644 --- a/libs/ui/src/presentation/views/screens/pos/VariantUpdateScreen.stories.tsx +++ b/libs/ui/src/presentation/views/screens/pos/VariantUpdateScreen.stories.tsx @@ -13,6 +13,7 @@ const defaultValues: VariantForm = { price: mockVariant.price, description: mockVariant.description, recipe: mockVariant.recipe, + imageUrl: mockVariant.imageUrl ?? '', materials: mockVariant.materials, productId: mockProduct.id, values: mockVariant.values.map((value) => ({ From 259643ce8a0f5b44be3ccfd1d811c49ec1e6c456 Mon Sep 17 00:00:00 2001 From: M Nindra Zaka Date: Fri, 2 Oct 2026 05:10:09 +0700 Subject: [PATCH 04/15] feat(api): add variant-tag assignment endpoints (#669) Add the variant_tags join table, PUT /tags/{tagId}/variants and optional VariantRequest.tagIds. Both write paths diff against the stored pairs so untouched rows keep their created_at, which later orders highlight entries by taggedAt. Omitted tagIds leave a variant's tags alone. Variant reads preload their tags, and tag reads return variantCount for the delete confirmation. Also renumber add_variant_image_url to 000046: it collided with 000045_create_tags, which golang-migrate rejects as a duplicate version. Claude-Session: https://claude.ai/code/session_01FXmvmGqaJhBHmyYxbbeHoX Co-authored-by: Claude --- apps/api/data/mock/tag_repository.go | 77 ++++++++- ... => 000046_add_variant_image_url.down.sql} | 0 ...ql => 000046_add_variant_image_url.up.sql} | 0 .../000047_create_variant_tags.down.sql | 1 + .../000047_create_variant_tags.up.sql | 9 + apps/api/data/mysql/tag_entity.go | 8 + apps/api/data/mysql/tag_repo.go | 61 ++++++- apps/api/data/mysql/tag_repo_test.go | 71 +++++++- apps/api/data/mysql/tag_transformer.go | 20 +++ apps/api/data/mysql/variant_entity.go | 1 + apps/api/data/mysql/variant_repo.go | 12 ++ apps/api/data/mysql/variant_transformer.go | 1 + .../data/mysql/variant_transformer_test.go | 21 +++ apps/api/domain/tag_assignment.go | 51 ++++++ apps/api/domain/tag_entity.go | 11 ++ apps/api/domain/tag_repository.go | 5 + apps/api/domain/tag_usecase.go | 41 +++++ apps/api/domain/tag_usecase_test.go | 116 +++++++++++++ apps/api/domain/variant_entity.go | 2 + apps/api/domain/variant_usecase.go | 74 +++++++- apps/api/domain/variant_usecase_test.go | 161 +++++++++++++++++- apps/api/main.go | 2 +- .../restapi/public_handler_test.go | 2 +- apps/api/presentation/restapi/tag_handler.go | 32 +++- .../presentation/restapi/tag_handler_test.go | 79 +++++++++ apps/api/presentation/restapi/tag_route.go | 1 + .../presentation/restapi/tag_transformer.go | 20 +++ .../restapi/variant_handler_test.go | 78 ++++++++- .../restapi/variant_transformer.go | 7 + libs/api-contract/src/api.yaml | 74 ++++++++ 30 files changed, 1018 insertions(+), 20 deletions(-) rename apps/api/data/mysql/migrations/{000045_add_variant_image_url.down.sql => 000046_add_variant_image_url.down.sql} (100%) rename apps/api/data/mysql/migrations/{000045_add_variant_image_url.up.sql => 000046_add_variant_image_url.up.sql} (100%) create mode 100644 apps/api/data/mysql/migrations/000047_create_variant_tags.down.sql create mode 100644 apps/api/data/mysql/migrations/000047_create_variant_tags.up.sql create mode 100644 apps/api/domain/tag_assignment.go diff --git a/apps/api/data/mock/tag_repository.go b/apps/api/data/mock/tag_repository.go index 071bb311..7ddbcfe2 100644 --- a/apps/api/data/mock/tag_repository.go +++ b/apps/api/data/mock/tag_repository.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: tag_repository.go +// Source: domain/tag_repository.go // // Generated by this command: // -// mockgen -source=tag_repository.go -destination=../data/mock/tag_repository.go -package=mock +// mockgen -source=domain/tag_repository.go -destination=data/mock/tag_repository.go -package=mock // // Package mock is a generated GoMock package. @@ -84,6 +84,35 @@ func (mr *MockTagRepositoryMockRecorder) DeleteTagById(ctx, id any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTagById", reflect.TypeOf((*MockTagRepository)(nil).DeleteTagById), ctx, id) } +// DeleteVariantTags mocks base method. +func (m *MockTagRepository) DeleteVariantTags(ctx context.Context, pairs []domain.VariantTagPair) *domain.Error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteVariantTags", ctx, pairs) + ret0, _ := ret[0].(*domain.Error) + return ret0 +} + +// DeleteVariantTags indicates an expected call of DeleteVariantTags. +func (mr *MockTagRepositoryMockRecorder) DeleteVariantTags(ctx, pairs any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteVariantTags", reflect.TypeOf((*MockTagRepository)(nil).DeleteVariantTags), ctx, pairs) +} + +// GetLiveVariantIds mocks base method. +func (m *MockTagRepository) GetLiveVariantIds(ctx context.Context, variantIds []int64) ([]int64, *domain.Error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLiveVariantIds", ctx, variantIds) + ret0, _ := ret[0].([]int64) + ret1, _ := ret[1].(*domain.Error) + return ret0, ret1 +} + +// GetLiveVariantIds indicates an expected call of GetLiveVariantIds. +func (mr *MockTagRepositoryMockRecorder) GetLiveVariantIds(ctx, variantIds any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLiveVariantIds", reflect.TypeOf((*MockTagRepository)(nil).GetLiveVariantIds), ctx, variantIds) +} + // GetTagById mocks base method. func (m *MockTagRepository) GetTagById(ctx context.Context, id int64) (domain.Tag, *domain.Error) { m.ctrl.T.Helper() @@ -114,6 +143,50 @@ func (mr *MockTagRepositoryMockRecorder) GetTagList(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTagList", reflect.TypeOf((*MockTagRepository)(nil).GetTagList), ctx) } +// GetTagVariantIds mocks base method. +func (m *MockTagRepository) GetTagVariantIds(ctx context.Context, tagId int64) ([]int64, *domain.Error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTagVariantIds", ctx, tagId) + ret0, _ := ret[0].([]int64) + ret1, _ := ret[1].(*domain.Error) + return ret0, ret1 +} + +// GetTagVariantIds indicates an expected call of GetTagVariantIds. +func (mr *MockTagRepositoryMockRecorder) GetTagVariantIds(ctx, tagId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTagVariantIds", reflect.TypeOf((*MockTagRepository)(nil).GetTagVariantIds), ctx, tagId) +} + +// GetVariantTagIds mocks base method. +func (m *MockTagRepository) GetVariantTagIds(ctx context.Context, variantId int64) ([]int64, *domain.Error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetVariantTagIds", ctx, variantId) + ret0, _ := ret[0].([]int64) + ret1, _ := ret[1].(*domain.Error) + return ret0, ret1 +} + +// GetVariantTagIds indicates an expected call of GetVariantTagIds. +func (mr *MockTagRepositoryMockRecorder) GetVariantTagIds(ctx, variantId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetVariantTagIds", reflect.TypeOf((*MockTagRepository)(nil).GetVariantTagIds), ctx, variantId) +} + +// InsertVariantTags mocks base method. +func (m *MockTagRepository) InsertVariantTags(ctx context.Context, pairs []domain.VariantTagPair) *domain.Error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertVariantTags", ctx, pairs) + ret0, _ := ret[0].(*domain.Error) + return ret0 +} + +// InsertVariantTags indicates an expected call of InsertVariantTags. +func (mr *MockTagRepositoryMockRecorder) InsertVariantTags(ctx, pairs any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertVariantTags", reflect.TypeOf((*MockTagRepository)(nil).InsertVariantTags), ctx, pairs) +} + // UpdateTagById mocks base method. func (m *MockTagRepository) UpdateTagById(ctx context.Context, tag domain.Tag, id int64) (domain.Tag, *domain.Error) { m.ctrl.T.Helper() diff --git a/apps/api/data/mysql/migrations/000045_add_variant_image_url.down.sql b/apps/api/data/mysql/migrations/000046_add_variant_image_url.down.sql similarity index 100% rename from apps/api/data/mysql/migrations/000045_add_variant_image_url.down.sql rename to apps/api/data/mysql/migrations/000046_add_variant_image_url.down.sql diff --git a/apps/api/data/mysql/migrations/000045_add_variant_image_url.up.sql b/apps/api/data/mysql/migrations/000046_add_variant_image_url.up.sql similarity index 100% rename from apps/api/data/mysql/migrations/000045_add_variant_image_url.up.sql rename to apps/api/data/mysql/migrations/000046_add_variant_image_url.up.sql diff --git a/apps/api/data/mysql/migrations/000047_create_variant_tags.down.sql b/apps/api/data/mysql/migrations/000047_create_variant_tags.down.sql new file mode 100644 index 00000000..f8ee4859 --- /dev/null +++ b/apps/api/data/mysql/migrations/000047_create_variant_tags.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `variant_tags`; diff --git a/apps/api/data/mysql/migrations/000047_create_variant_tags.up.sql b/apps/api/data/mysql/migrations/000047_create_variant_tags.up.sql new file mode 100644 index 00000000..f574b9a6 --- /dev/null +++ b/apps/api/data/mysql/migrations/000047_create_variant_tags.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS `variant_tags` ( + `variant_id` BIGINT NOT NULL, + `tag_id` BIGINT NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`variant_id`, `tag_id`), + KEY `idx_variant_tags_tag_id` (`tag_id`), + CONSTRAINT `fk_variant_tags_variant` FOREIGN KEY (`variant_id`) REFERENCES `variants` (`id`), + CONSTRAINT `fk_variant_tags_tag` FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/apps/api/data/mysql/tag_entity.go b/apps/api/data/mysql/tag_entity.go index 81c7bb16..0090315e 100644 --- a/apps/api/data/mysql/tag_entity.go +++ b/apps/api/data/mysql/tag_entity.go @@ -8,5 +8,13 @@ type Tag struct { Color string IsHighlighted bool SortOrder int + VariantCount int64 `gorm:"->"` CreatedAt time.Time } + +type VariantTag struct { + VariantId int64 `gorm:"primaryKey"` + TagId int64 `gorm:"primaryKey"` + Tag Tag + CreatedAt time.Time +} diff --git a/apps/api/data/mysql/tag_repo.go b/apps/api/data/mysql/tag_repo.go index 2be6478f..846d4b35 100644 --- a/apps/api/data/mysql/tag_repo.go +++ b/apps/api/data/mysql/tag_repo.go @@ -5,23 +5,28 @@ import ( "context" "gorm.io/gorm" + "gorm.io/gorm/clause" ) func NewTagRepository(db *gorm.DB) domain.TagRepository { return Repository{db: db} } +func selectTagsWithVariantCount(db *gorm.DB) *gorm.DB { + return db.Table("tags").Select("tags.*, (SELECT COUNT(*) FROM variant_tags JOIN variants ON variants.id = variant_tags.variant_id WHERE variant_tags.tag_id = tags.id AND variants.deleted_at IS NULL) AS variant_count") +} + func (repo Repository) GetTagList(ctx context.Context) ([]domain.Tag, *domain.Error) { db := GetDbFromCtx(ctx, repo.db) var tags []Tag - result := db.Table("tags").Order("sort_order ASC, name ASC").Find(&tags) + result := selectTagsWithVariantCount(db).Order("sort_order ASC, name ASC").Find(&tags) return ToTagListDomain(tags), ToErrorCtx(ctx, result.Error, "GetTagList") } func (repo Repository) GetTagById(ctx context.Context, id int64) (domain.Tag, *domain.Error) { db := GetDbFromCtx(ctx, repo.db) var tag Tag - result := db.Table("tags").Where("id = ?", id).First(&tag) + result := selectTagsWithVariantCount(db).Where("tags.id = ?", id).First(&tag) return ToTagDomain(tag), ToErrorCtx(ctx, result.Error, "GetTagById") } @@ -43,7 +48,7 @@ func (repo Repository) UpdateTagById(ctx context.Context, tag domain.Tag, id int } var updatedTag Tag - fetchResult := db.Table("tags").Where("id = ?", id).First(&updatedTag) + fetchResult := selectTagsWithVariantCount(db).Where("tags.id = ?", id).First(&updatedTag) return ToTagDomain(updatedTag), ToErrorCtx(ctx, fetchResult.Error, "UpdateTagById") } @@ -52,3 +57,53 @@ func (repo Repository) DeleteTagById(ctx context.Context, id int64) *domain.Erro result := db.Table("tags").Where("id = ?", id).Delete(&Tag{}) return ToErrorCtx(ctx, result.Error, "DeleteTagById") } + +func (repo Repository) GetTagVariantIds(ctx context.Context, tagId int64) ([]int64, *domain.Error) { + db := GetDbFromCtx(ctx, repo.db) + variantIds := []int64{} + result := db.Table("variant_tags").Where("tag_id = ?", tagId).Order("variant_id ASC").Pluck("variant_id", &variantIds) + return variantIds, ToErrorCtx(ctx, result.Error, "GetTagVariantIds") +} + +func (repo Repository) GetVariantTagIds(ctx context.Context, variantId int64) ([]int64, *domain.Error) { + db := GetDbFromCtx(ctx, repo.db) + tagIds := []int64{} + result := db.Table("variant_tags").Where("variant_id = ?", variantId).Order("tag_id ASC").Pluck("tag_id", &tagIds) + return tagIds, ToErrorCtx(ctx, result.Error, "GetVariantTagIds") +} + +func (repo Repository) GetLiveVariantIds(ctx context.Context, variantIds []int64) ([]int64, *domain.Error) { + liveVariantIds := []int64{} + if len(variantIds) == 0 { + return liveVariantIds, nil + } + + db := GetDbFromCtx(ctx, repo.db) + result := db.Table("variants").Where("id IN ? AND deleted_at IS NULL", variantIds).Pluck("id", &liveVariantIds) + return liveVariantIds, ToErrorCtx(ctx, result.Error, "GetLiveVariantIds") +} + +func (repo Repository) InsertVariantTags(ctx context.Context, pairs []domain.VariantTagPair) *domain.Error { + if len(pairs) == 0 { + return nil + } + + db := GetDbFromCtx(ctx, repo.db) + payload := ToVariantTagPairsDB(pairs) + result := db.Table("variant_tags").Clauses(clause.OnConflict{DoNothing: true}).Create(&payload) + return ToErrorCtx(ctx, result.Error, "InsertVariantTags") +} + +func (repo Repository) DeleteVariantTags(ctx context.Context, pairs []domain.VariantTagPair) *domain.Error { + if len(pairs) == 0 { + return nil + } + + db := GetDbFromCtx(ctx, repo.db) + tuples := [][]interface{}{} + for _, pair := range pairs { + tuples = append(tuples, []interface{}{pair.VariantId, pair.TagId}) + } + result := db.Table("variant_tags").Where("(variant_id, tag_id) IN ?", tuples).Delete(&VariantTag{}) + return ToErrorCtx(ctx, result.Error, "DeleteVariantTags") +} diff --git a/apps/api/data/mysql/tag_repo_test.go b/apps/api/data/mysql/tag_repo_test.go index 9d40d7ef..327ba2c2 100644 --- a/apps/api/data/mysql/tag_repo_test.go +++ b/apps/api/data/mysql/tag_repo_test.go @@ -36,7 +36,7 @@ func TestTagRepository_Update_WritesFalseAndZeroValues(t *testing.T) { WithArgs("New", "green", false, 0, int64(1)). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() - mock.ExpectQuery("SELECT \\* FROM `tags` WHERE id = \\? ORDER BY `tags`.`id` LIMIT \\?"). + mock.ExpectQuery("SELECT tags.\\*, \\(SELECT COUNT\\(\\*\\) FROM variant_tags .+ AS variant_count FROM `tags` WHERE tags.id = \\? ORDER BY `tags`.`id` LIMIT \\?"). WithArgs(int64(1), 1). WillReturnRows(sqlmock.NewRows([]string{"id", "name", "color", "is_highlighted", "sort_order"}). AddRow(1, "New", "green", false, 0)) @@ -66,10 +66,10 @@ func TestTagRepository_Delete_RemovesTheRow(t *testing.T) { func TestTagRepository_GetList_OrdersBySortOrderThenName(t *testing.T) { repo, mock := newMockTagRepository(t) - mock.ExpectQuery("SELECT \\* FROM `tags` ORDER BY sort_order ASC, name ASC"). - WillReturnRows(sqlmock.NewRows([]string{"id", "name", "color", "is_highlighted", "sort_order"}). - AddRow(2, "Best Seller", "orange", true, 1). - AddRow(1, "New", "green", true, 2)) + mock.ExpectQuery("SELECT tags.\\*, \\(SELECT COUNT\\(\\*\\) FROM variant_tags .+ AS variant_count FROM `tags` ORDER BY sort_order ASC, name ASC"). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "color", "is_highlighted", "sort_order", "variant_count"}). + AddRow(2, "Best Seller", "orange", true, 1, 4). + AddRow(1, "New", "green", true, 2, 0)) tags, err := repo.GetTagList(context.Background()) @@ -77,5 +77,66 @@ func TestTagRepository_GetList_OrdersBySortOrderThenName(t *testing.T) { require.Len(t, tags, 2) require.Equal(t, "Best Seller", tags[0].Name) require.True(t, tags[0].IsHighlighted) + require.Equal(t, int64(4), tags[0].VariantCount) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestTagRepository_InsertVariantTags_IgnoresPairsThatAlreadyExist(t *testing.T) { + repo, mock := newMockTagRepository(t) + + mock.ExpectBegin() + mock.ExpectExec("INSERT INTO `variant_tags` \\(`variant_id`,`tag_id`,`created_at`\\) VALUES \\(\\?,\\?,\\?\\),\\(\\?,\\?,\\?\\) ON DUPLICATE KEY UPDATE `variant_id`=`variant_id`"). + WithArgs(int64(1), int64(5), sqlmock.AnyArg(), int64(2), int64(5), sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(0, 2)) + mock.ExpectCommit() + + err := repo.InsertVariantTags(context.Background(), []domain.VariantTagPair{ + {VariantId: 1, TagId: 5}, + {VariantId: 2, TagId: 5}, + }) + + require.Nil(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestTagRepository_DeleteVariantTags_RemovesOnlyTheGivenPairs(t *testing.T) { + repo, mock := newMockTagRepository(t) + + mock.ExpectBegin() + mock.ExpectExec("DELETE FROM `variant_tags` WHERE \\(variant_id, tag_id\\) IN \\(\\(\\?,\\?\\),\\(\\?,\\?\\)\\)"). + WithArgs(int64(1), int64(5), int64(2), int64(5)). + WillReturnResult(sqlmock.NewResult(0, 2)) + mock.ExpectCommit() + + err := repo.DeleteVariantTags(context.Background(), []domain.VariantTagPair{ + {VariantId: 1, TagId: 5}, + {VariantId: 2, TagId: 5}, + }) + + require.Nil(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestTagRepository_GetLiveVariantIds_SkipsDeletedAndUnknownVariants(t *testing.T) { + repo, mock := newMockTagRepository(t) + + mock.ExpectQuery("SELECT `id` FROM `variants` WHERE id IN \\(\\?,\\?,\\?\\) AND deleted_at IS NULL"). + WithArgs(int64(1), int64(2), int64(3)). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1).AddRow(3)) + + liveIds, err := repo.GetLiveVariantIds(context.Background(), []int64{1, 2, 3}) + + require.Nil(t, err) + require.Equal(t, []int64{1, 3}, liveIds) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestTagRepository_GetLiveVariantIds_EmptyInputSkipsTheQuery(t *testing.T) { + repo, mock := newMockTagRepository(t) + + liveIds, err := repo.GetLiveVariantIds(context.Background(), []int64{}) + + require.Nil(t, err) + require.Empty(t, liveIds) require.NoError(t, mock.ExpectationsWereMet()) } diff --git a/apps/api/data/mysql/tag_transformer.go b/apps/api/data/mysql/tag_transformer.go index 15dabc20..f9b9addd 100644 --- a/apps/api/data/mysql/tag_transformer.go +++ b/apps/api/data/mysql/tag_transformer.go @@ -20,6 +20,7 @@ func ToTagDomain(dbTag Tag) domain.Tag { Color: domain.TagColor(dbTag.Color), IsHighlighted: dbTag.IsHighlighted, SortOrder: dbTag.SortOrder, + VariantCount: dbTag.VariantCount, CreatedAt: dbTag.CreatedAt, } } @@ -31,3 +32,22 @@ func ToTagListDomain(dbTags []Tag) []domain.Tag { } return domainTags } + +func ToVariantTagListDomain(dbVariantTags []VariantTag) []domain.VariantTag { + var domainVariantTags []domain.VariantTag + for _, dbVariantTag := range dbVariantTags { + domainVariantTags = append(domainVariantTags, domain.VariantTag{ + Tag: ToTagDomain(dbVariantTag.Tag), + TaggedAt: dbVariantTag.CreatedAt, + }) + } + return domainVariantTags +} + +func ToVariantTagPairsDB(pairs []domain.VariantTagPair) []VariantTag { + var dbVariantTags []VariantTag + for _, pair := range pairs { + dbVariantTags = append(dbVariantTags, VariantTag{VariantId: pair.VariantId, TagId: pair.TagId}) + } + return dbVariantTags +} diff --git a/apps/api/data/mysql/variant_entity.go b/apps/api/data/mysql/variant_entity.go index 5834c508..bb0bc323 100644 --- a/apps/api/data/mysql/variant_entity.go +++ b/apps/api/data/mysql/variant_entity.go @@ -41,6 +41,7 @@ type Variant struct { CreatedAt time.Time VariantValues []VariantValue PricingTiers []PricingTier + Tags []VariantTag IsAvailable bool `gorm:"default:1"` AvailableQuantity *int } diff --git a/apps/api/data/mysql/variant_repo.go b/apps/api/data/mysql/variant_repo.go index a217034a..43f7821a 100644 --- a/apps/api/data/mysql/variant_repo.go +++ b/apps/api/data/mysql/variant_repo.go @@ -17,6 +17,10 @@ func preloadVariantPricingTiers(db *gorm.DB) *gorm.DB { return db.Order("pricing_tiers.up_to_minutes ASC") } +func preloadVariantTags(db *gorm.DB) *gorm.DB { + return db.Order("variant_tags.created_at ASC") +} + func (repo Repository) GetVariantList(ctx context.Context, query string, sortBy domain.SortBy, order domain.Order, skip int, limit int, productId *int, optionValueIds []int) ([]domain.Variant, *domain.Error) { db := GetDbFromCtx(ctx, repo.db) @@ -29,6 +33,8 @@ func (repo Repository) GetVariantList(ctx context.Context, query string, sortBy Preload("VariantValues"). Preload("VariantValues.OptionValue"). Preload("PricingTiers", preloadVariantPricingTiers). + Preload("Tags", preloadVariantTags). + Preload("Tags.Tag"). Where("variants.deleted_at", nil). Order(fmt.Sprintf("%s %s", ToSortByColumn(sortBy), ToOrderColumn(order))) @@ -83,6 +89,8 @@ func (repo Repository) GetVariantById(ctx context.Context, id int64) (domain.Var Preload("VariantValues"). Preload("VariantValues.OptionValue"). Preload("PricingTiers", preloadVariantPricingTiers). + Preload("Tags", preloadVariantTags). + Preload("Tags.Tag"). Where("id = ?", id). First(&variant) return ToVariantDomain(variant), ToErrorCtx(ctx, result.Error, "GetVariantById") @@ -118,6 +126,8 @@ func (repo Repository) CreateVariant(ctx context.Context, variant domain.Variant Preload("VariantValues"). Preload("VariantValues.OptionValue"). Preload("PricingTiers", preloadVariantPricingTiers). + Preload("Tags", preloadVariantTags). + Preload("Tags.Tag"). Where("id = ?", payload.Id). First(&createdVariant) return ToVariantDomain(createdVariant), ToErrorCtx(ctx, fetchResult.Error, "CreateVariant") @@ -186,6 +196,8 @@ func (repo Repository) UpdateVariantById(ctx context.Context, variant domain.Var Preload("VariantValues"). Preload("VariantValues.OptionValue"). Preload("PricingTiers", preloadVariantPricingTiers). + Preload("Tags", preloadVariantTags). + Preload("Tags.Tag"). Where("id = ?", id). First(&updatedVariant) return ToVariantDomain(updatedVariant), ToErrorCtx(ctx, fetchResult.Error, "UpdateVariantById") diff --git a/apps/api/data/mysql/variant_transformer.go b/apps/api/data/mysql/variant_transformer.go index 39eeec3d..ad9d1e0c 100644 --- a/apps/api/data/mysql/variant_transformer.go +++ b/apps/api/data/mysql/variant_transformer.go @@ -73,6 +73,7 @@ func ToVariantDomain(dbVariant Variant) domain.Variant { Materials: ToVariantMaterialListDomain(dbVariant.Materials), VariantValues: ToVariantValueListDomain(dbVariant.VariantValues), PricingTiers: ToPricingTierListDomain(dbVariant.PricingTiers), + Tags: ToVariantTagListDomain(dbVariant.Tags), IsAvailable: dbVariant.IsAvailable, AvailableQuantity: dbVariant.AvailableQuantity, } diff --git a/apps/api/data/mysql/variant_transformer_test.go b/apps/api/data/mysql/variant_transformer_test.go index cb264b52..5b94b923 100644 --- a/apps/api/data/mysql/variant_transformer_test.go +++ b/apps/api/data/mysql/variant_transformer_test.go @@ -4,6 +4,7 @@ import ( "apps/api/data/mysql" "apps/api/domain" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -67,3 +68,23 @@ func TestVariantTransformerRoundTrip_ImageUrlOmitted(t *testing.T) { assert.Nil(t, roundTripped.ImageUrl) } + +func TestVariantTransformer_TagsMapToVariantTags(t *testing.T) { + taggedAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + dbVariant := mysql.Variant{ + Id: 1, + Tags: []mysql.VariantTag{ + {VariantId: 1, TagId: 7, CreatedAt: taggedAt, Tag: mysql.Tag{Id: 7, Name: "New", Color: "green"}}, + }, + } + + variant := mysql.ToVariantDomain(dbVariant) + + assert.Equal(t, []domain.VariantTag{{Tag: domain.Tag{Id: 7, Name: "New", Color: domain.TagColorGreen}, TaggedAt: taggedAt}}, variant.Tags) +} + +func TestVariantTransformer_ToVariantDBNeverCarriesTags(t *testing.T) { + variant := domain.Variant{Id: 1, Tags: []domain.VariantTag{{Tag: domain.Tag{Id: 7}}}, TagIds: []int64{7}} + + assert.Empty(t, mysql.ToVariantDB(variant).Tags) +} diff --git a/apps/api/domain/tag_assignment.go b/apps/api/domain/tag_assignment.go new file mode 100644 index 00000000..4f756260 --- /dev/null +++ b/apps/api/domain/tag_assignment.go @@ -0,0 +1,51 @@ +package domain + +import "context" + +func uniqueInt64s(values []int64) []int64 { + seen := map[int64]bool{} + unique := []int64{} + for _, value := range values { + if !seen[value] { + seen[value] = true + unique = append(unique, value) + } + } + return unique +} + +func diffVariantTagPairs(existing []VariantTagPair, desired []VariantTagPair) (toInsert []VariantTagPair, toDelete []VariantTagPair) { + existingSet := map[VariantTagPair]bool{} + for _, pair := range existing { + existingSet[pair] = true + } + desiredSet := map[VariantTagPair]bool{} + for _, pair := range desired { + desiredSet[pair] = true + if !existingSet[pair] { + toInsert = append(toInsert, pair) + } + } + for _, pair := range existing { + if !desiredSet[pair] { + toDelete = append(toDelete, pair) + } + } + return toInsert, toDelete +} + +func applyVariantTagPairs(ctx context.Context, repository TagRepository, existing []VariantTagPair, desired []VariantTagPair) *Error { + toInsert, toDelete := diffVariantTagPairs(existing, desired) + + if len(toDelete) > 0 { + if err := repository.DeleteVariantTags(ctx, toDelete); err != nil { + return err + } + } + if len(toInsert) > 0 { + if err := repository.InsertVariantTags(ctx, toInsert); err != nil { + return err + } + } + return nil +} diff --git a/apps/api/domain/tag_entity.go b/apps/api/domain/tag_entity.go index 621f156e..8f591354 100644 --- a/apps/api/domain/tag_entity.go +++ b/apps/api/domain/tag_entity.go @@ -30,5 +30,16 @@ type Tag struct { Color TagColor IsHighlighted bool SortOrder int + VariantCount int64 CreatedAt time.Time } + +type VariantTag struct { + Tag Tag + TaggedAt time.Time +} + +type VariantTagPair struct { + VariantId int64 + TagId int64 +} diff --git a/apps/api/domain/tag_repository.go b/apps/api/domain/tag_repository.go index 87dab852..f689380f 100644 --- a/apps/api/domain/tag_repository.go +++ b/apps/api/domain/tag_repository.go @@ -13,4 +13,9 @@ type TagRepository interface { CreateTag(ctx context.Context, tag Tag) (Tag, *Error) UpdateTagById(ctx context.Context, tag Tag, id int64) (Tag, *Error) DeleteTagById(ctx context.Context, id int64) *Error + GetTagVariantIds(ctx context.Context, tagId int64) ([]int64, *Error) + GetVariantTagIds(ctx context.Context, variantId int64) ([]int64, *Error) + GetLiveVariantIds(ctx context.Context, variantIds []int64) ([]int64, *Error) + InsertVariantTags(ctx context.Context, pairs []VariantTagPair) *Error + DeleteVariantTags(ctx context.Context, pairs []VariantTagPair) *Error } diff --git a/apps/api/domain/tag_usecase.go b/apps/api/domain/tag_usecase.go index 5ccfeebe..c6d26a2d 100644 --- a/apps/api/domain/tag_usecase.go +++ b/apps/api/domain/tag_usecase.go @@ -47,6 +47,47 @@ func (usecase TagUsecase) DeleteTagById(ctx context.Context, id int64) *Error { return usecase.repository.DeleteTagById(ctx, id) } +func (usecase TagUsecase) SetTagVariants(ctx context.Context, tagId int64, variantIds []int64) (Tag, *Error) { + variantIds = uniqueInt64s(variantIds) + + err := usecase.repository.BeginTransaction(ctx, func(ctxWithTx context.Context) *Error { + if _, err := usecase.repository.GetTagById(ctxWithTx, tagId); err != nil { + return err + } + + liveVariantIds, err := usecase.repository.GetLiveVariantIds(ctxWithTx, variantIds) + if err != nil { + return err + } + if len(liveVariantIds) != len(variantIds) { + return &Error{Type: BadRequest, Message: "variant ids contain unknown or deleted variants"} + } + + existingVariantIds, err := usecase.repository.GetTagVariantIds(ctxWithTx, tagId) + if err != nil { + return err + } + + return applyVariantTagPairs(ctxWithTx, usecase.repository, + tagVariantPairs(tagId, existingVariantIds), + tagVariantPairs(tagId, variantIds), + ) + }) + if err != nil { + return Tag{}, err + } + + return usecase.repository.GetTagById(ctx, tagId) +} + +func tagVariantPairs(tagId int64, variantIds []int64) []VariantTagPair { + pairs := []VariantTagPair{} + for _, variantId := range variantIds { + pairs = append(pairs, VariantTagPair{VariantId: variantId, TagId: tagId}) + } + return pairs +} + func (usecase TagUsecase) validateTag(ctx context.Context, tag Tag, excludeId *int64) *Error { nameLength := utf8.RuneCountInString(tag.Name) if nameLength < 1 || nameLength > tagNameMaxLength { diff --git a/apps/api/domain/tag_usecase_test.go b/apps/api/domain/tag_usecase_test.go index 54ae792a..82ab467f 100644 --- a/apps/api/domain/tag_usecase_test.go +++ b/apps/api/domain/tag_usecase_test.go @@ -298,3 +298,119 @@ func TestTagUsecase_DeleteTagById(t *testing.T) { }) } } + +func runTransactionCallback(r *mock.MockTagRepository) { + r.EXPECT().BeginTransaction(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, callback func(context.Context) *domain.Error) *domain.Error { + return callback(ctx) + }) +} + +func TestTagUsecase_SetTagVariants(t *testing.T) { + pair := func(variantId int64) domain.VariantTagPair { + return domain.VariantTagPair{VariantId: variantId, TagId: 1} + } + + tests := []struct { + name string + variantIds []int64 + setupMock func(r *mock.MockTagRepository) + expectedError *domain.Error + }{ + { + name: "replace diffs the set and only touches changed pairs", + variantIds: []int64{2, 3}, + setupMock: func(r *mock.MockTagRepository) { + runTransactionCallback(r) + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(existingTags[0], nil).Times(2) + r.EXPECT().GetLiveVariantIds(gomock.Any(), []int64{2, 3}).Return([]int64{2, 3}, nil) + r.EXPECT().GetTagVariantIds(gomock.Any(), int64(1)).Return([]int64{1, 2}, nil) + r.EXPECT().DeleteVariantTags(gomock.Any(), []domain.VariantTagPair{pair(1)}).Return(nil) + r.EXPECT().InsertVariantTags(gomock.Any(), []domain.VariantTagPair{pair(3)}).Return(nil) + }, + }, + { + name: "empty set clears every assignment", + variantIds: []int64{}, + setupMock: func(r *mock.MockTagRepository) { + runTransactionCallback(r) + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(existingTags[0], nil).Times(2) + r.EXPECT().GetLiveVariantIds(gomock.Any(), []int64{}).Return([]int64{}, nil) + r.EXPECT().GetTagVariantIds(gomock.Any(), int64(1)).Return([]int64{1, 2}, nil) + r.EXPECT().DeleteVariantTags(gomock.Any(), []domain.VariantTagPair{pair(1), pair(2)}).Return(nil) + }, + }, + { + name: "unchanged set writes nothing", + variantIds: []int64{1, 2}, + setupMock: func(r *mock.MockTagRepository) { + runTransactionCallback(r) + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(existingTags[0], nil).Times(2) + r.EXPECT().GetLiveVariantIds(gomock.Any(), []int64{1, 2}).Return([]int64{1, 2}, nil) + r.EXPECT().GetTagVariantIds(gomock.Any(), int64(1)).Return([]int64{1, 2}, nil) + }, + }, + { + name: "duplicate ids are collapsed", + variantIds: []int64{2, 2}, + setupMock: func(r *mock.MockTagRepository) { + runTransactionCallback(r) + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(existingTags[0], nil).Times(2) + r.EXPECT().GetLiveVariantIds(gomock.Any(), []int64{2}).Return([]int64{2}, nil) + r.EXPECT().GetTagVariantIds(gomock.Any(), int64(1)).Return([]int64{}, nil) + r.EXPECT().InsertVariantTags(gomock.Any(), []domain.VariantTagPair{pair(2)}).Return(nil) + }, + }, + { + name: "unknown or deleted variant id is rejected without writes", + variantIds: []int64{2, 99}, + setupMock: func(r *mock.MockTagRepository) { + runTransactionCallback(r) + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(existingTags[0], nil) + r.EXPECT().GetLiveVariantIds(gomock.Any(), []int64{2, 99}).Return([]int64{2}, nil) + }, + expectedError: &domain.Error{Type: domain.BadRequest}, + }, + { + name: "tag not found", + variantIds: []int64{2}, + setupMock: func(r *mock.MockTagRepository) { + runTransactionCallback(r) + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(domain.Tag{}, &domain.Error{Type: domain.NotFound}) + }, + expectedError: &domain.Error{Type: domain.NotFound}, + }, + { + name: "repository error while inserting", + variantIds: []int64{2}, + setupMock: func(r *mock.MockTagRepository) { + runTransactionCallback(r) + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(existingTags[0], nil) + r.EXPECT().GetLiveVariantIds(gomock.Any(), []int64{2}).Return([]int64{2}, nil) + r.EXPECT().GetTagVariantIds(gomock.Any(), int64(1)).Return([]int64{}, nil) + r.EXPECT().InsertVariantTags(gomock.Any(), gomock.Any()).Return(&domain.Error{Type: domain.InternalServerError}) + }, + expectedError: &domain.Error{Type: domain.InternalServerError}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + + tag, err := domain.NewTagUsecase(mockRepo).SetTagVariants(context.Background(), 1, tt.variantIds) + + if tt.expectedError != nil { + assert.NotNil(t, err) + assert.Equal(t, tt.expectedError.Type, err.Type) + } else { + assert.Nil(t, err) + assert.Equal(t, int64(1), tag.Id) + } + }) + } +} diff --git a/apps/api/domain/variant_entity.go b/apps/api/domain/variant_entity.go index 3f9671e3..05deae7b 100644 --- a/apps/api/domain/variant_entity.go +++ b/apps/api/domain/variant_entity.go @@ -41,6 +41,8 @@ type Variant struct { CreatedAt time.Time VariantValues []VariantValue PricingTiers []PricingTier + Tags []VariantTag + TagIds []int64 IsAvailable bool AvailableQuantity *int IsSellable bool diff --git a/apps/api/domain/variant_usecase.go b/apps/api/domain/variant_usecase.go index 5b7da56a..00a6c130 100644 --- a/apps/api/domain/variant_usecase.go +++ b/apps/api/domain/variant_usecase.go @@ -7,12 +7,14 @@ import ( type VariantUsecase struct { repository VariantRepository productRepository ProductRepository + tagRepository TagRepository } -func NewVariantUsecase(repository VariantRepository, productRepository ProductRepository) VariantUsecase { +func NewVariantUsecase(repository VariantRepository, productRepository ProductRepository, tagRepository TagRepository) VariantUsecase { return VariantUsecase{ repository: repository, productRepository: productRepository, + tagRepository: tagRepository, } } @@ -53,7 +55,32 @@ func (usecase VariantUsecase) CreateVariant(ctx context.Context, variant Variant return Variant{}, err } - created, err := usecase.repository.CreateVariant(ctx, variant) + if variant.TagIds == nil { + created, err := usecase.repository.CreateVariant(ctx, variant) + if err != nil { + return Variant{}, err + } + return resolveVariantAvailability(created, product), nil + } + + var created Variant + err = usecase.repository.BeginTransaction(ctx, func(ctxWithTx context.Context) *Error { + if err := usecase.validateTagIds(ctxWithTx, variant.TagIds); err != nil { + return err + } + + createdVariant, err := usecase.repository.CreateVariant(ctxWithTx, variant) + if err != nil { + return err + } + + if err := usecase.replaceVariantTags(ctxWithTx, createdVariant.Id, variant.TagIds); err != nil { + return err + } + + created, err = usecase.repository.GetVariantById(ctxWithTx, createdVariant.Id) + return err + }) if err != nil { return Variant{}, err } @@ -80,6 +107,15 @@ func (usecase VariantUsecase) UpdateVariantById(ctx context.Context, variant Var return err } + if variant.TagIds != nil { + if err := usecase.validateTagIds(ctxWithTx, variant.TagIds); err != nil { + return err + } + if err := usecase.replaceVariantTags(ctxWithTx, id, variant.TagIds); err != nil { + return err + } + } + updated, err := usecase.repository.UpdateVariantById(ctxWithTx, variant, id) if err != nil { return err @@ -95,6 +131,40 @@ func (usecase VariantUsecase) UpdateVariantById(ctx context.Context, variant Var return resolveVariantAvailability(updateResult, product), nil } +func (usecase VariantUsecase) validateTagIds(ctx context.Context, tagIds []int64) *Error { + tags, err := usecase.tagRepository.GetTagList(ctx) + if err != nil { + return err + } + knownTagIds := map[int64]bool{} + for _, tag := range tags { + knownTagIds[tag.Id] = true + } + for _, tagId := range tagIds { + if !knownTagIds[tagId] { + return &Error{Type: BadRequest, Message: "tag ids contain unknown tags"} + } + } + return nil +} + +func (usecase VariantUsecase) replaceVariantTags(ctx context.Context, variantId int64, tagIds []int64) *Error { + existingTagIds, err := usecase.tagRepository.GetVariantTagIds(ctx, variantId) + if err != nil { + return err + } + + toVariantPairs := func(ids []int64) []VariantTagPair { + pairs := []VariantTagPair{} + for _, tagId := range uniqueInt64s(ids) { + pairs = append(pairs, VariantTagPair{VariantId: variantId, TagId: tagId}) + } + return pairs + } + + return applyVariantTagPairs(ctx, usecase.tagRepository, toVariantPairs(existingTagIds), toVariantPairs(tagIds)) +} + func resolveVariantAvailability(variant Variant, product Product) Variant { isSellable, sellableQuantity := ResolveVariantAvailability(product, variant) variant.IsSellable = isSellable diff --git a/apps/api/domain/variant_usecase_test.go b/apps/api/domain/variant_usecase_test.go index f2eaef42..f2fe5e46 100644 --- a/apps/api/domain/variant_usecase_test.go +++ b/apps/api/domain/variant_usecase_test.go @@ -11,7 +11,7 @@ import ( ) func newVariantUsecase(ctrl *gomock.Controller, variantRepo *mock.MockVariantRepository, productRepo *mock.MockProductRepository) domain.VariantUsecase { - return domain.NewVariantUsecase(variantRepo, productRepo) + return domain.NewVariantUsecase(variantRepo, productRepo, mock.NewMockTagRepository(ctrl)) } func TestVariantUsecase_GetVariantList(t *testing.T) { @@ -390,3 +390,162 @@ func TestVariantUsecase_DeleteVariantById(t *testing.T) { }) } } + +func runVariantTransactionCallback(vr *mock.MockVariantRepository) { + vr.EXPECT().BeginTransaction(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, cb func(context.Context) *domain.Error) *domain.Error { + return cb(ctx) + }) +} + +func tagPairs(variantId int64, tagIds ...int64) []domain.VariantTagPair { + pairs := []domain.VariantTagPair{} + for _, tagId := range tagIds { + pairs = append(pairs, domain.VariantTagPair{VariantId: variantId, TagId: tagId}) + } + return pairs +} + +var knownTags = []domain.Tag{{Id: 1, Name: "New"}, {Id: 2, Name: "Best Seller"}, {Id: 3, Name: "Spicy"}} + +func TestVariantUsecase_UpdateVariantById_Tags(t *testing.T) { + tests := []struct { + name string + tagIds []int64 + setupMock func(vr *mock.MockVariantRepository, tr *mock.MockTagRepository) + expectedError *domain.Error + }{ + { + name: "omitted tagIds leave assignments untouched", + tagIds: nil, + setupMock: func(vr *mock.MockVariantRepository, tr *mock.MockTagRepository) { + vr.EXPECT().UpdateVariantById(gomock.Any(), gomock.Any(), int64(1)).Return(domain.Variant{Id: 1, Name: "Hot"}, nil) + }, + }, + { + name: "empty tagIds clear every assignment", + tagIds: []int64{}, + setupMock: func(vr *mock.MockVariantRepository, tr *mock.MockTagRepository) { + tr.EXPECT().GetTagList(gomock.Any()).Return(knownTags, nil) + tr.EXPECT().GetVariantTagIds(gomock.Any(), int64(1)).Return([]int64{1, 2}, nil) + tr.EXPECT().DeleteVariantTags(gomock.Any(), tagPairs(1, 1, 2)).Return(nil) + vr.EXPECT().UpdateVariantById(gomock.Any(), gomock.Any(), int64(1)).Return(domain.Variant{Id: 1, Name: "Hot"}, nil) + }, + }, + { + name: "a list replaces the set by diffing", + tagIds: []int64{2, 3}, + setupMock: func(vr *mock.MockVariantRepository, tr *mock.MockTagRepository) { + tr.EXPECT().GetTagList(gomock.Any()).Return(knownTags, nil) + tr.EXPECT().GetVariantTagIds(gomock.Any(), int64(1)).Return([]int64{1, 2}, nil) + tr.EXPECT().DeleteVariantTags(gomock.Any(), tagPairs(1, 1)).Return(nil) + tr.EXPECT().InsertVariantTags(gomock.Any(), tagPairs(1, 3)).Return(nil) + vr.EXPECT().UpdateVariantById(gomock.Any(), gomock.Any(), int64(1)).Return(domain.Variant{Id: 1, Name: "Hot"}, nil) + }, + }, + { + name: "an unchanged list writes nothing", + tagIds: []int64{1, 2}, + setupMock: func(vr *mock.MockVariantRepository, tr *mock.MockTagRepository) { + tr.EXPECT().GetTagList(gomock.Any()).Return(knownTags, nil) + tr.EXPECT().GetVariantTagIds(gomock.Any(), int64(1)).Return([]int64{1, 2}, nil) + vr.EXPECT().UpdateVariantById(gomock.Any(), gomock.Any(), int64(1)).Return(domain.Variant{Id: 1, Name: "Hot"}, nil) + }, + }, + { + name: "unknown tag id is rejected before anything is written", + tagIds: []int64{99}, + setupMock: func(vr *mock.MockVariantRepository, tr *mock.MockTagRepository) { + tr.EXPECT().GetTagList(gomock.Any()).Return(knownTags, nil) + }, + expectedError: &domain.Error{Type: domain.BadRequest}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + variantRepo := mock.NewMockVariantRepository(ctrl) + productRepo := mock.NewMockProductRepository(ctrl) + tagRepo := mock.NewMockTagRepository(ctrl) + runVariantTransactionCallback(variantRepo) + variantRepo.EXPECT().GetVariantById(gomock.Any(), int64(1)).Return(domain.Variant{Id: 1, ProductId: 10}, nil) + productRepo.EXPECT().GetProductById(gomock.Any(), int64(10)).Return(domain.Product{Id: 10, SaleType: domain.SaleTypePurchase}, nil) + tt.setupMock(variantRepo, tagRepo) + + usecase := domain.NewVariantUsecase(variantRepo, productRepo, tagRepo) + _, err := usecase.UpdateVariantById(context.Background(), domain.Variant{Name: "Hot", Price: 8000, TagIds: tt.tagIds}, 1) + + if tt.expectedError != nil { + assert.NotNil(t, err) + assert.Equal(t, tt.expectedError.Type, err.Type) + } else { + assert.Nil(t, err) + } + }) + } +} + +func TestVariantUsecase_CreateVariant_Tags(t *testing.T) { + product := domain.Product{Id: 10, SaleType: domain.SaleTypePurchase} + input := domain.Variant{ProductId: 10, Name: "Hot", Price: 8000} + + t.Run("tagIds are applied to the created variant and returned", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + variantRepo := mock.NewMockVariantRepository(ctrl) + productRepo := mock.NewMockProductRepository(ctrl) + tagRepo := mock.NewMockTagRepository(ctrl) + + withTags := input + withTags.TagIds = []int64{2} + productRepo.EXPECT().GetProductById(gomock.Any(), int64(10)).Return(product, nil) + runVariantTransactionCallback(variantRepo) + tagRepo.EXPECT().GetTagList(gomock.Any()).Return(knownTags, nil) + variantRepo.EXPECT().CreateVariant(gomock.Any(), withTags).Return(domain.Variant{Id: 5, ProductId: 10, Name: "Hot"}, nil) + tagRepo.EXPECT().GetVariantTagIds(gomock.Any(), int64(5)).Return([]int64{}, nil) + tagRepo.EXPECT().InsertVariantTags(gomock.Any(), tagPairs(5, 2)).Return(nil) + variantRepo.EXPECT().GetVariantById(gomock.Any(), int64(5)).Return(domain.Variant{Id: 5, ProductId: 10, Name: "Hot", Tags: []domain.VariantTag{{Tag: knownTags[1]}}}, nil) + + created, err := domain.NewVariantUsecase(variantRepo, productRepo, tagRepo).CreateVariant(context.Background(), withTags) + + assert.Nil(t, err) + assert.Len(t, created.Tags, 1) + }) + + t.Run("omitted tagIds never touch the tag repository", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + variantRepo := mock.NewMockVariantRepository(ctrl) + productRepo := mock.NewMockProductRepository(ctrl) + tagRepo := mock.NewMockTagRepository(ctrl) + + productRepo.EXPECT().GetProductById(gomock.Any(), int64(10)).Return(product, nil) + variantRepo.EXPECT().CreateVariant(gomock.Any(), input).Return(domain.Variant{Id: 5, ProductId: 10, Name: "Hot"}, nil) + + _, err := domain.NewVariantUsecase(variantRepo, productRepo, tagRepo).CreateVariant(context.Background(), input) + + assert.Nil(t, err) + }) + + t.Run("unknown tag id is rejected before the variant is created", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + variantRepo := mock.NewMockVariantRepository(ctrl) + productRepo := mock.NewMockProductRepository(ctrl) + tagRepo := mock.NewMockTagRepository(ctrl) + + withTags := input + withTags.TagIds = []int64{99} + productRepo.EXPECT().GetProductById(gomock.Any(), int64(10)).Return(product, nil) + runVariantTransactionCallback(variantRepo) + tagRepo.EXPECT().GetTagList(gomock.Any()).Return(knownTags, nil) + + _, err := domain.NewVariantUsecase(variantRepo, productRepo, tagRepo).CreateVariant(context.Background(), withTags) + + assert.NotNil(t, err) + assert.Equal(t, domain.BadRequest, err.Type) + }) +} diff --git a/apps/api/main.go b/apps/api/main.go index 118076b4..5f699ade 100644 --- a/apps/api/main.go +++ b/apps/api/main.go @@ -144,7 +144,7 @@ func main() { availabilityReservation := domain.NewAvailabilityReservation(availabilityReservationRepository) walletUsecase := domain.NewWalletUsecase(walletRepository) transactionUsecase := domain.NewTransactionUsecase(transactionRepository, variantRepository, couponRepository, walletRepository, availabilityReservation, kdsNotificationRepository, kdsNotificationUsecase, paymentRepository, guestNotificationRepository, guestNotificationUsecase, cartRepository, paymentVerificationRepository) - variantUsecase := domain.NewVariantUsecase(variantRepository, productRepository) + variantUsecase := domain.NewVariantUsecase(variantRepository, productRepository, tagRepository) productUsecase := domain.NewProductUsecase(productRepository, variantRepository) materialUsecase := domain.NewMaterialUsecase(materialRepository, supplierRepository) supplierUsecase := domain.NewSupplierUsecase(supplierRepository) diff --git a/apps/api/presentation/restapi/public_handler_test.go b/apps/api/presentation/restapi/public_handler_test.go index 76d473b3..1f3840d8 100644 --- a/apps/api/presentation/restapi/public_handler_test.go +++ b/apps/api/presentation/restapi/public_handler_test.go @@ -27,7 +27,7 @@ func newPublicHandler(t *testing.T) (restapi.PublicHandler, *mock.MockProductRep handler := restapi.NewPublicHandler( domain.NewProductUsecase(productRepo, variantRepo), domain.NewCategoryUsecase(categoryRepo), - domain.NewVariantUsecase(variantRepo, productRepo), + domain.NewVariantUsecase(variantRepo, productRepo, mock.NewMockTagRepository(ctrl)), domain.NewTableUsecase(tableRepo), ) diff --git a/apps/api/presentation/restapi/tag_handler.go b/apps/api/presentation/restapi/tag_handler.go index 2dd3a8a8..d2ee2ef3 100644 --- a/apps/api/presentation/restapi/tag_handler.go +++ b/apps/api/presentation/restapi/tag_handler.go @@ -25,7 +25,7 @@ func (handler TagHandler) GetTagList(w http.ResponseWriter, r *http.Request) { apiTags := []apiContract.Tag{} for _, tag := range tags { - apiTags = append(apiTags, ToApiTag(tag)) + apiTags = append(apiTags, ToApiTagWithVariantCount(tag)) } WriteResponse(w, apiContract.TagListResponse{Data: apiTags}) @@ -46,7 +46,7 @@ func (handler TagHandler) GetTagById(w http.ResponseWriter, r *http.Request) { return } - WriteResponse(w, apiContract.TagFindByIdResponse{Data: ToApiTag(tag)}) + WriteResponse(w, apiContract.TagFindByIdResponse{Data: ToApiTagWithVariantCount(tag)}) } func (handler TagHandler) CreateTag(w http.ResponseWriter, r *http.Request) { @@ -64,7 +64,7 @@ func (handler TagHandler) CreateTag(w http.ResponseWriter, r *http.Request) { return } - WriteResponse(w, apiContract.TagCreateResponse{Data: ToApiTag(tag)}) + WriteResponse(w, apiContract.TagCreateResponse{Data: ToApiTagWithVariantCount(tag)}) } func (handler TagHandler) UpdateTagById(w http.ResponseWriter, r *http.Request) { @@ -89,7 +89,7 @@ func (handler TagHandler) UpdateTagById(w http.ResponseWriter, r *http.Request) return } - WriteResponse(w, apiContract.TagUpdateByIdResponse{Data: ToApiTag(tag)}) + WriteResponse(w, apiContract.TagUpdateByIdResponse{Data: ToApiTagWithVariantCount(tag)}) } func (handler TagHandler) DeleteTagById(w http.ResponseWriter, r *http.Request) { @@ -108,3 +108,27 @@ func (handler TagHandler) DeleteTagById(w http.ResponseWriter, r *http.Request) WriteResponse(w, apiContract.SuccessResponse{Success: true}) } + +func (handler TagHandler) SetTagVariants(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + id, err := GetTagId(r) + if err != nil { + WriteError(ctx, w, apiContract.Error{Code: apiContract.BAD_REQUEST, Message: err.Error()}) + return + } + + request, err := GetTagVariantsRequest(r) + if err != nil { + WriteError(ctx, w, apiContract.Error{Code: apiContract.BAD_REQUEST, Message: err.Error()}) + return + } + + tag, usecaseErr := handler.usecase.SetTagVariants(ctx, id, request.VariantIds) + if usecaseErr != nil { + WriteError(ctx, w, apiContract.Error{Code: ToErrorCode(usecaseErr.Type), Message: usecaseErr.Message}) + return + } + + WriteResponse(w, apiContract.TagSetVariantsResponse{Data: ToApiTagWithVariantCount(tag)}) +} diff --git a/apps/api/presentation/restapi/tag_handler_test.go b/apps/api/presentation/restapi/tag_handler_test.go index ef739a44..69dcdca1 100644 --- a/apps/api/presentation/restapi/tag_handler_test.go +++ b/apps/api/presentation/restapi/tag_handler_test.go @@ -5,6 +5,7 @@ import ( "apps/api/domain" "apps/api/presentation/restapi" "bytes" + "context" "net/http" "net/http/httptest" "testing" @@ -275,3 +276,81 @@ func TestTagHandler_DeleteTagById(t *testing.T) { }) } } + +func TestTagHandler_SetTagVariants(t *testing.T) { + tests := []struct { + name string + tagId string + body string + setupMock func(r *mock.MockTagRepository) + expectedStatus int + }{ + { + name: "success returns the tag with its new variant count", + tagId: "1", + body: `{"variantIds": [2, 3]}`, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().BeginTransaction(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, cb func(context.Context) *domain.Error) *domain.Error { return cb(ctx) }) + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(domain.Tag{Id: 1, Name: "New", Color: domain.TagColorGreen, VariantCount: 2}, nil).Times(2) + r.EXPECT().GetLiveVariantIds(gomock.Any(), []int64{2, 3}).Return([]int64{2, 3}, nil) + r.EXPECT().GetTagVariantIds(gomock.Any(), int64(1)).Return([]int64{}, nil) + r.EXPECT().InsertVariantTags(gomock.Any(), []domain.VariantTagPair{{VariantId: 2, TagId: 1}, {VariantId: 3, TagId: 1}}).Return(nil) + }, + expectedStatus: http.StatusOK, + }, + { + name: "unknown variant is a validation error", + tagId: "1", + body: `{"variantIds": [99]}`, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().BeginTransaction(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, cb func(context.Context) *domain.Error) *domain.Error { return cb(ctx) }) + r.EXPECT().GetTagById(gomock.Any(), int64(1)).Return(domain.Tag{Id: 1}, nil) + r.EXPECT().GetLiveVariantIds(gomock.Any(), []int64{99}).Return([]int64{}, nil) + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "tag not found", + tagId: "9", + body: `{"variantIds": []}`, + setupMock: func(r *mock.MockTagRepository) { + r.EXPECT().BeginTransaction(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, cb func(context.Context) *domain.Error) *domain.Error { return cb(ctx) }) + r.EXPECT().GetTagById(gomock.Any(), int64(9)).Return(domain.Tag{}, &domain.Error{Type: domain.NotFound}) + }, + expectedStatus: http.StatusNotFound, + }, + { + name: "invalid id", + tagId: "abc", + body: `{"variantIds": []}`, + setupMock: func(r *mock.MockTagRepository) {}, + expectedStatus: http.StatusBadRequest, + }, + { + name: "malformed body", + tagId: "1", + body: `{`, + setupMock: func(r *mock.MockTagRepository) {}, + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockRepo := mock.NewMockTagRepository(ctrl) + tt.setupMock(mockRepo) + handler := restapi.NewTagHandler(domain.NewTagUsecase(mockRepo)) + req := httptest.NewRequest(http.MethodPut, "/tags/"+tt.tagId+"/variants", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + req = mux.SetURLVars(req, map[string]string{"tagId": tt.tagId}) + w := httptest.NewRecorder() + handler.SetTagVariants(w, req) + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} diff --git a/apps/api/presentation/restapi/tag_route.go b/apps/api/presentation/restapi/tag_route.go index 31df1888..f94754ab 100644 --- a/apps/api/presentation/restapi/tag_route.go +++ b/apps/api/presentation/restapi/tag_route.go @@ -19,5 +19,6 @@ func (tagRouter TagRouter) AddRouter(router *mux.Router) { router.HandleFunc("/tags/{tagId}", CheckAuth(tagRouter.handler.GetTagById)).Methods(http.MethodGet) router.HandleFunc("/tags/{tagId}", CheckAuth(tagRouter.handler.DeleteTagById)).Methods(http.MethodDelete) router.HandleFunc("/tags/{tagId}", CheckAuth(tagRouter.handler.UpdateTagById)).Methods(http.MethodPut, http.MethodOptions) + router.HandleFunc("/tags/{tagId}/variants", CheckAuth(tagRouter.handler.SetTagVariants)).Methods(http.MethodPut, http.MethodOptions) router.HandleFunc("/tags", CheckAuth(tagRouter.handler.CreateTag)).Methods(http.MethodPost, http.MethodOptions) } diff --git a/apps/api/presentation/restapi/tag_transformer.go b/apps/api/presentation/restapi/tag_transformer.go index 89f72b9f..bcbbba54 100644 --- a/apps/api/presentation/restapi/tag_transformer.go +++ b/apps/api/presentation/restapi/tag_transformer.go @@ -34,6 +34,26 @@ func ToApiTag(tag domain.Tag) apiContract.Tag { } } +func ToApiTagWithVariantCount(tag domain.Tag) apiContract.Tag { + apiTag := ToApiTag(tag) + variantCount := tag.VariantCount + apiTag.VariantCount = &variantCount + return apiTag +} + +func GetTagVariantsRequest(r *http.Request) (apiContract.TagVariantsRequest, error) { + var request apiContract.TagVariantsRequest + err := json.NewDecoder(r.Body).Decode(&request) + return request, err +} + +func ToApiVariantTag(variantTag domain.VariantTag) apiContract.VariantTag { + return apiContract.VariantTag{ + Tag: ToApiTag(variantTag.Tag), + TaggedAt: variantTag.TaggedAt, + } +} + func ToTag(tagRequest apiContract.TagRequest) domain.Tag { return domain.Tag{ Name: tagRequest.Name, diff --git a/apps/api/presentation/restapi/variant_handler_test.go b/apps/api/presentation/restapi/variant_handler_test.go index 3cdff242..82b42e1a 100644 --- a/apps/api/presentation/restapi/variant_handler_test.go +++ b/apps/api/presentation/restapi/variant_handler_test.go @@ -10,6 +10,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/gorilla/mux" "github.com/stretchr/testify/assert" @@ -17,7 +18,7 @@ import ( ) func newTestVariantHandler(ctrl *gomock.Controller, variantRepo *mock.MockVariantRepository, productRepo *mock.MockProductRepository) restapi.VariantHandler { - return restapi.NewVariantHandler(domain.NewVariantUsecase(variantRepo, productRepo)) + return restapi.NewVariantHandler(domain.NewVariantUsecase(variantRepo, productRepo, mock.NewMockTagRepository(ctrl))) } func TestVariantHandler_GetVariantList(t *testing.T) { @@ -326,3 +327,78 @@ func TestVariantHandler_DeleteVariantById(t *testing.T) { }) } } + +func TestVariantHandler_UpdateVariantById_TagIds(t *testing.T) { + tests := []struct { + name string + body string + expectedTagIds []int64 + }{ + { + name: "omitted tagIds stay nil", + body: `{"productId": 1, "name": "Hot", "price": 20000, "materials": [], "values": []}`, + expectedTagIds: nil, + }, + { + name: "empty tagIds stay empty", + body: `{"productId": 1, "name": "Hot", "price": 20000, "materials": [], "values": [], "tagIds": []}`, + expectedTagIds: []int64{}, + }, + { + name: "tagIds are passed through", + body: `{"productId": 1, "name": "Hot", "price": 20000, "materials": [], "values": [], "tagIds": [1, 2]}`, + expectedTagIds: []int64{1, 2}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + variantRepo := mock.NewMockVariantRepository(ctrl) + productRepo := mock.NewMockProductRepository(ctrl) + tagRepo := mock.NewMockTagRepository(ctrl) + variantRepo.EXPECT().BeginTransaction(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, cb func(context.Context) *domain.Error) *domain.Error { return cb(ctx) }) + variantRepo.EXPECT().GetVariantById(gomock.Any(), int64(1)).Return(domain.Variant{Id: 1, ProductId: 1}, nil) + productRepo.EXPECT().GetProductById(gomock.Any(), int64(1)).Return(domain.Product{Id: 1, SaleType: domain.SaleTypePurchase}, nil) + if tt.expectedTagIds != nil { + tagRepo.EXPECT().GetTagList(gomock.Any()).Return([]domain.Tag{{Id: 1}, {Id: 2}}, nil) + tagRepo.EXPECT().GetVariantTagIds(gomock.Any(), int64(1)).Return([]int64{}, nil) + if len(tt.expectedTagIds) > 0 { + tagRepo.EXPECT().InsertVariantTags(gomock.Any(), gomock.Any()).Return(nil) + } + } + taggedAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + variantRepo.EXPECT().UpdateVariantById(gomock.Any(), gomock.Any(), int64(1)).DoAndReturn( + func(ctx context.Context, variant domain.Variant, id int64) (domain.Variant, *domain.Error) { + assert.Equal(t, tt.expectedTagIds, variant.TagIds) + variant.Tags = []domain.VariantTag{{Tag: domain.Tag{Id: 1, Name: "New", Color: domain.TagColorGreen}, TaggedAt: taggedAt}} + return variant, nil + }) + + handler := restapi.NewVariantHandler(domain.NewVariantUsecase(variantRepo, productRepo, tagRepo)) + req := httptest.NewRequest(http.MethodPut, "/variants/1", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + req = mux.SetURLVars(req, map[string]string{"variantId": "1"}) + w := httptest.NewRecorder() + handler.UpdateVariantById(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var response struct { + Data struct { + Tags []struct { + Tag struct { + Name string `json:"name"` + } `json:"tag"` + TaggedAt time.Time `json:"taggedAt"` + } `json:"tags"` + } `json:"data"` + } + assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + assert.Len(t, response.Data.Tags, 1) + assert.Equal(t, "New", response.Data.Tags[0].Tag.Name) + assert.True(t, taggedAt.Equal(response.Data.Tags[0].TaggedAt)) + }) + } +} diff --git a/apps/api/presentation/restapi/variant_transformer.go b/apps/api/presentation/restapi/variant_transformer.go index fc5e2d31..87d5906d 100644 --- a/apps/api/presentation/restapi/variant_transformer.go +++ b/apps/api/presentation/restapi/variant_transformer.go @@ -88,6 +88,11 @@ func ToApiVariant(variant domain.Variant) apiContract.Variant { apiPricingTiers = append(apiPricingTiers, ToApiPricingTier(tier)) } + apiTags := []apiContract.VariantTag{} + for _, variantTag := range variant.Tags { + apiTags = append(apiTags, ToApiVariantTag(variantTag)) + } + return apiContract.Variant{ Id: variant.Id, Name: variant.Name, @@ -102,6 +107,7 @@ func ToApiVariant(variant domain.Variant) apiContract.Variant { ImageUrl: variant.ImageUrl, Values: apiVariantValues, PricingTiers: apiPricingTiers, + Tags: apiTags, IsAvailable: variant.IsAvailable, AvailableQuantity: ToApiQuantity(variant.AvailableQuantity), IsSellable: variant.IsSellable, @@ -153,5 +159,6 @@ func ToVariant(variantRequest apiContract.VariantRequest) domain.Variant { ImageUrl: variantRequest.ImageUrl, VariantValues: variantValues, PricingTiers: pricingTiers, + TagIds: variantRequest.TagIds, } } diff --git a/libs/api-contract/src/api.yaml b/libs/api-contract/src/api.yaml index fe3197e5..47f59130 100644 --- a/libs/api-contract/src/api.yaml +++ b/libs/api-contract/src/api.yaml @@ -709,6 +709,39 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /tags/{tagId}/variants: + put: + summary: Replace the variants a tag is applied to + operationId: tagSetVariants + tags: + - tag + parameters: + - $ref: '#/components/parameters/TagId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TagVariantsRequest' + responses: + '200': + description: success response + content: + application/json: + schema: + $ref: '#/components/schemas/TagSetVariantsResponse' + '404': + description: not found error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: validation error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /materials: get: summary: List all materials @@ -3477,9 +3510,40 @@ components: type: boolean sortOrder: type: integer + variantCount: + type: integer + format: int64 createdAt: type: string format: date-time + VariantTag: + type: object + required: + - tag + - taggedAt + properties: + tag: + $ref: '#/components/schemas/Tag' + taggedAt: + type: string + format: date-time + TagVariantsRequest: + type: object + required: + - variantIds + properties: + variantIds: + type: array + items: + type: integer + format: int64 + TagSetVariantsResponse: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Tag' TagRequest: required: - name @@ -4200,6 +4264,7 @@ components: - pricingTiers - isAvailable - isSellable + - tags properties: id: type: integer @@ -4219,6 +4284,10 @@ components: type: string imageUrl: type: string + tags: + type: array + items: + $ref: '#/components/schemas/VariantTag' materials: type: array items: @@ -4281,6 +4350,11 @@ components: type: array items: $ref: '#/components/schemas/PricingTier' + tagIds: + type: array + items: + type: integer + format: int64 VariantMaterial: type: object required: From ea9d874ce77464c247d4d90679ab9ea2108c6162 Mon Sep 17 00:00:00 2001 From: M Nindra Zaka Date: Fri, 2 Oct 2026 05:11:43 +0700 Subject: [PATCH 05/15] feat(ui): add tag entity, repository and CRUD use cases (#668) Claude-Session: https://claude.ai/code/session_01Bue7Cr3LqJkJwVwJNi82E6 Co-authored-by: Claude --- libs/ui/src/data/api/index.ts | 2 + libs/ui/src/data/api/tag.transformer.ts | 23 +++ libs/ui/src/data/api/tag.ts | 52 ++++++ libs/ui/src/data/mock/index.ts | 1 + libs/ui/src/data/mock/tag.ts | 81 +++++++++ libs/ui/src/domain/entities/Tag.ts | 37 ++++ libs/ui/src/domain/entities/index.ts | 1 + libs/ui/src/domain/repositories/index.ts | 1 + libs/ui/src/domain/repositories/tag.ts | 13 ++ libs/ui/src/domain/usecases/index.ts | 4 + libs/ui/src/domain/usecases/tagCreate.test.ts | 64 +++++++ libs/ui/src/domain/usecases/tagCreate.ts | 106 +++++++++++ libs/ui/src/domain/usecases/tagDelete.test.ts | 68 +++++++ libs/ui/src/domain/usecases/tagDelete.ts | 101 +++++++++++ libs/ui/src/domain/usecases/tagList.test.ts | 122 +++++++++++++ libs/ui/src/domain/usecases/tagList.ts | 120 +++++++++++++ libs/ui/src/domain/usecases/tagUpdate.test.ts | 117 ++++++++++++ libs/ui/src/domain/usecases/tagUpdate.ts | 170 ++++++++++++++++++ 18 files changed, 1083 insertions(+) create mode 100644 libs/ui/src/data/api/tag.transformer.ts create mode 100644 libs/ui/src/data/api/tag.ts create mode 100644 libs/ui/src/data/mock/tag.ts create mode 100644 libs/ui/src/domain/entities/Tag.ts create mode 100644 libs/ui/src/domain/repositories/tag.ts create mode 100644 libs/ui/src/domain/usecases/tagCreate.test.ts create mode 100644 libs/ui/src/domain/usecases/tagCreate.ts create mode 100644 libs/ui/src/domain/usecases/tagDelete.test.ts create mode 100644 libs/ui/src/domain/usecases/tagDelete.ts create mode 100644 libs/ui/src/domain/usecases/tagList.test.ts create mode 100644 libs/ui/src/domain/usecases/tagList.ts create mode 100644 libs/ui/src/domain/usecases/tagUpdate.test.ts create mode 100644 libs/ui/src/domain/usecases/tagUpdate.ts diff --git a/libs/ui/src/data/api/index.ts b/libs/ui/src/data/api/index.ts index aefdbfcc..e744752b 100644 --- a/libs/ui/src/data/api/index.ts +++ b/libs/ui/src/data/api/index.ts @@ -48,3 +48,5 @@ export * from './payment.transformer'; export * from './customer'; export * from './kdsDevice'; export * from './kdsDevice.transformer'; +export * from './tag'; +export * from './tag.transformer'; diff --git a/libs/ui/src/data/api/tag.transformer.ts b/libs/ui/src/data/api/tag.transformer.ts new file mode 100644 index 00000000..2b09f8d0 --- /dev/null +++ b/libs/ui/src/data/api/tag.transformer.ts @@ -0,0 +1,23 @@ +// eslint-disable-next-line @nx/enforce-module-boundaries +import { Tag as ApiTag } from '../../../../api-contract/src'; +import { Tag, TagForm } from '../../domain'; + +export function toTag(tag: ApiTag): Tag { + return { + id: tag.id, + name: tag.name, + color: tag.color, + isHighlighted: tag.isHighlighted, + sortOrder: tag.sortOrder, + createdAt: tag.createdAt, + }; +} + +export function toApiTag(form: TagForm) { + return { + name: form.name, + color: form.color, + isHighlighted: form.isHighlighted, + sortOrder: form.sortOrder, + }; +} diff --git a/libs/ui/src/data/api/tag.ts b/libs/ui/src/data/api/tag.ts new file mode 100644 index 00000000..b573e145 --- /dev/null +++ b/libs/ui/src/data/api/tag.ts @@ -0,0 +1,52 @@ +import { QueryClient } from '@tanstack/react-query'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { + tagCreate, + tagDeleteById, + tagFindById, + tagFindByIdQueryKey, + tagList, + tagListQueryKey, + tagUpdateById, +} from '../../../../api-contract/src'; +import { Tag, TagRepository } from '../../domain'; +import { RequestConfig } from '@kubb/swagger-client/client'; +import { toApiTag, toTag } from './tag.transformer'; + +export class ApiTagRepository implements TagRepository { + client: QueryClient; + + constructor(client: QueryClient) { + this.client = client; + } + + fetchTagById = (tagId: number, options?: Partial) => { + return this.client + .fetchQuery({ + queryKey: tagFindByIdQueryKey(tagId), + queryFn: () => tagFindById(tagId, options), + }) + .then(({ data }) => toTag(data)); + }; + + createTag: TagRepository['createTag'] = (formValues) => { + return tagCreate(toApiTag(formValues)).then(); + }; + + updateTag: TagRepository['updateTag'] = (formValues, tagId) => { + return tagUpdateById(tagId, toApiTag(formValues)).then(); + }; + + deleteTagById: TagRepository['deleteTagById'] = (tagId) => { + return tagDeleteById(tagId).then(); + }; + + fetchTagList = (options?: Partial): Promise => { + return this.client + .fetchQuery({ + queryKey: tagListQueryKey(), + queryFn: () => tagList(options), + }) + .then((data) => data.data.map(toTag)); + }; +} diff --git a/libs/ui/src/data/mock/index.ts b/libs/ui/src/data/mock/index.ts index 3c04b36f..10d2917c 100644 --- a/libs/ui/src/data/mock/index.ts +++ b/libs/ui/src/data/mock/index.ts @@ -39,3 +39,4 @@ export * from './payment'; export * from './customer'; export * from './kdsDevice'; export * from './pushToken'; +export * from './tag'; diff --git a/libs/ui/src/data/mock/tag.ts b/libs/ui/src/data/mock/tag.ts new file mode 100644 index 00000000..8b3153a1 --- /dev/null +++ b/libs/ui/src/data/mock/tag.ts @@ -0,0 +1,81 @@ +import { Tag, TagForm } from '../../domain/entities/Tag'; +import { TagRepository } from '../../domain/repositories/tag'; + +const initialTags: Tag[] = [ + { + id: 1, + name: 'New', + color: 'green', + isHighlighted: true, + sortOrder: 1, + createdAt: '2024-03-20T00:00:00.000Z', + }, + { + id: 2, + name: 'Best Seller', + color: 'orange', + isHighlighted: true, + sortOrder: 2, + createdAt: '2024-03-21T00:00:00.000Z', + }, +]; + +export class MockTagRepository implements TagRepository { + tags: Tag[] = [...initialTags]; + + private nextId = 3; + private shouldFail = false; + + setShouldFail(value: boolean) { + this.shouldFail = value; + } + + async fetchTagList(): Promise { + if (this.shouldFail) { + throw new Error('Failed to fetch tags'); + } + return [...this.tags]; + } + + async fetchTagById(tagId: number): Promise { + if (this.shouldFail) { + throw new Error('Failed to fetch tag'); + } + const tag = this.tags.find((t) => t.id === tagId); + if (!tag) throw new Error('Tag not found'); + return { ...tag }; + } + + async deleteTagById(tagId: number): Promise { + if (this.shouldFail) { + throw new Error('Failed to delete tag'); + } + this.tags = this.tags.filter((t) => t.id !== tagId); + } + + async createTag(formValues: TagForm): Promise { + if (this.shouldFail) { + throw new Error('Failed to create tag'); + } + this.tags.push({ + id: this.nextId++, + ...formValues, + createdAt: new Date().toISOString(), + }); + } + + async updateTag(formValues: TagForm, tagId: number): Promise { + if (this.shouldFail) { + throw new Error('Failed to update tag'); + } + const idx = this.tags.findIndex((t) => t.id === tagId); + if (idx === -1) throw new Error('Tag not found'); + this.tags[idx] = { ...this.tags[idx], ...formValues }; + } + + reset() { + this.tags = [...initialTags]; + this.nextId = 3; + this.shouldFail = false; + } +} diff --git a/libs/ui/src/domain/entities/Tag.ts b/libs/ui/src/domain/entities/Tag.ts new file mode 100644 index 00000000..ad773b26 --- /dev/null +++ b/libs/ui/src/domain/entities/Tag.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +export const tagColors = [ + 'red', + 'orange', + 'yellow', + 'green', + 'blue', + 'purple', + 'pink', + 'gray', +] as const; + +export type TagColor = (typeof tagColors)[number]; + +export type Tag = { + id: number; + name: string; + color: TagColor; + isHighlighted: boolean; + sortOrder: number; + createdAt: string; +}; + +export type TagForm = { + name: string; + color: TagColor; + isHighlighted: boolean; + sortOrder: number; +}; + +export const tagFormSchema = z.object({ + name: z.string().min(1).max(100), + color: z.enum(tagColors), + isHighlighted: z.boolean(), + sortOrder: z.number().int(), +}) satisfies z.ZodType; diff --git a/libs/ui/src/domain/entities/index.ts b/libs/ui/src/domain/entities/index.ts index 42f336a7..563cfc47 100644 --- a/libs/ui/src/domain/entities/index.ts +++ b/libs/ui/src/domain/entities/index.ts @@ -27,3 +27,4 @@ export * from './PublicTable'; export * from './Cart'; export * from './Payment'; export * from './KdsDevice'; +export * from './Tag'; diff --git a/libs/ui/src/domain/repositories/index.ts b/libs/ui/src/domain/repositories/index.ts index 06e873cc..4c2bee64 100644 --- a/libs/ui/src/domain/repositories/index.ts +++ b/libs/ui/src/domain/repositories/index.ts @@ -39,3 +39,4 @@ export * from './payment'; export * from './customer'; export * from './kdsDevice'; export * from './pushToken'; +export * from './tag'; diff --git a/libs/ui/src/domain/repositories/tag.ts b/libs/ui/src/domain/repositories/tag.ts new file mode 100644 index 00000000..eb91e08c --- /dev/null +++ b/libs/ui/src/domain/repositories/tag.ts @@ -0,0 +1,13 @@ +import { Tag, TagForm } from '../entities'; + +export interface TagRepository { + fetchTagList: () => Promise; + + fetchTagById: (tagId: number) => Promise; + + deleteTagById: (tagId: number) => Promise; + + createTag: (formValues: TagForm) => Promise; + + updateTag: (formValues: TagForm, tagId: number) => Promise; +} diff --git a/libs/ui/src/domain/usecases/index.ts b/libs/ui/src/domain/usecases/index.ts index 2c4d5793..ae5b4c43 100644 --- a/libs/ui/src/domain/usecases/index.ts +++ b/libs/ui/src/domain/usecases/index.ts @@ -95,3 +95,7 @@ export * from './kdsDeviceRegister'; export * from './kdsDeviceUnregister'; export * from './kdsTestNotification'; export * from './IUsecase'; +export * from './tagList'; +export * from './tagDelete'; +export * from './tagUpdate'; +export * from './tagCreate'; diff --git a/libs/ui/src/domain/usecases/tagCreate.test.ts b/libs/ui/src/domain/usecases/tagCreate.test.ts new file mode 100644 index 00000000..52d6d0d7 --- /dev/null +++ b/libs/ui/src/domain/usecases/tagCreate.test.ts @@ -0,0 +1,64 @@ +import { TagCreateUsecase, TagCreateState, TagCreateAction } from './tagCreate'; +import { MockTagRepository } from '../../data/mock'; +import { UsecaseTester, flushPromises } from '../../utils/usecase'; + +describe('TagCreateUsecase', () => { + describe('success flow', () => { + it('should transition loaded → submitting → submitSuccess', async () => { + const repository = new MockTagRepository(); + const usecase = new TagCreateUsecase(repository); + const tester = new UsecaseTester< + TagCreateUsecase, + TagCreateState, + TagCreateAction, + undefined + >(usecase); + + expect(tester.state.type).toBe('loaded'); + + tester.dispatch({ + type: 'SUBMIT', + values: { + name: 'New Tag', + color: 'green', + isHighlighted: false, + sortOrder: 0, + }, + }); + expect(tester.state.type).toBe('submitting'); + + await flushPromises(); + expect(tester.state.type).toBe('submitSuccess'); + }); + }); + + describe('error flow', () => { + it('should transition loaded → submitting → submitError', async () => { + const repository = new MockTagRepository(); + repository.setShouldFail(true); + const usecase = new TagCreateUsecase(repository); + const tester = new UsecaseTester< + TagCreateUsecase, + TagCreateState, + TagCreateAction, + undefined + >(usecase); + + expect(tester.state.type).toBe('loaded'); + + tester.dispatch({ + type: 'SUBMIT', + values: { + name: 'New Tag', + color: 'green', + isHighlighted: false, + sortOrder: 0, + }, + }); + expect(tester.state.type).toBe('submitting'); + + await flushPromises(); + expect(tester.state.type).toBe('submitError'); + }); + }); +}); diff --git a/libs/ui/src/domain/usecases/tagCreate.ts b/libs/ui/src/domain/usecases/tagCreate.ts new file mode 100644 index 00000000..ec8effb6 --- /dev/null +++ b/libs/ui/src/domain/usecases/tagCreate.ts @@ -0,0 +1,106 @@ +import { match } from 'ts-pattern'; +import { TagForm } from '../entities'; +import { TagRepository } from '../repositories'; +import { Usecase } from './IUsecase'; + +type Context = { + errorMessage: string | null; + values: TagForm; +}; + +export type TagCreateState = ( + | { type: 'loaded' } + | { type: 'submitting' } + | { type: 'submitSuccess' } + | { type: 'submitError' } +) & + Context; + +export type TagCreateAction = + | { type: 'SUBMIT'; values: TagForm } + | { type: 'SUBMIT_SUCCESS' } + | { type: 'SUBMIT_ERROR'; errorMessage: string } + | { type: 'SUBMIT_CANCEL' }; + +export class TagCreateUsecase extends Usecase { + params: undefined; + repository: TagRepository; + + constructor(repository: TagRepository) { + super(); + this.repository = repository; + } + + getInitialState(): TagCreateState { + return { + type: 'loaded', + errorMessage: null, + values: { + name: '', + color: 'gray', + isHighlighted: false, + sortOrder: 0, + }, + }; + } + + getNextState(state: TagCreateState, action: TagCreateAction): TagCreateState { + return match([state, action]) + .returnType() + .with( + [{ type: 'loaded' }, { type: 'SUBMIT' }], + ([state, { values }]) => ({ + ...state, + values, + type: 'submitting', + }) + ) + .with( + [{ type: 'submitError' }, { type: 'SUBMIT' }], + ([state, { values }]) => ({ + ...state, + values, + type: 'submitting', + }) + ) + .with( + [{ type: 'submitting' }, { type: 'SUBMIT_SUCCESS' }], + ([state]) => ({ + ...state, + type: 'submitSuccess', + }) + ) + .with( + [{ type: 'submitting' }, { type: 'SUBMIT_ERROR' }], + ([state, { errorMessage }]) => ({ + ...state, + type: 'submitError', + errorMessage, + }) + ) + .with( + [{ type: 'submitError' }, { type: 'SUBMIT_CANCEL' }], + ([state]) => ({ + ...state, + type: 'loaded', + }) + ) + .otherwise(() => state); + } + + onStateChange( + state: TagCreateState, + dispatch: (action: TagCreateAction) => void + ): void { + match(state) + .with({ type: 'submitting' }, ({ values }) => { + this.repository + .createTag(values) + .then(() => dispatch({ type: 'SUBMIT_SUCCESS' })) + .catch(() => + dispatch({ type: 'SUBMIT_ERROR', errorMessage: 'Submit failed' }) + ); + }) + .otherwise(() => undefined); + } +} diff --git a/libs/ui/src/domain/usecases/tagDelete.test.ts b/libs/ui/src/domain/usecases/tagDelete.test.ts new file mode 100644 index 00000000..935c5e53 --- /dev/null +++ b/libs/ui/src/domain/usecases/tagDelete.test.ts @@ -0,0 +1,68 @@ +import { TagDeleteUsecase, TagDeleteState, TagDeleteAction } from './tagDelete'; +import { MockTagRepository } from '../../data/mock'; +import { UsecaseTester, flushPromises } from '../../utils/usecase'; + +describe('TagDeleteUsecase', () => { + describe('success flow', () => { + it('should transition hidden → shown → deleting → hidden', async () => { + const repository = new MockTagRepository(); + const usecase = new TagDeleteUsecase(repository); + const tester = new UsecaseTester< + TagDeleteUsecase, + TagDeleteState, + TagDeleteAction, + undefined + >(usecase); + + expect(tester.state).toEqual({ type: 'hidden', tagId: null }); + + tester.dispatch({ type: 'SHOW_CONFIRMATION', tagId: 1 }); + expect(tester.state).toEqual({ type: 'shown', tagId: 1 }); + + tester.dispatch({ type: 'DELETE' }); + expect(tester.state.type).toBe('deleting'); + + await flushPromises(); + expect(tester.state.type).toBe('hidden'); + }); + }); + + it('transitions to hidden when HIDE_CONFIRMATION is dispatched from shown', () => { + const repository = new MockTagRepository(); + const usecase = new TagDeleteUsecase(repository); + const tester = new UsecaseTester< + TagDeleteUsecase, + TagDeleteState, + TagDeleteAction, + undefined + >(usecase); + tester.dispatch({ type: 'SHOW_CONFIRMATION', tagId: 1 }); + tester.dispatch({ type: 'HIDE_CONFIRMATION' }); + expect(tester.state.type).toBe('hidden'); + }); + + describe('error flow', () => { + it('should transition hidden → shown → deleting → shown (auto-recovery)', async () => { + const repository = new MockTagRepository(); + repository.setShouldFail(true); + const usecase = new TagDeleteUsecase(repository); + const tester = new UsecaseTester< + TagDeleteUsecase, + TagDeleteState, + TagDeleteAction, + undefined + >(usecase); + + expect(tester.state.type).toBe('hidden'); + + tester.dispatch({ type: 'SHOW_CONFIRMATION', tagId: 1 }); + expect(tester.state.type).toBe('shown'); + + tester.dispatch({ type: 'DELETE' }); + expect(tester.state.type).toBe('deleting'); + + await flushPromises(); + expect(tester.state.type).toBe('shown'); + }); + }); +}); diff --git a/libs/ui/src/domain/usecases/tagDelete.ts b/libs/ui/src/domain/usecases/tagDelete.ts new file mode 100644 index 00000000..159a25d4 --- /dev/null +++ b/libs/ui/src/domain/usecases/tagDelete.ts @@ -0,0 +1,101 @@ +import { match } from 'ts-pattern'; +import { TagRepository } from '../repositories'; +import { Usecase } from './IUsecase'; + +type Context = { + tagId: number | null; +}; + +export type TagDeleteState = ( + | { type: 'hidden' } + | { type: 'shown' } + | { type: 'deleting' } + | { type: 'deletingSuccess' } + | { type: 'deletingError' } +) & + Context; + +export type TagDeleteAction = + | { type: 'SHOW_CONFIRMATION'; tagId: number } + | { type: 'HIDE_CONFIRMATION' } + | { type: 'DELETE' } + | { type: 'DELETE_SUCCESS' } + | { type: 'DELETE_ERROR' } + | { type: 'DELETE_CANCEL' }; + +export class TagDeleteUsecase extends Usecase { + params: undefined; + repository: TagRepository; + + constructor(repository: TagRepository) { + super(); + this.repository = repository; + } + + getInitialState(): TagDeleteState { + return { + type: 'hidden', + tagId: null, + }; + } + getNextState(state: TagDeleteState, action: TagDeleteAction): TagDeleteState { + return match([state, action]) + .returnType() + .with( + [{ type: 'hidden' }, { type: 'SHOW_CONFIRMATION' }], + ([_state, { tagId }]) => ({ type: 'shown', tagId }) + ) + .with([{ type: 'shown' }, { type: 'HIDE_CONFIRMATION' }], ([state]) => ({ + ...state, + type: 'hidden', + tagId: null, + })) + .with([{ type: 'shown' }, { type: 'DELETE' }], ([state]) => ({ + ...state, + type: 'deleting', + })) + .with([{ type: 'deleting' }, { type: 'DELETE_ERROR' }], ([state]) => ({ + ...state, + type: 'deletingError', + })) + .with( + [{ type: 'deletingError' }, { type: 'DELETE_CANCEL' }], + ([state]) => ({ + ...state, + type: 'shown', + }) + ) + .with([{ type: 'deleting' }, { type: 'DELETE_SUCCESS' }], ([state]) => ({ + ...state, + type: 'deletingSuccess', + })) + .with( + [{ type: 'deletingSuccess' }, { type: 'HIDE_CONFIRMATION' }], + ([state]) => ({ + ...state, + type: 'hidden', + tagId: null, + }) + ) + .otherwise(() => state); + } + onStateChange( + state: TagDeleteState, + dispatch: (action: TagDeleteAction) => void + ): void { + match(state) + .with({ type: 'deleting' }, ({ tagId }) => { + this.repository + .deleteTagById(tagId ?? NaN) + .then(() => dispatch({ type: 'DELETE_SUCCESS' })) + .catch(() => dispatch({ type: 'DELETE_ERROR' })); + }) + .with({ type: 'deletingSuccess' }, () => { + dispatch({ type: 'HIDE_CONFIRMATION' }); + }) + .with({ type: 'deletingError' }, () => { + dispatch({ type: 'DELETE_CANCEL' }); + }) + .otherwise(() => undefined); + } +} diff --git a/libs/ui/src/domain/usecases/tagList.test.ts b/libs/ui/src/domain/usecases/tagList.test.ts new file mode 100644 index 00000000..f6511d13 --- /dev/null +++ b/libs/ui/src/domain/usecases/tagList.test.ts @@ -0,0 +1,122 @@ +import { + TagListUsecase, + TagListAction, + TagListState, + TagListParams, +} from './tagList'; +import { MockTagRepository } from '../../data/mock'; +import { UsecaseTester, flushPromises } from '../../utils/usecase'; + +describe('TagListUsecase', () => { + describe('success flow', () => { + it('should transition loading → loaded → revalidating → loaded', async () => { + const repository = new MockTagRepository(); + const usecase = new TagListUsecase(repository, { tags: [] }); + const tagList = new UsecaseTester< + TagListUsecase, + TagListState, + TagListAction, + TagListParams + >(usecase); + + expect(tagList.state).toEqual({ + type: 'loading', + tags: [], + errorMessage: null, + }); + + await flushPromises(); + expect(tagList.state).toEqual({ + type: 'loaded', + tags: repository.tags, + errorMessage: null, + }); + + tagList.dispatch({ type: 'FETCH' }); + expect(tagList.state).toEqual({ + type: 'revalidating', + tags: repository.tags, + errorMessage: null, + }); + + await flushPromises(); + expect(tagList.state).toEqual({ + type: 'loaded', + tags: repository.tags, + errorMessage: null, + }); + }); + }); + + describe('failed flow', () => { + it('should transition loading → error → loading → loaded', async () => { + const repository = new MockTagRepository(); + repository.setShouldFail(true); + const usecase = new TagListUsecase(repository, { tags: [] }); + const tagList = new UsecaseTester< + TagListUsecase, + TagListState, + TagListAction, + TagListParams + >(usecase); + + expect(tagList.state).toEqual({ + type: 'loading', + tags: [], + errorMessage: null, + }); + + await flushPromises(); + expect(tagList.state).toEqual({ + type: 'error', + tags: [], + errorMessage: 'Failed to fetch tags', + }); + + repository.setShouldFail(false); + tagList.dispatch({ type: 'FETCH' }); + expect(tagList.state).toEqual({ + type: 'loading', + tags: [], + errorMessage: null, + }); + + await flushPromises(); + expect(tagList.state).toEqual({ + type: 'loaded', + tags: repository.tags, + errorMessage: null, + }); + }); + }); + + it('show loaded state when initial data is given', async () => { + const repository = new MockTagRepository(); + + const tags = [ + { + id: 1, + name: 'Tag Test 1', + color: 'red' as const, + isHighlighted: false, + sortOrder: 0, + createdAt: new Date().toISOString(), + }, + ]; + + const usecase = new TagListUsecase(repository, { tags }); + + const tagList = new UsecaseTester< + TagListUsecase, + TagListState, + TagListAction, + TagListParams + >(usecase); + + expect(tagList.state).toEqual({ + type: 'loaded', + tags, + errorMessage: null, + }); + }); +}); diff --git a/libs/ui/src/domain/usecases/tagList.ts b/libs/ui/src/domain/usecases/tagList.ts new file mode 100644 index 00000000..72c401b1 --- /dev/null +++ b/libs/ui/src/domain/usecases/tagList.ts @@ -0,0 +1,120 @@ +import { match, P } from 'ts-pattern'; +import { Tag } from '../entities'; +import { TagRepository } from '../repositories'; +import { Usecase } from './IUsecase'; + +type Context = { + tags: Tag[]; + errorMessage: string | null; +}; + +export type TagListState = ( + | { type: 'idle' } + | { type: 'loading' } + | { type: 'loaded' } + | { type: 'error' } + | { type: 'revalidating' } +) & + Context; + +export type TagListAction = + | { type: 'FETCH' } + | { type: 'FETCH_SUCCESS'; tags: Tag[] } + | { type: 'FETCH_ERROR'; message: string } + | { type: 'REVALIDATE'; tags: Tag[] } + | { type: 'REVALIDATE_FINISH'; tags: Tag[] }; + +export type TagListParams = { + tags: Tag[]; +}; + +export class TagListUsecase extends Usecase< + TagListState, + TagListAction, + TagListParams +> { + params: TagListParams; + repository: TagRepository; + + constructor(repository: TagRepository, params: TagListParams) { + super(); + this.repository = repository; + this.params = params; + } + + getInitialState() { + const state: TagListState = { + type: this.params.tags.length >= 1 ? 'loaded' : 'idle', + errorMessage: null, + tags: this.params.tags, + }; + return state; + } + + getNextState(state: TagListState, action: TagListAction) { + return match([state, action]) + .returnType() + .with( + [{ type: P.union('idle', 'error') }, { type: 'FETCH' }], + ([state]) => ({ ...state, type: 'loading', errorMessage: null }) + ) + .with( + [{ type: 'loading' }, { type: 'FETCH_SUCCESS' }], + ([state, { tags }]) => ({ + ...state, + type: 'loaded', + tags, + errorMessage: null, + }) + ) + .with( + [{ type: 'loading' }, { type: 'FETCH_ERROR' }], + ([state, { message }]) => ({ + ...state, + type: 'error', + errorMessage: message, + }) + ) + .with([{ type: 'loaded' }, { type: 'FETCH' }], ([state]) => ({ + ...state, + type: 'revalidating', + errorMessage: null, + })) + .with( + [{ type: 'revalidating' }, { type: 'REVALIDATE_FINISH' }], + ([state, { tags }]) => ({ + ...state, + type: 'loaded', + tags, + errorMessage: null, + }) + ) + .otherwise(() => state); + } + + onStateChange( + state: TagListState, + dispatch: (action: TagListAction) => void + ) { + match(state) + .with({ type: 'idle' }, () => dispatch({ type: 'FETCH' })) + .with({ type: 'loading' }, () => + this.repository + .fetchTagList() + .then((tags) => dispatch({ type: 'FETCH_SUCCESS', tags })) + .catch(() => + dispatch({ + type: 'FETCH_ERROR', + message: 'Failed to fetch tags', + }) + ) + ) + .with({ type: 'revalidating' }, ({ tags }) => { + this.repository + .fetchTagList() + .then((tags) => dispatch({ type: 'REVALIDATE_FINISH', tags })) + .catch(() => dispatch({ type: 'REVALIDATE_FINISH', tags })); + }) + .otherwise(() => undefined); + } +} diff --git a/libs/ui/src/domain/usecases/tagUpdate.test.ts b/libs/ui/src/domain/usecases/tagUpdate.test.ts new file mode 100644 index 00000000..bde6d825 --- /dev/null +++ b/libs/ui/src/domain/usecases/tagUpdate.test.ts @@ -0,0 +1,117 @@ +import { + TagUpdateUsecase, + TagUpdateState, + TagUpdateAction, + TagUpdateParams, +} from './tagUpdate'; +import { MockTagRepository } from '../../data/mock'; +import { UsecaseTester, flushPromises } from '../../utils/usecase'; + +describe('TagUpdateUsecase', () => { + describe('success flow - fetch then submit', () => { + it('should transition loading → loaded → submitting → submitSuccess', async () => { + const repository = new MockTagRepository(); + const usecase = new TagUpdateUsecase(repository, { tagId: 1, tag: null }); + const tester = new UsecaseTester< + TagUpdateUsecase, + TagUpdateState, + TagUpdateAction, + TagUpdateParams + >(usecase); + + expect(tester.state.type).toBe('loading'); + + await flushPromises(); + expect(tester.state.type).toBe('loaded'); + + tester.dispatch({ + type: 'SUBMIT', + values: { + name: 'Updated Tag', + color: 'blue', + isHighlighted: true, + sortOrder: 5, + }, + }); + expect(tester.state.type).toBe('submitting'); + + await flushPromises(); + expect(tester.state.type).toBe('submitSuccess'); + }); + }); + + describe('error flow - fetch error', () => { + it('should transition loading → error → loading → loaded', async () => { + const repository = new MockTagRepository(); + repository.setShouldFail(true); + const usecase = new TagUpdateUsecase(repository, { tagId: 1, tag: null }); + const tester = new UsecaseTester< + TagUpdateUsecase, + TagUpdateState, + TagUpdateAction, + TagUpdateParams + >(usecase); + + expect(tester.state.type).toBe('loading'); + + await flushPromises(); + expect(tester.state.type).toBe('error'); + + repository.setShouldFail(false); + tester.dispatch({ type: 'FETCH' }); + expect(tester.state.type).toBe('loading'); + + await flushPromises(); + expect(tester.state.type).toBe('loaded'); + }); + }); + + describe('error flow - submit error', () => { + it('should transition loaded → submitting → submitError', async () => { + const repository = new MockTagRepository(); + repository.setShouldFail(true); + const usecase = new TagUpdateUsecase(repository, { + tagId: 1, + tag: repository.tags[0], + }); + const tester = new UsecaseTester< + TagUpdateUsecase, + TagUpdateState, + TagUpdateAction, + TagUpdateParams + >(usecase); + + expect(tester.state.type).toBe('loaded'); + + tester.dispatch({ + type: 'SUBMIT', + values: { + name: 'Updated Tag', + color: 'blue', + isHighlighted: true, + sortOrder: 5, + }, + }); + expect(tester.state.type).toBe('submitting'); + + await flushPromises(); + expect(tester.state.type).toBe('submitError'); + }); + }); + + it('starts in loaded state when data is preloaded', () => { + const repository = new MockTagRepository(); + const existing = repository.tags[0]; + const usecase = new TagUpdateUsecase(repository, { + tagId: 1, + tag: existing, + }); + const tester = new UsecaseTester< + TagUpdateUsecase, + TagUpdateState, + TagUpdateAction, + TagUpdateParams + >(usecase); + expect(tester.state.type).toBe('loaded'); + }); +}); diff --git a/libs/ui/src/domain/usecases/tagUpdate.ts b/libs/ui/src/domain/usecases/tagUpdate.ts new file mode 100644 index 00000000..0fe58d8d --- /dev/null +++ b/libs/ui/src/domain/usecases/tagUpdate.ts @@ -0,0 +1,170 @@ +import { match } from 'ts-pattern'; +import { Tag, TagForm } from '../entities'; +import { TagRepository } from '../repositories'; +import { Usecase } from './IUsecase'; + +type Context = { + errorMessage: string | null; + values: TagForm; +}; + +export type TagUpdateState = ( + | { type: 'idle' } + | { type: 'loading' } + | { type: 'loaded' } + | { type: 'error' } + | { type: 'submitting' } + | { type: 'submitSuccess' } + | { type: 'submitError' } +) & + Context; + +export type TagUpdateAction = + | { type: 'FETCH' } + | { type: 'FETCH_SUCCESS'; values: TagForm } + | { type: 'FETCH_ERROR'; errorMessage: string } + | { type: 'SUBMIT'; values: TagForm } + | { type: 'SUBMIT_SUCCESS' } + | { type: 'SUBMIT_ERROR'; errorMessage: string } + | { type: 'SUBMIT_CANCEL' }; + +export type TagUpdateParams = { + tagId: number; + tag: Tag | null; +}; + +export class TagUpdateUsecase extends Usecase< + TagUpdateState, + TagUpdateAction, + TagUpdateParams +> { + params: TagUpdateParams; + repository: TagRepository; + + constructor(repository: TagRepository, params: TagUpdateParams) { + super(); + this.repository = repository; + this.params = params; + } + + getInitialState(): TagUpdateState { + return { + type: this.params.tag !== null ? 'loaded' : 'idle', + errorMessage: null, + values: { + name: this.params.tag?.name ?? '', + color: this.params.tag?.color ?? 'gray', + isHighlighted: this.params.tag?.isHighlighted ?? false, + sortOrder: this.params.tag?.sortOrder ?? 0, + }, + }; + } + + getNextState(state: TagUpdateState, action: TagUpdateAction): TagUpdateState { + return match([state, action]) + .returnType() + .with([{ type: 'idle' }, { type: 'FETCH' }], ([state]) => ({ + ...state, + type: 'loading', + })) + .with( + [{ type: 'loading' }, { type: 'FETCH_ERROR' }], + ([state, { errorMessage }]) => ({ + ...state, + type: 'error', + errorMessage, + }) + ) + .with([{ type: 'error' }, { type: 'FETCH' }], ([state]) => ({ + ...state, + type: 'loading', + })) + .with( + [{ type: 'loading' }, { type: 'FETCH_SUCCESS' }], + ([state, { values }]) => ({ + ...state, + type: 'loaded', + values, + }) + ) + .with( + [{ type: 'loaded' }, { type: 'SUBMIT' }], + ([state, { values }]) => ({ + ...state, + values, + type: 'submitting', + }) + ) + .with( + [{ type: 'submitError' }, { type: 'SUBMIT' }], + ([state, { values }]) => ({ + ...state, + values, + type: 'submitting', + }) + ) + .with( + [{ type: 'submitting' }, { type: 'SUBMIT_SUCCESS' }], + ([state]) => ({ + ...state, + type: 'submitSuccess', + }) + ) + .with( + [{ type: 'submitting' }, { type: 'SUBMIT_ERROR' }], + ([state, { errorMessage }]) => ({ + ...state, + type: 'submitError', + errorMessage, + }) + ) + .with( + [{ type: 'submitError' }, { type: 'SUBMIT_CANCEL' }], + ([state]) => ({ + ...state, + type: 'loaded', + }) + ) + .otherwise(() => state); + } + + onStateChange( + state: TagUpdateState, + dispatch: (action: TagUpdateAction) => void + ): void { + match(state) + .with({ type: 'idle' }, () => { + dispatch({ type: 'FETCH' }); + }) + .with({ type: 'loading' }, () => { + this.repository + .fetchTagById(this.params.tagId) + .then((tag) => + dispatch({ + type: 'FETCH_SUCCESS', + values: { + name: tag.name, + color: tag.color, + isHighlighted: tag.isHighlighted, + sortOrder: tag.sortOrder, + }, + }) + ) + .catch(() => + dispatch({ + type: 'FETCH_ERROR', + errorMessage: 'Failed to fetch tag', + }) + ); + }) + .with({ type: 'submitting' }, ({ values }) => { + this.repository + .updateTag(values, this.params.tagId) + .then(() => dispatch({ type: 'SUBMIT_SUCCESS' })) + .catch(() => + dispatch({ type: 'SUBMIT_ERROR', errorMessage: 'Submit failed' }) + ); + }) + .otherwise(() => undefined); + } +} From 5b724da0ed9a8a155bb76b47805506fcaafaeaaf Mon Sep 17 00:00:00 2001 From: M Nindra Zaka Date: Fri, 2 Oct 2026 05:35:54 +0700 Subject: [PATCH 06/15] feat(api): resolve product tags on product reads (#670) Add ProductTag and ResolveProductTags, derive product vs variant scope over live variants, and expose Product.tags on authenticated and public product endpoints. Claude-Session: https://claude.ai/code/session_01FQbRptVGsQxpRkvxiR3fwN Co-authored-by: Claude --- apps/api/domain/product_entity.go | 1 + apps/api/domain/product_usecase.go | 11 +- apps/api/domain/product_usecase_test.go | 25 ++++ apps/api/domain/tag_entity.go | 63 +++++++++- apps/api/domain/tag_entity_test.go | 116 ++++++++++++++++++ .../restapi/product_handler_test.go | 50 ++++++++ .../restapi/product_transformer.go | 6 + .../presentation/restapi/tag_transformer.go | 13 ++ libs/api-contract/src/api.yaml | 26 ++++ 9 files changed, 305 insertions(+), 6 deletions(-) create mode 100644 apps/api/domain/tag_entity_test.go diff --git a/apps/api/domain/product_entity.go b/apps/api/domain/product_entity.go index 30fc14c9..46a17bb1 100644 --- a/apps/api/domain/product_entity.go +++ b/apps/api/domain/product_entity.go @@ -44,6 +44,7 @@ type Product struct { AvailableQuantity *int IsSellable bool SellableQuantity *int + Tags []ProductTag } func ResolveProductAvailability(product Product, variants []Variant) (isSellable bool, sellableQuantity *int) { diff --git a/apps/api/domain/product_usecase.go b/apps/api/domain/product_usecase.go index 0240dbdb..382f19c8 100644 --- a/apps/api/domain/product_usecase.go +++ b/apps/api/domain/product_usecase.go @@ -13,7 +13,7 @@ func NewProductUsecase(repository ProductRepository, variantRepository VariantRe return ProductUsecase{repository: repository, variantRepository: variantRepository} } -func (usecase ProductUsecase) resolveAvailability(ctx context.Context, product Product) (Product, *Error) { +func (usecase ProductUsecase) resolveDerivedFields(ctx context.Context, product Product) (Product, *Error) { productId := int(product.Id) variants, err := usecase.variantRepository.GetVariantList(ctx, "", CreatedAt, Ascending, 0, 0, &productId, []int{}) if err != nil { @@ -23,6 +23,7 @@ func (usecase ProductUsecase) resolveAvailability(ctx context.Context, product P isSellable, sellableQuantity := ResolveProductAvailability(product, variants) product.IsSellable = isSellable product.SellableQuantity = sellableQuantity + product.Tags = ResolveProductTags(variants) return product, nil } @@ -33,7 +34,7 @@ func (usecase ProductUsecase) GetProductList(ctx context.Context, query string, } for i, product := range products { - resolved, err := usecase.resolveAvailability(ctx, product) + resolved, err := usecase.resolveDerivedFields(ctx, product) if err != nil { return []Product{}, 0, err } @@ -54,7 +55,7 @@ func (usecase ProductUsecase) GetProductById(ctx context.Context, id int64) (Pro return Product{}, err } - return usecase.resolveAvailability(ctx, product) + return usecase.resolveDerivedFields(ctx, product) } func (usecase ProductUsecase) CreateProduct(ctx context.Context, product Product) (Product, *Error) { @@ -63,7 +64,7 @@ func (usecase ProductUsecase) CreateProduct(ctx context.Context, product Product return Product{}, err } - return usecase.resolveAvailability(ctx, created) + return usecase.resolveDerivedFields(ctx, created) } func (usecase ProductUsecase) UpdateProductById(ctx context.Context, product Product, id int64) (Product, *Error) { @@ -100,7 +101,7 @@ func (usecase ProductUsecase) UpdateProductById(ctx context.Context, product Pro return Product{}, err } - return usecase.resolveAvailability(ctx, updateResult) + return usecase.resolveDerivedFields(ctx, updateResult) } func (usecase ProductUsecase) DeleteProductById(ctx context.Context, id int64) *Error { diff --git a/apps/api/domain/product_usecase_test.go b/apps/api/domain/product_usecase_test.go index 067ca373..a05bb18e 100644 --- a/apps/api/domain/product_usecase_test.go +++ b/apps/api/domain/product_usecase_test.go @@ -5,6 +5,7 @@ import ( "apps/api/domain" "context" "testing" + "time" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" @@ -115,6 +116,30 @@ func TestProductUsecase_GetProductList_resolvesAvailability(t *testing.T) { assert.Nil(t, products[0].SellableQuantity) } +func TestProductUsecase_GetProductById_resolvesTags(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + usecase, mockRepo, mockVariantRepo := newProductUsecase(ctrl) + + newTag := domain.Tag{Id: 1, Name: "New"} + taggedAt := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + mockRepo.EXPECT().GetProductById(gomock.Any(), int64(1)).Return(domain.Product{Id: 1, Name: "Pancong"}, nil) + mockVariantRepo.EXPECT().GetVariantList(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]domain.Variant{ + {Id: 1, ProductId: 1}, + {Id: 2, ProductId: 1, Tags: []domain.VariantTag{{Tag: newTag, TaggedAt: taggedAt}}}, + }, nil) + + product, err := usecase.GetProductById(context.Background(), 1) + + assert.Nil(t, err) + assert.Equal(t, []domain.ProductTag{ + {Tag: newTag, Scope: domain.ProductTagScopeVariant, VariantIds: []int64{2}, TaggedAt: taggedAt}, + }, product.Tags) +} + func TestProductUsecase_GetProductById(t *testing.T) { tests := []struct { name string diff --git a/apps/api/domain/tag_entity.go b/apps/api/domain/tag_entity.go index 8f591354..a7b3e5dc 100644 --- a/apps/api/domain/tag_entity.go +++ b/apps/api/domain/tag_entity.go @@ -1,6 +1,9 @@ package domain -import "time" +import ( + "sort" + "time" +) type TagColor string @@ -43,3 +46,61 @@ type VariantTagPair struct { VariantId int64 TagId int64 } + +type ProductTagScope string + +const ( + ProductTagScopeProduct ProductTagScope = "product" + ProductTagScopeVariant ProductTagScope = "variant" +) + +type ProductTag struct { + Tag Tag + Scope ProductTagScope + VariantIds []int64 + TaggedAt time.Time +} + +func ResolveProductTags(variants []Variant) []ProductTag { + liveVariantCount := 0 + productTags := []ProductTag{} + indexByTagId := map[int64]int{} + + for _, variant := range variants { + if variant.DeletedAt != nil { + continue + } + liveVariantCount++ + + for _, variantTag := range variant.Tags { + index, found := indexByTagId[variantTag.Tag.Id] + if !found { + index = len(productTags) + indexByTagId[variantTag.Tag.Id] = index + productTags = append(productTags, ProductTag{Tag: variantTag.Tag, VariantIds: []int64{}}) + } + + productTags[index].VariantIds = append(productTags[index].VariantIds, variant.Id) + if variantTag.TaggedAt.After(productTags[index].TaggedAt) { + productTags[index].TaggedAt = variantTag.TaggedAt + } + } + } + + for i := range productTags { + if len(productTags[i].VariantIds) == liveVariantCount { + productTags[i].Scope = ProductTagScopeProduct + } else { + productTags[i].Scope = ProductTagScopeVariant + } + } + + sort.SliceStable(productTags, func(i, j int) bool { + if productTags[i].Tag.SortOrder != productTags[j].Tag.SortOrder { + return productTags[i].Tag.SortOrder < productTags[j].Tag.SortOrder + } + return productTags[i].Tag.Name < productTags[j].Tag.Name + }) + + return productTags +} diff --git a/apps/api/domain/tag_entity_test.go b/apps/api/domain/tag_entity_test.go new file mode 100644 index 00000000..dab5aa32 --- /dev/null +++ b/apps/api/domain/tag_entity_test.go @@ -0,0 +1,116 @@ +package domain_test + +import ( + "apps/api/domain" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestResolveProductTags(t *testing.T) { + baseTime := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + newTag := domain.Tag{Id: 1, Name: "New", SortOrder: 2} + bestSeller := domain.Tag{Id: 2, Name: "Best Seller", SortOrder: 1} + + tagged := func(id int64, tags ...domain.VariantTag) domain.Variant { + return domain.Variant{Id: id, IsAvailable: true, Tags: tags} + } + at := func(tag domain.Tag, offsetDays int) domain.VariantTag { + return domain.VariantTag{Tag: tag, TaggedAt: baseTime.AddDate(0, 0, offsetDays)} + } + deletedAt := baseTime + + tests := []struct { + name string + variants []domain.Variant + expected []domain.ProductTag + }{ + { + name: "no variants", + variants: []domain.Variant{}, + expected: []domain.ProductTag{}, + }, + { + name: "untagged variants", + variants: []domain.Variant{tagged(1), tagged(2)}, + expected: []domain.ProductTag{}, + }, + { + name: "pancong — only ice cream is new, so variant scope", + variants: []domain.Variant{ + tagged(1), tagged(2), tagged(3), + tagged(4, at(newTag, 0)), + }, + expected: []domain.ProductTag{ + {Tag: newTag, Scope: domain.ProductTagScopeVariant, VariantIds: []int64{4}, TaggedAt: baseTime}, + }, + }, + { + name: "salted caramel macchiato — one of one variant, so product scope", + variants: []domain.Variant{tagged(1, at(newTag, 0))}, + expected: []domain.ProductTag{ + {Tag: newTag, Scope: domain.ProductTagScopeProduct, VariantIds: []int64{1}, TaggedAt: baseTime}, + }, + }, + { + name: "coffee latte — hot and iced both best seller, so product scope", + variants: []domain.Variant{ + tagged(1, at(bestSeller, 0)), + tagged(2, at(bestSeller, 0)), + }, + expected: []domain.ProductTag{ + {Tag: bestSeller, Scope: domain.ProductTagScopeProduct, VariantIds: []int64{1, 2}, TaggedAt: baseTime}, + }, + }, + { + name: "deleted variant is ignored for coverage and ids", + variants: []domain.Variant{ + tagged(1, at(bestSeller, 0)), + func() domain.Variant { v := tagged(2); v.DeletedAt = &deletedAt; return v }(), + func() domain.Variant { v := tagged(3, at(bestSeller, 5)); v.DeletedAt = &deletedAt; return v }(), + }, + expected: []domain.ProductTag{ + {Tag: bestSeller, Scope: domain.ProductTagScopeProduct, VariantIds: []int64{1}, TaggedAt: baseTime}, + }, + }, + { + name: "sold-out variant still counts toward coverage", + variants: []domain.Variant{ + tagged(1, at(bestSeller, 0)), + func() domain.Variant { v := tagged(2, at(bestSeller, 0)); v.IsAvailable = false; return v }(), + }, + expected: []domain.ProductTag{ + {Tag: bestSeller, Scope: domain.ProductTagScopeProduct, VariantIds: []int64{1, 2}, TaggedAt: baseTime}, + }, + }, + { + name: "taggedAt is the latest among tagged variants", + variants: []domain.Variant{ + tagged(1, at(newTag, 3)), + tagged(2, at(newTag, 7)), + tagged(3, at(newTag, 1)), + }, + expected: []domain.ProductTag{ + {Tag: newTag, Scope: domain.ProductTagScopeProduct, VariantIds: []int64{1, 2, 3}, TaggedAt: baseTime.AddDate(0, 0, 7)}, + }, + }, + { + name: "sorted by tag sort order, then name", + variants: []domain.Variant{ + tagged(1, at(newTag, 0), at(bestSeller, 0), at(domain.Tag{Id: 3, Name: "Aaa", SortOrder: 1}, 0)), + }, + expected: []domain.ProductTag{ + {Tag: domain.Tag{Id: 3, Name: "Aaa", SortOrder: 1}, Scope: domain.ProductTagScopeProduct, VariantIds: []int64{1}, TaggedAt: baseTime}, + {Tag: bestSeller, Scope: domain.ProductTagScopeProduct, VariantIds: []int64{1}, TaggedAt: baseTime}, + {Tag: newTag, Scope: domain.ProductTagScopeProduct, VariantIds: []int64{1}, TaggedAt: baseTime}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, domain.ResolveProductTags(tt.variants)) + }) + } +} diff --git a/apps/api/presentation/restapi/product_handler_test.go b/apps/api/presentation/restapi/product_handler_test.go index e8bbb417..b00a3638 100644 --- a/apps/api/presentation/restapi/product_handler_test.go +++ b/apps/api/presentation/restapi/product_handler_test.go @@ -8,6 +8,7 @@ import ( "context" "net/http" "net/http/httptest" + "strconv" "testing" "github.com/gorilla/mux" @@ -140,6 +141,55 @@ func TestProductHandler_GetProductById(t *testing.T) { } } +func TestProductHandler_GetProductById_returnsTags(t *testing.T) { + tests := []struct { + name string + variants []domain.Variant + expectedTags string + }{ + { + name: "untagged product returns an empty array", + variants: []domain.Variant{{Id: 1, ProductId: 1}}, + expectedTags: `"tags":[]`, + }, + { + name: "tagged variant of a multi-variant product has variant scope", + variants: []domain.Variant{ + {Id: 1, ProductId: 1}, + {Id: 2, ProductId: 1, Tags: []domain.VariantTag{{Tag: domain.Tag{Id: 1, Name: "New", Color: domain.TagColorGreen}}}}, + }, + expectedTags: `"scope":"variant","tag":`, + }, + { + name: "tag on the only variant has product scope", + variants: []domain.Variant{ + {Id: 1, ProductId: 1, Tags: []domain.VariantTag{{Tag: domain.Tag{Id: 1, Name: "New", Color: domain.TagColorGreen}}}}, + }, + expectedTags: `"scope":"product","tag":`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + handler, mockRepo, mockVariantRepo := newProductHandler(ctrl) + mockRepo.EXPECT().GetProductById(gomock.Any(), int64(1)).Return(domain.Product{Id: 1, Name: "Pancong"}, nil) + mockVariantRepo.EXPECT().GetVariantList(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(tt.variants, nil) + req := httptest.NewRequest(http.MethodGet, "/products/1", nil) + req = mux.SetURLVars(req, map[string]string{"productId": "1"}) + w := httptest.NewRecorder() + handler.GetProductById(w, req) + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), tt.expectedTags) + if len(tt.variants[len(tt.variants)-1].Tags) > 0 { + assert.Contains(t, w.Body.String(), `"variantIds":[`+strconv.FormatInt(tt.variants[len(tt.variants)-1].Id, 10)+`]`) + } + }) + } +} + func TestProductHandler_CreateProduct(t *testing.T) { tests := []struct { name string diff --git a/apps/api/presentation/restapi/product_transformer.go b/apps/api/presentation/restapi/product_transformer.go index fa8de5e3..e1b016be 100644 --- a/apps/api/presentation/restapi/product_transformer.go +++ b/apps/api/presentation/restapi/product_transformer.go @@ -68,6 +68,11 @@ func ToApiProduct(product domain.Product) apiContract.Product { }) } + apiTags := []apiContract.ProductTag{} + for _, productTag := range product.Tags { + apiTags = append(apiTags, ToApiProductTag(productTag)) + } + return apiContract.Product{ Id: product.Id, Name: product.Name, @@ -86,6 +91,7 @@ func ToApiProduct(product domain.Product) apiContract.Product { AvailableQuantity: ToApiQuantity(product.AvailableQuantity), IsSellable: product.IsSellable, SellableQuantity: ToApiQuantity(product.SellableQuantity), + Tags: apiTags, } } diff --git a/apps/api/presentation/restapi/tag_transformer.go b/apps/api/presentation/restapi/tag_transformer.go index bcbbba54..dbf0b85a 100644 --- a/apps/api/presentation/restapi/tag_transformer.go +++ b/apps/api/presentation/restapi/tag_transformer.go @@ -54,6 +54,19 @@ func ToApiVariantTag(variantTag domain.VariantTag) apiContract.VariantTag { } } +func ToApiProductTag(productTag domain.ProductTag) apiContract.ProductTag { + variantIds := productTag.VariantIds + if variantIds == nil { + variantIds = []int64{} + } + return apiContract.ProductTag{ + Tag: ToApiTag(productTag.Tag), + Scope: string(productTag.Scope), + VariantIds: variantIds, + TaggedAt: productTag.TaggedAt, + } +} + func ToTag(tagRequest apiContract.TagRequest) domain.Tag { return domain.Tag{ Name: tagRequest.Name, diff --git a/libs/api-contract/src/api.yaml b/libs/api-contract/src/api.yaml index 47f59130..23992cf5 100644 --- a/libs/api-contract/src/api.yaml +++ b/libs/api-contract/src/api.yaml @@ -3527,6 +3527,27 @@ components: taggedAt: type: string format: date-time + ProductTag: + type: object + required: + - tag + - scope + - variantIds + - taggedAt + properties: + tag: + $ref: '#/components/schemas/Tag' + scope: + type: string + enum: [product, variant] + variantIds: + type: array + items: + type: integer + format: int64 + taggedAt: + type: string + format: date-time TagVariantsRequest: type: object required: @@ -4107,6 +4128,7 @@ components: - isAvailable - availabilityTracking - isSellable + - tags properties: id: type: integer @@ -4153,6 +4175,10 @@ components: sellableQuantity: type: integer format: int64 + tags: + type: array + items: + $ref: '#/components/schemas/ProductTag' ProductRequest: type: object required: From 65dcfb57bf14335149b4df79564573d3293bc1c3 Mon Sep 17 00:00:00 2001 From: M Nindra Zaka Date: Fri, 2 Oct 2026 05:38:10 +0700 Subject: [PATCH 07/15] feat(pos): add tag management screens (#671) Add TagList/TagCreate/TagUpdate screens, handlers and composition roots, TagFormView with colour swatch picker, TagListItem, TagDeleteAlert, POS pages under /tags and a Tags sidebar entry. Tag gains variantCount for the list item and the delete confirmation. Claude-Session: https://claude.ai/code/session_01LcTrodMYofppndzqZwi9mC Co-authored-by: Claude --- apps/pos-web/src/pages/tags/[tagId].tsx | 35 ++ apps/pos-web/src/pages/tags/create.tsx | 12 + apps/pos-web/src/pages/tags/index.tsx | 33 ++ libs/ui/.storybook/mocks/mockData.ts | 31 ++ libs/ui/src/app/pos/TagCreate.tsx | 20 + libs/ui/src/app/pos/TagList.tsx | 34 ++ libs/ui/src/app/pos/TagUpdate.tsx | 33 ++ libs/ui/src/app/pos/index.ts | 3 + libs/ui/src/data/api/tag.transformer.ts | 1 + libs/ui/src/data/mock/tag.ts | 3 + libs/ui/src/domain/entities/Tag.ts | 1 + libs/ui/src/domain/usecases/tagList.test.ts | 1 + .../handlers/pos/TagCreateHandler.test.tsx | 231 +++++++++++ .../handlers/pos/TagCreateHandler.tsx | 64 +++ .../handlers/pos/TagListHandler.test.tsx | 329 ++++++++++++++++ .../handlers/pos/TagListHandler.tsx | 96 +++++ .../handlers/pos/TagUpdateHandler.test.tsx | 363 ++++++++++++++++++ .../handlers/pos/TagUpdateHandler.tsx | 75 ++++ .../ui/src/presentation/handlers/pos/index.ts | 3 + .../components/base/Sidebar/Sidebar.state.tsx | 1 + .../presentation/views/components/index.ts | 1 + .../views/components/tags/TagColorPicker.tsx | 49 +++ .../components/tags/TagColorPill.stories.tsx | 17 + .../views/components/tags/TagColorPill.tsx | 22 ++ .../tags/TagDeleteAlert.stories.tsx | 37 ++ .../views/components/tags/TagDeleteAlert.tsx | 75 ++++ .../components/tags/TagFormView.stories.tsx | 69 ++++ .../views/components/tags/TagFormView.tsx | 61 +++ .../views/components/tags/TagList.stories.tsx | 44 +++ .../views/components/tags/TagList.tsx | 72 ++++ .../components/tags/TagListItem.stories.tsx | 29 ++ .../views/components/tags/TagListItem.tsx | 45 +++ .../views/components/tags/index.ts | 7 + .../views/components/tags/tagColors.ts | 15 + .../screens/pos/TagCreateScreen.stories.tsx | 36 ++ .../views/screens/pos/TagCreateScreen.tsx | 46 +++ .../screens/pos/TagListScreen.stories.tsx | 50 +++ .../views/screens/pos/TagListScreen.tsx | 71 ++++ .../screens/pos/TagUpdateScreen.stories.tsx | 42 ++ .../views/screens/pos/TagUpdateScreen.tsx | 46 +++ .../presentation/views/screens/pos/index.ts | 3 + 41 files changed, 2206 insertions(+) create mode 100644 apps/pos-web/src/pages/tags/[tagId].tsx create mode 100644 apps/pos-web/src/pages/tags/create.tsx create mode 100644 apps/pos-web/src/pages/tags/index.tsx create mode 100644 libs/ui/src/app/pos/TagCreate.tsx create mode 100644 libs/ui/src/app/pos/TagList.tsx create mode 100644 libs/ui/src/app/pos/TagUpdate.tsx create mode 100644 libs/ui/src/presentation/handlers/pos/TagCreateHandler.test.tsx create mode 100644 libs/ui/src/presentation/handlers/pos/TagCreateHandler.tsx create mode 100644 libs/ui/src/presentation/handlers/pos/TagListHandler.test.tsx create mode 100644 libs/ui/src/presentation/handlers/pos/TagListHandler.tsx create mode 100644 libs/ui/src/presentation/handlers/pos/TagUpdateHandler.test.tsx create mode 100644 libs/ui/src/presentation/handlers/pos/TagUpdateHandler.tsx create mode 100644 libs/ui/src/presentation/views/components/tags/TagColorPicker.tsx create mode 100644 libs/ui/src/presentation/views/components/tags/TagColorPill.stories.tsx create mode 100644 libs/ui/src/presentation/views/components/tags/TagColorPill.tsx create mode 100644 libs/ui/src/presentation/views/components/tags/TagDeleteAlert.stories.tsx create mode 100644 libs/ui/src/presentation/views/components/tags/TagDeleteAlert.tsx create mode 100644 libs/ui/src/presentation/views/components/tags/TagFormView.stories.tsx create mode 100644 libs/ui/src/presentation/views/components/tags/TagFormView.tsx create mode 100644 libs/ui/src/presentation/views/components/tags/TagList.stories.tsx create mode 100644 libs/ui/src/presentation/views/components/tags/TagList.tsx create mode 100644 libs/ui/src/presentation/views/components/tags/TagListItem.stories.tsx create mode 100644 libs/ui/src/presentation/views/components/tags/TagListItem.tsx create mode 100644 libs/ui/src/presentation/views/components/tags/index.ts create mode 100644 libs/ui/src/presentation/views/components/tags/tagColors.ts create mode 100644 libs/ui/src/presentation/views/screens/pos/TagCreateScreen.stories.tsx create mode 100644 libs/ui/src/presentation/views/screens/pos/TagCreateScreen.tsx create mode 100644 libs/ui/src/presentation/views/screens/pos/TagListScreen.stories.tsx create mode 100644 libs/ui/src/presentation/views/screens/pos/TagListScreen.tsx create mode 100644 libs/ui/src/presentation/views/screens/pos/TagUpdateScreen.stories.tsx create mode 100644 libs/ui/src/presentation/views/screens/pos/TagUpdateScreen.tsx diff --git a/apps/pos-web/src/pages/tags/[tagId].tsx b/apps/pos-web/src/pages/tags/[tagId].tsx new file mode 100644 index 00000000..0eb2efce --- /dev/null +++ b/apps/pos-web/src/pages/tags/[tagId].tsx @@ -0,0 +1,35 @@ +import { ApiTagRepository } from '@gatherloop-pos/ui'; +import { + TagUpdate, + TagUpdateProps, +} from '@gatherloop-pos/ui/pos'; +import { GetServerSideProps } from 'next'; +import { QueryClient } from '@tanstack/react-query'; + +export const getServerSideProps: GetServerSideProps< + TagUpdateProps, + { tagId: string } +> = async (ctx) => { + const isLoggedIn = ctx.req.headers.cookie?.includes('Authorization'); + if (!isLoggedIn) { + return { + redirect: { + destination: '/auth/login', + permanent: false, + }, + }; + } + + const client = new QueryClient(); + const tagRepository = new ApiTagRepository(client); + const tagId = parseInt(ctx.params?.tagId ?? ''); + const tag = await tagRepository.fetchTagById(tagId, { + headers: { Cookie: ctx.req.headers.cookie }, + }); + + return { + props: { tagUpdateParams: { tag, tagId } }, + }; +}; + +export default TagUpdate; diff --git a/apps/pos-web/src/pages/tags/create.tsx b/apps/pos-web/src/pages/tags/create.tsx new file mode 100644 index 00000000..af07051c --- /dev/null +++ b/apps/pos-web/src/pages/tags/create.tsx @@ -0,0 +1,12 @@ +import { TagCreate } from '@gatherloop-pos/ui/pos'; +import { GetServerSideProps } from 'next'; + +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const isLoggedIn = ctx.req.headers.cookie?.includes('Authorization'); + return { + props: {}, + redirect: isLoggedIn ? undefined : { destination: '/auth/login' }, + }; +}; + +export default TagCreate; diff --git a/apps/pos-web/src/pages/tags/index.tsx b/apps/pos-web/src/pages/tags/index.tsx new file mode 100644 index 00000000..b9a90c59 --- /dev/null +++ b/apps/pos-web/src/pages/tags/index.tsx @@ -0,0 +1,33 @@ +import { ApiTagRepository } from '@gatherloop-pos/ui'; +import { + TagList, + TagListProps, +} from '@gatherloop-pos/ui/pos'; +import { GetServerSideProps } from 'next'; +import { QueryClient } from '@tanstack/react-query'; + +export const getServerSideProps: GetServerSideProps = async ( + ctx +) => { + const isLoggedIn = ctx.req.headers.cookie?.includes('Authorization'); + if (!isLoggedIn) { + return { + redirect: { + destination: '/auth/login', + permanent: false, + }, + }; + } + + const client = new QueryClient(); + const tagRepository = new ApiTagRepository(client); + const tags = await tagRepository.fetchTagList({ + headers: { Cookie: ctx.req.headers.cookie }, + }); + + return { + props: { tagListParams: { tags } }, + }; +}; + +export default TagList; diff --git a/libs/ui/.storybook/mocks/mockData.ts b/libs/ui/.storybook/mocks/mockData.ts index 8c96851b..c2064907 100644 --- a/libs/ui/.storybook/mocks/mockData.ts +++ b/libs/ui/.storybook/mocks/mockData.ts @@ -16,6 +16,7 @@ import type { Supplier, OptionValue, Table, + Tag, } from '../../src/domain'; export const mockCategory: Category = { @@ -575,3 +576,33 @@ export const mockSuppliers: Supplier[] = [ createdAt: '2024-01-16T08:00:00.000Z', }, ]; + +export const mockTags: Tag[] = [ + { + id: 1, + name: 'New', + color: 'green', + isHighlighted: true, + sortOrder: 1, + variantCount: 3, + createdAt: '2024-03-20T00:00:00.000Z', + }, + { + id: 2, + name: 'Best Seller', + color: 'orange', + isHighlighted: true, + sortOrder: 2, + variantCount: 5, + createdAt: '2024-03-21T00:00:00.000Z', + }, + { + id: 3, + name: 'Vegan', + color: 'blue', + isHighlighted: false, + sortOrder: 3, + variantCount: 1, + createdAt: '2024-03-22T00:00:00.000Z', + }, +]; diff --git a/libs/ui/src/app/pos/TagCreate.tsx b/libs/ui/src/app/pos/TagCreate.tsx new file mode 100644 index 00000000..022d6080 --- /dev/null +++ b/libs/ui/src/app/pos/TagCreate.tsx @@ -0,0 +1,20 @@ +import { ApiAuthRepository, ApiTagRepository } from '../../data'; +import { AuthLogoutUsecase, TagCreateUsecase } from '../../domain'; +import { TagCreateHandler } from '../../presentation'; +import { QueryClient } from '@tanstack/react-query'; + +export function TagCreate() { + const client = new QueryClient(); + const tagRepository = new ApiTagRepository(client); + const authRepository = new ApiAuthRepository(); + + const tagCreateUsecase = new TagCreateUsecase(tagRepository); + const authLogoutUsecase = new AuthLogoutUsecase(authRepository); + + return ( + + ); +} diff --git a/libs/ui/src/app/pos/TagList.tsx b/libs/ui/src/app/pos/TagList.tsx new file mode 100644 index 00000000..32a3ff9e --- /dev/null +++ b/libs/ui/src/app/pos/TagList.tsx @@ -0,0 +1,34 @@ +import { ApiAuthRepository, ApiTagRepository } from '../../data'; +import { + TagListUsecase, + TagDeleteUsecase, + AuthLogoutUsecase, + TagListParams, +} from '../../domain'; +import { TagListHandler } from '../../presentation'; +import { QueryClient } from '@tanstack/react-query'; + +export type TagListProps = { + tagListParams: TagListParams; +}; + +export function TagList({ tagListParams }: TagListProps) { + const client = new QueryClient(); + const tagRepository = new ApiTagRepository(client); + const authRepository = new ApiAuthRepository(); + + const authLogoutUsecase = new AuthLogoutUsecase(authRepository); + const tagDeleteUsecase = new TagDeleteUsecase(tagRepository); + const tagListUsecase = new TagListUsecase( + tagRepository, + tagListParams + ); + + return ( + + ); +} diff --git a/libs/ui/src/app/pos/TagUpdate.tsx b/libs/ui/src/app/pos/TagUpdate.tsx new file mode 100644 index 00000000..b52c2f7c --- /dev/null +++ b/libs/ui/src/app/pos/TagUpdate.tsx @@ -0,0 +1,33 @@ +import { ApiAuthRepository, ApiTagRepository } from '../../data'; +import { + AuthLogoutUsecase, + TagUpdateParams, + TagUpdateUsecase, +} from '../../domain'; +import { TagUpdateHandler } from '../../presentation'; +import { QueryClient } from '@tanstack/react-query'; + +export type TagUpdateProps = { + tagUpdateParams: TagUpdateParams; +}; + +export function TagUpdate({ + tagUpdateParams, +}: TagUpdateProps) { + const client = new QueryClient(); + const tagRepository = new ApiTagRepository(client); + const authRepository = new ApiAuthRepository(); + + const authLogoutUsecase = new AuthLogoutUsecase(authRepository); + const tagUpdateUsecase = new TagUpdateUsecase( + tagRepository, + tagUpdateParams + ); + + return ( + + ); +} diff --git a/libs/ui/src/app/pos/index.ts b/libs/ui/src/app/pos/index.ts index 942df1a9..dee6751f 100644 --- a/libs/ui/src/app/pos/index.ts +++ b/libs/ui/src/app/pos/index.ts @@ -14,6 +14,9 @@ export * from './MaterialUpdate'; export * from './CategoryList'; export * from './CategoryCreate'; export * from './CategoryUpdate'; +export * from './TagList'; +export * from './TagCreate'; +export * from './TagUpdate'; export * from './CouponList'; export * from './CouponCreate'; export * from './CouponUpdate'; diff --git a/libs/ui/src/data/api/tag.transformer.ts b/libs/ui/src/data/api/tag.transformer.ts index 2b09f8d0..96a85993 100644 --- a/libs/ui/src/data/api/tag.transformer.ts +++ b/libs/ui/src/data/api/tag.transformer.ts @@ -9,6 +9,7 @@ export function toTag(tag: ApiTag): Tag { color: tag.color, isHighlighted: tag.isHighlighted, sortOrder: tag.sortOrder, + variantCount: tag.variantCount ?? 0, createdAt: tag.createdAt, }; } diff --git a/libs/ui/src/data/mock/tag.ts b/libs/ui/src/data/mock/tag.ts index 8b3153a1..1aeceba5 100644 --- a/libs/ui/src/data/mock/tag.ts +++ b/libs/ui/src/data/mock/tag.ts @@ -8,6 +8,7 @@ const initialTags: Tag[] = [ color: 'green', isHighlighted: true, sortOrder: 1, + variantCount: 3, createdAt: '2024-03-20T00:00:00.000Z', }, { @@ -16,6 +17,7 @@ const initialTags: Tag[] = [ color: 'orange', isHighlighted: true, sortOrder: 2, + variantCount: 5, createdAt: '2024-03-21T00:00:00.000Z', }, ]; @@ -60,6 +62,7 @@ export class MockTagRepository implements TagRepository { this.tags.push({ id: this.nextId++, ...formValues, + variantCount: 0, createdAt: new Date().toISOString(), }); } diff --git a/libs/ui/src/domain/entities/Tag.ts b/libs/ui/src/domain/entities/Tag.ts index ad773b26..95a7a12d 100644 --- a/libs/ui/src/domain/entities/Tag.ts +++ b/libs/ui/src/domain/entities/Tag.ts @@ -19,6 +19,7 @@ export type Tag = { color: TagColor; isHighlighted: boolean; sortOrder: number; + variantCount: number; createdAt: string; }; diff --git a/libs/ui/src/domain/usecases/tagList.test.ts b/libs/ui/src/domain/usecases/tagList.test.ts index f6511d13..0788ffe4 100644 --- a/libs/ui/src/domain/usecases/tagList.test.ts +++ b/libs/ui/src/domain/usecases/tagList.test.ts @@ -100,6 +100,7 @@ describe('TagListUsecase', () => { color: 'red' as const, isHighlighted: false, sortOrder: 0, + variantCount: 0, createdAt: new Date().toISOString(), }, ]; diff --git a/libs/ui/src/presentation/handlers/pos/TagCreateHandler.test.tsx b/libs/ui/src/presentation/handlers/pos/TagCreateHandler.test.tsx new file mode 100644 index 00000000..2eed4a65 --- /dev/null +++ b/libs/ui/src/presentation/handlers/pos/TagCreateHandler.test.tsx @@ -0,0 +1,231 @@ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TagCreateHandler } from './TagCreateHandler'; +import { MockAuthRepository, MockTagRepository } from '../../../data/mock'; +import { AuthLogoutUsecase, TagCreateUsecase } from '../../../domain'; +import { flushPromises } from '../../../utils/testUtils'; + +const mockRouterPush = jest.fn(); +jest.mock('solito/router', () => ({ + useRouter: () => ({ push: mockRouterPush, replace: jest.fn(), back: jest.fn() }), +})); + +const mockToastShow = jest.fn(); +jest.mock('@tamagui/toast', () => ({ + useToastController: () => ({ show: mockToastShow }), +})); + +const createProps = (options: { shouldFail?: boolean } = {}) => { + const tagRepo = new MockTagRepository(); + if (options.shouldFail) tagRepo.setShouldFail(true); + return { + authLogoutUsecase: new AuthLogoutUsecase(new MockAuthRepository()), + tagCreateUsecase: new TagCreateUsecase(tagRepo), + }; +}; + +describe('TagCreateHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('form rendering', () => { + it('should render the create form in loaded state', () => { + render(); + expect(screen.getByRole('button', { name: 'Submit' })).toBeTruthy(); + }); + + it('should render the name input field', () => { + render(); + expect(screen.getByRole('textbox', { name: 'Name' })).toBeTruthy(); + }); + + it('should render a swatch for every colour with gray selected', () => { + render(); + expect(screen.getAllByRole('radio').length).toBe(8); + expect( + screen.getByRole('radio', { name: 'Gray' }).getAttribute('aria-checked') + ).toBe('true'); + }); + + it('should render the highlight switch off by default', () => { + render(); + expect( + screen.getByRole('switch').getAttribute('aria-checked') + ).toBe('false'); + }); + }); + + describe('navigation', () => { + it('should navigate to "/tags" after successful creation', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'New Tag'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(mockRouterPush).toHaveBeenCalledWith('/tags'); + }); + + it('should create tag with the chosen colour and highlight', async () => { + const user = userEvent.setup(); + const tagRepo = new MockTagRepository(); + + render( + + ); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'New Tag'); + await user.click(screen.getByRole('radio', { name: 'Green' })); + await user.click(screen.getByRole('switch')); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(tagRepo.tags.at(-1)).toMatchObject({ + name: 'New Tag', + color: 'green', + isHighlighted: true, + }); + }); + + it('should not navigate when creation fails', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'New Tag'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(mockRouterPush).not.toHaveBeenCalled(); + }); + + it('should not navigate when name field is empty (validation fails)', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(mockRouterPush).not.toHaveBeenCalled(); + }); + + it('should not navigate without any user interaction', async () => { + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(mockRouterPush).not.toHaveBeenCalled(); + }); + }); + + describe('validation', () => { + it('should show error message when name field is empty and submit is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByText('String must contain at least 1 character(s)')).toBeTruthy(); + }); + }); + + describe('loading states', () => { + it('should disable submit button and show spinner while submitting', async () => { + const user = userEvent.setup(); + let resolveCreate!: () => void; + const tagRepo = new MockTagRepository(); + jest.spyOn(tagRepo, 'createTag').mockImplementation( + () => new Promise((resolve) => { resolveCreate = resolve; }) + ); + + render( + + ); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'New Tag'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + await act(async () => { + await flushPromises(); + }); + + expect((screen.getByRole('button', { name: 'Submit' }) as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByTestId('spinner')).toBeTruthy(); + + await act(async () => { + resolveCreate(); + await flushPromises(); + }); + + expect(screen.queryByTestId('spinner')).toBeNull(); + expect(mockRouterPush).toHaveBeenCalledWith('/tags'); + }); + + it('should not disable submit button before any interaction', () => { + render(); + expect((screen.getByRole('button', { name: 'Submit' }) as HTMLButtonElement).disabled).toBe(false); + }); + }); + + describe('toast notifications', () => { + it('should show toast error message when creation fails', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'New Tag'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(mockToastShow).toHaveBeenCalledWith('Create Tag Error'); + }); + }); + + describe('error banner', () => { + it('should show error banner when creation fails', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'New Tag'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByText('Failed to submit. Please try again.')).toBeTruthy(); + }); + + it('should not show error banner before any submission', () => { + render(); + expect(screen.queryByText('Failed to submit. Please try again.')).toBeNull(); + }); + }); +}); diff --git a/libs/ui/src/presentation/handlers/pos/TagCreateHandler.tsx b/libs/ui/src/presentation/handlers/pos/TagCreateHandler.tsx new file mode 100644 index 00000000..608d4952 --- /dev/null +++ b/libs/ui/src/presentation/handlers/pos/TagCreateHandler.tsx @@ -0,0 +1,64 @@ +import { useRouter } from 'solito/router'; +import { AuthLogoutUsecase, TagCreateUsecase } from '../../../domain'; +import { match, P } from 'ts-pattern'; +import { useEffect } from 'react'; +import { useToastController } from '@tamagui/toast'; +import { useUsecase, useAuthLogout } from '../hooks'; +import { + TagCreateScreen, + TagCreateScreenProps, +} from '../../views/screens/pos/TagCreateScreen'; + +export type TagCreateHandlerProps = { + authLogoutUsecase: AuthLogoutUsecase; + tagCreateUsecase: TagCreateUsecase; +}; + +export const TagCreateHandler = ({ + authLogoutUsecase, + tagCreateUsecase, +}: TagCreateHandlerProps) => { + const authLogout = useAuthLogout(authLogoutUsecase); + const tagCreate = useUsecase(tagCreateUsecase); + const router = useRouter(); + const toast = useToastController(); + + useEffect(() => { + if (tagCreate.state.type === 'submitSuccess') { + toast.show('Create Tag Success'); + router.push('/tags'); + } else if (tagCreate.state.type === 'submitError') { + toast.show('Create Tag Error'); + } + }, [tagCreate.state.type, toast, router]); + + return ( + authLogout.dispatch({ type: 'LOGOUT' })} + defaultValues={tagCreate.state.values} + isSubmitDisabled={ + tagCreate.state.type === 'submitting' || + tagCreate.state.type === 'submitSuccess' + } + isSubmitting={tagCreate.state.type === 'submitting'} + serverError={ + tagCreate.state.type === 'submitError' + ? 'Failed to submit. Please try again.' + : undefined + } + onSubmit={(values) => tagCreate.dispatch({ type: 'SUBMIT', values })} + variant={match(tagCreate.state) + .returnType() + .with({ type: 'loaded' }, () => ({ type: 'loaded' })) + .with( + { + type: P.union('submitting', 'submitSuccess', 'submitError'), + }, + () => ({ + type: 'loaded', + }) + ) + .exhaustive()} + /> + ); +}; diff --git a/libs/ui/src/presentation/handlers/pos/TagListHandler.test.tsx b/libs/ui/src/presentation/handlers/pos/TagListHandler.test.tsx new file mode 100644 index 00000000..519c996a --- /dev/null +++ b/libs/ui/src/presentation/handlers/pos/TagListHandler.test.tsx @@ -0,0 +1,329 @@ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TagListHandler } from './TagListHandler'; +import { MockAuthRepository, MockTagRepository } from '../../../data/mock'; +import { + AuthLogoutUsecase, + TagDeleteUsecase, + TagListUsecase, +} from '../../../domain'; +import { flushPromises } from '../../../utils/testUtils'; + +const mockRouterPush = jest.fn(); +jest.mock('solito/router', () => ({ + useRouter: () => ({ push: mockRouterPush, replace: jest.fn(), back: jest.fn() }), +})); + +jest.mock('@tamagui/toast', () => ({ + useToastController: () => ({ show: jest.fn() }), +})); + +const createProps = ( + options: { + tagRepo?: MockTagRepository; + authRepo?: MockAuthRepository; + } = {} +) => { + const tagRepo = options.tagRepo ?? new MockTagRepository(); + const authRepo = options.authRepo ?? new MockAuthRepository(); + return { + authLogoutUsecase: new AuthLogoutUsecase(authRepo), + tagListUsecase: new TagListUsecase(tagRepo, { tags: [] }), + tagDeleteUsecase: new TagDeleteUsecase(tagRepo), + }; +}; + +describe('TagListHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('loading and data states', () => { + it('should show skeleton list during initial loading', async () => { + render(); + expect(screen.getByTestId('skeleton-list')).toBeTruthy(); + await act(async () => { + await flushPromises(); + }); + }); + + it('should show tag list after successful fetch', async () => { + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('heading', { name: 'New' })).toBeTruthy(); + expect(screen.getByRole('heading', { name: 'Best Seller' })).toBeTruthy(); + }); + + it('should show the variant count for each tag', async () => { + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByText('3 variants')).toBeTruthy(); + expect(screen.getByText('5 variants')).toBeTruthy(); + }); + + it('should show the highlighted flag for each tag', async () => { + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getAllByText('Highlighted').length).toBe(2); + }); + + it('should show error state when fetch fails', async () => { + const tagRepo = new MockTagRepository(); + tagRepo.setShouldFail(true); + + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('heading', { name: 'Failed to Fetch Tags' })).toBeTruthy(); + }); + + it('should not show skeleton after data is loaded', async () => { + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.queryByTestId('skeleton-list')).toBeNull(); + }); + + it('should preserve list content during revalidation after delete', async () => { + const user = userEvent.setup(); + render(); + + await act(async () => { + await flushPromises(); + }); + + const deleteMenuItems = screen.getAllByRole('button', { name: 'Delete' }); + await user.click(deleteMenuItems[0]); + await user.click(screen.getByRole('button', { name: 'Yes' })); + + expect(screen.queryByTestId('skeleton-list')).toBeNull(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.queryByTestId('skeleton-list')).toBeNull(); + }); + + it('should show empty state when no tags exist', async () => { + const tagRepo = new MockTagRepository(); + tagRepo.tags = []; + + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('heading', { name: 'Oops, Tag is Empty' })).toBeTruthy(); + }); + + it('should show create CTA button in empty state', async () => { + const tagRepo = new MockTagRepository(); + tagRepo.tags = []; + render(); + await act(async () => { await flushPromises(); }); + expect(screen.getByRole('button', { name: 'Create Tag' })).toBeTruthy(); + }); + + it('should navigate to create page when CTA button is pressed', async () => { + const user = userEvent.setup(); + const tagRepo = new MockTagRepository(); + tagRepo.tags = []; + render(); + await act(async () => { await flushPromises(); }); + await user.click(screen.getByRole('button', { name: 'Create Tag' })); + expect(mockRouterPush).toHaveBeenCalledWith('/tags/create'); + }); + }); + + describe('delete modal', () => { + it('should not show delete modal initially', async () => { + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.queryByRole('heading', { name: 'Delete Tag ?' })).toBeNull(); + }); + + it('should show delete modal when delete menu is pressed', async () => { + const user = userEvent.setup(); + render(); + + await act(async () => { + await flushPromises(); + }); + + const deleteMenuItems = screen.getAllByRole('button', { name: 'Delete' }); + await user.click(deleteMenuItems[0]); + + expect(screen.getByRole('heading', { name: 'Delete Tag ?' })).toBeTruthy(); + }); + + it('should state how many variants carry the tag in the delete modal', async () => { + const user = userEvent.setup(); + render(); + + await act(async () => { + await flushPromises(); + }); + + const deleteMenuItems = screen.getAllByRole('button', { name: 'Delete' }); + await user.click(deleteMenuItems[0]); + + expect(screen.getByText(/applied to 3 variants/)).toBeTruthy(); + }); + + it('should hide delete modal when cancel is pressed', async () => { + const user = userEvent.setup(); + render(); + + await act(async () => { + await flushPromises(); + }); + + const deleteMenuItems = screen.getAllByRole('button', { name: 'Delete' }); + await user.click(deleteMenuItems[0]); + expect(screen.getByRole('heading', { name: 'Delete Tag ?' })).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: 'No' })); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.queryByRole('heading', { name: 'Delete Tag ?' })).toBeNull(); + }); + + it('should disable Yes button and show spinner during deletion', async () => { + const user = userEvent.setup(); + let resolveDelete!: () => void; + const tagRepo = new MockTagRepository(); + jest.spyOn(tagRepo, 'deleteTagById').mockImplementation( + () => new Promise((resolve) => { resolveDelete = resolve; }) + ); + + render(); + + await act(async () => { + await flushPromises(); + }); + + const deleteMenuItems = screen.getAllByRole('button', { name: 'Delete' }); + await user.click(deleteMenuItems[0]); + await user.click(screen.getByRole('button', { name: 'Yes' })); + + expect((screen.getByRole('button', { name: 'Yes' }) as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByTestId('spinner')).toBeTruthy(); + + await act(async () => { + resolveDelete(); + await flushPromises(); + }); + + expect(screen.queryByTestId('spinner')).toBeNull(); + }); + + it('should refetch tag list after successful delete', async () => { + const user = userEvent.setup(); + const tagRepo = new MockTagRepository(); + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('heading', { name: 'New' })).toBeTruthy(); + + const deleteMenuItems = screen.getAllByRole('button', { name: 'Delete' }); + await user.click(deleteMenuItems[0]); + expect(screen.getByRole('heading', { name: 'Delete Tag ?' })).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: 'Yes' })); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.queryByRole('heading', { name: 'Delete Tag ?' })).toBeNull(); + expect(screen.queryByRole('heading', { name: 'New' })).toBeNull(); + expect(screen.getByRole('heading', { name: 'Best Seller' })).toBeTruthy(); + }); + }); + + describe('navigation', () => { + it('should navigate to tag edit page when edit menu is pressed', async () => { + const user = userEvent.setup(); + render(); + + await act(async () => { + await flushPromises(); + }); + + const editMenuItems = screen.getAllByRole('button', { name: 'Edit' }); + await user.click(editMenuItems[0]); + + expect(mockRouterPush).toHaveBeenCalledWith('/tags/1'); + }); + + it('should navigate to tag page when item is pressed', async () => { + const user = userEvent.setup(); + render(); + + await act(async () => { + await flushPromises(); + }); + + await user.click(screen.getByRole('heading', { name: 'New' })); + + expect(mockRouterPush).toHaveBeenCalledWith('/tags/1'); + }); + }); + + describe('error recovery', () => { + it('should refetch tags when retry button is pressed', async () => { + const user = userEvent.setup(); + const tagRepo = new MockTagRepository(); + tagRepo.setShouldFail(true); + + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('heading', { name: 'Failed to Fetch Tags' })).toBeTruthy(); + + tagRepo.setShouldFail(false); + + await user.click(screen.getByRole('button', { name: 'Retry' })); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('heading', { name: 'New' })).toBeTruthy(); + }); + }); +}); diff --git a/libs/ui/src/presentation/handlers/pos/TagListHandler.tsx b/libs/ui/src/presentation/handlers/pos/TagListHandler.tsx new file mode 100644 index 00000000..edc1e4ea --- /dev/null +++ b/libs/ui/src/presentation/handlers/pos/TagListHandler.tsx @@ -0,0 +1,96 @@ +import { useRouter } from 'solito/router'; +import { + AuthLogoutUsecase, + Tag, + TagDeleteUsecase, + TagListUsecase, +} from '../../../domain'; +import { TagListScreen, TagListScreenProps } from '../../views/screens/pos/TagListScreen'; +import { match, P } from 'ts-pattern'; +import { useCallback, useEffect } from 'react'; +import { useToastController } from '@tamagui/toast'; +import { useUsecase, useAuthLogout } from '../hooks'; +import { useFocusEffect } from '../../../utils'; + +export type TagListHandlerProps = { + authLogoutUsecase: AuthLogoutUsecase; + tagListUsecase: TagListUsecase; + tagDeleteUsecase: TagDeleteUsecase; +}; + +export const TagListHandler = ({ + authLogoutUsecase, + tagListUsecase, + tagDeleteUsecase, +}: TagListHandlerProps) => { + const authLogout = useAuthLogout(authLogoutUsecase); + const tagList = useUsecase(tagListUsecase); + const tagDelete = useUsecase(tagDeleteUsecase); + const router = useRouter(); + const toast = useToastController(); + + useFocusEffect( + useCallback(() => { + tagList.dispatch({ type: 'FETCH' }); + }, [tagList.dispatch]) + ); + + useEffect(() => { + match(tagDelete.state) + .with({ type: 'deletingSuccess' }, () => { + toast.show('Delete Tag Success'); + tagList.dispatch({ type: 'FETCH' }); + }) + .with({ type: 'deletingError' }, () => { + toast.show('Delete Tag Error'); + }) + .otherwise(() => { + // nothing to do + }); + }, [tagDelete.state, tagList, toast]); + + return ( + authLogout.dispatch({ type: 'LOGOUT' })} + onEditMenuPress={(tag: Tag) => + router.push(`/tags/${tag.id}`) + } + onItemPress={(tag: Tag) => + router.push(`/tags/${tag.id}`) + } + onDeleteMenuPress={(tag: Tag) => + tagDelete.dispatch({ + type: 'SHOW_CONFIRMATION', + tagId: tag.id, + }) + } + onEmptyActionPress={() => router.push('/tags/create')} + onRetryButtonPress={() => tagList.dispatch({ type: 'FETCH' })} + isRevalidating={tagList.state.type === 'revalidating'} + variant={match(tagList.state) + .returnType() + .with({ type: P.union('idle', 'loading') }, () => ({ type: 'loading' })) + .with({ type: P.union('loaded', 'revalidating') }, ({ tags }) => ({ + type: tags.length > 0 ? 'loaded' : 'empty', + tags, + })) + .with({ type: 'error' }, () => ({ type: 'error' })) + .exhaustive()} + isDeleteButtonDisabled={tagDelete.state.type === 'deleting'} + deleteVariantCount={ + tagList.state.tags.find((tag) => tag.id === tagDelete.state.tagId) + ?.variantCount + } + isDeleteModalOpen={match(tagDelete.state.type) + .with( + P.union('shown', 'deleting', 'deletingError', 'deletingSuccess'), + () => true + ) + .otherwise(() => false)} + onDeleteCancel={() => + tagDelete.dispatch({ type: 'HIDE_CONFIRMATION' }) + } + onDeleteConfirm={() => tagDelete.dispatch({ type: 'DELETE' })} + /> + ); +}; diff --git a/libs/ui/src/presentation/handlers/pos/TagUpdateHandler.test.tsx b/libs/ui/src/presentation/handlers/pos/TagUpdateHandler.test.tsx new file mode 100644 index 00000000..0e156eac --- /dev/null +++ b/libs/ui/src/presentation/handlers/pos/TagUpdateHandler.test.tsx @@ -0,0 +1,363 @@ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TagUpdateHandler } from './TagUpdateHandler'; +import { MockAuthRepository, MockTagRepository } from '../../../data/mock'; +import { AuthLogoutUsecase, TagUpdateUsecase } from '../../../domain'; +import { flushPromises } from '../../../utils/testUtils'; + +const mockRouterPush = jest.fn(); +jest.mock('solito/router', () => ({ + useRouter: () => ({ push: mockRouterPush, replace: jest.fn(), back: jest.fn() }), +})); + +const mockToastShow = jest.fn(); +jest.mock('@tamagui/toast', () => ({ + useToastController: () => ({ show: mockToastShow }), +})); + +const createProps = (options: { + tagId?: number; + shouldFail?: boolean; + preloaded?: boolean; +} = {}) => { + const tagId = options.tagId ?? 1; + const tagRepo = new MockTagRepository(); + if (options.shouldFail) tagRepo.setShouldFail(true); + + const preloadedTag = options.preloaded + ? tagRepo.tags.find((c) => c.id === tagId) ?? null + : null; + + return { + authLogoutUsecase: new AuthLogoutUsecase(new MockAuthRepository()), + tagUpdateUsecase: new TagUpdateUsecase(tagRepo, { + tagId, + tag: preloadedTag, + }), + }; +}; + +describe('TagUpdateHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(async () => { + await act(async () => { + await flushPromises(); + }); + }); + + describe('loading and data states', () => { + it('should show loading state while fetching tag', async () => { + render(); + expect(screen.getByText('Fetching Tag...')).toBeTruthy(); + await act(async () => { + await flushPromises(); + }); + }); + + it('should show the form after tag data loads', async () => { + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('button', { name: 'Submit' })).toBeTruthy(); + }); + + it('should render pre-filled form when tag is preloaded', async () => { + render(); + expect(screen.getByDisplayValue('New')).toBeTruthy(); + await act(async () => { + await flushPromises(); + }); + }); + + it('should fill the form with fetched values when data loads after mount', async () => { + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByDisplayValue('New')).toBeTruthy(); + }); + + it('should render the current colour and highlight values', async () => { + render(); + expect( + screen.getByRole('radio', { name: 'Green' }).getAttribute('aria-checked') + ).toBe('true'); + expect( + screen.getByRole('switch').getAttribute('aria-checked') + ).toBe('true'); + await act(async () => { + await flushPromises(); + }); + }); + + it('should show error state when tag fetch fails', async () => { + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('heading', { name: 'Failed to Fetch Tag' })).toBeTruthy(); + }); + }); + + describe('navigation', () => { + it('should navigate to "/tags" after successful update', async () => { + const user = userEvent.setup(); + render(); + + const nameInput = screen.getByRole('textbox', { name: 'Name' }); + await user.clear(nameInput); + await user.type(nameInput, 'Updated Tag'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(mockRouterPush).toHaveBeenCalledWith('/tags'); + }); + + it('should update tag with the chosen colour and highlight', async () => { + const user = userEvent.setup(); + const tagRepo = new MockTagRepository(); + const preloadedTag = tagRepo.tags[0]; + + render( + + ); + + await user.click(screen.getByRole('radio', { name: 'Purple' })); + await user.click(screen.getByRole('switch')); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect( + tagRepo.tags.find((c) => c.id === preloadedTag.id) + ).toMatchObject({ color: 'purple', isHighlighted: false }); + }); + + it('should not navigate when update fails', async () => { + const user = userEvent.setup(); + const tagRepo = new MockTagRepository(); + const preloadedTag = tagRepo.tags[0]; + const tagUpdateUsecase = new TagUpdateUsecase(tagRepo, { + tagId: preloadedTag.id, + tag: preloadedTag, + }); + tagRepo.setShouldFail(true); + + render( + + ); + + const nameInput = screen.getByRole('textbox', { name: 'Name' }); + await user.clear(nameInput); + await user.type(nameInput, 'Updated Tag'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(mockRouterPush).not.toHaveBeenCalled(); + }); + + it('should not navigate without user interaction', async () => { + render(); + + await act(async () => { + await flushPromises(); + }); + + expect(mockRouterPush).not.toHaveBeenCalled(); + }); + }); + + describe('validation', () => { + it('should show error message when name field is empty and submit is clicked', async () => { + const user = userEvent.setup(); + render(); + + const nameInput = screen.getByRole('textbox', { name: 'Name' }); + await user.clear(nameInput); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByText('String must contain at least 1 character(s)')).toBeTruthy(); + }); + }); + + describe('loading states', () => { + it('should disable submit button and show spinner while updating', async () => { + const user = userEvent.setup(); + let resolveUpdate!: () => void; + const tagRepo = new MockTagRepository(); + jest.spyOn(tagRepo, 'updateTag').mockImplementation( + () => new Promise((resolve) => { resolveUpdate = resolve; }) + ); + const preloadedTag = tagRepo.tags[0]; + + render( + + ); + + const nameInput = screen.getByRole('textbox', { name: 'Name' }); + await user.clear(nameInput); + await user.type(nameInput, 'Updated Tag'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + expect((screen.getByRole('button', { name: 'Submit' }) as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByTestId('spinner')).toBeTruthy(); + + await act(async () => { + resolveUpdate(); + await flushPromises(); + }); + + expect(screen.queryByTestId('spinner')).toBeNull(); + expect(mockRouterPush).toHaveBeenCalledWith('/tags'); + }); + + it('should not disable submit button when form is loaded', async () => { + render(); + + await act(async () => { + await flushPromises(); + }); + + expect((screen.getByRole('button', { name: 'Submit' }) as HTMLButtonElement).disabled).toBe(false); + }); + }); + + describe('toast notifications', () => { + it('should show toast error message when update fails', async () => { + const user = userEvent.setup(); + const tagRepo = new MockTagRepository(); + const preloadedTag = tagRepo.tags[0]; + const tagUpdateUsecase = new TagUpdateUsecase(tagRepo, { + tagId: preloadedTag.id, + tag: preloadedTag, + }); + tagRepo.setShouldFail(true); + + render( + + ); + + const nameInput = screen.getByRole('textbox', { name: 'Name' }); + await user.clear(nameInput); + await user.type(nameInput, 'Updated Tag'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(mockToastShow).toHaveBeenCalledWith('Update Tag Error'); + }); + }); + + describe('error banner', () => { + it('should show error banner when update fails', async () => { + const user = userEvent.setup(); + const tagRepo = new MockTagRepository(); + const preloadedTag = tagRepo.tags[0]; + const tagUpdateUsecase = new TagUpdateUsecase(tagRepo, { + tagId: preloadedTag.id, + tag: preloadedTag, + }); + tagRepo.setShouldFail(true); + + render( + + ); + + const nameInput = screen.getByRole('textbox', { name: 'Name' }); + await user.clear(nameInput); + await user.type(nameInput, 'Updated Tag'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByText('Failed to submit. Please try again.')).toBeTruthy(); + }); + + it('should not show error banner before any submission', () => { + render(); + expect(screen.queryByText('Failed to submit. Please try again.')).toBeNull(); + }); + }); + + describe('error recovery', () => { + it('should refetch tag when retry button is pressed after error', async () => { + const user = userEvent.setup(); + const tagRepo = new MockTagRepository(); + tagRepo.setShouldFail(true); + + render( + + ); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('heading', { name: 'Failed to Fetch Tag' })).toBeTruthy(); + + tagRepo.setShouldFail(false); + + await user.click(screen.getByRole('button', { name: 'Retry' })); + + await act(async () => { + await flushPromises(); + }); + + expect(screen.getByRole('button', { name: 'Submit' })).toBeTruthy(); + }); + }); +}); diff --git a/libs/ui/src/presentation/handlers/pos/TagUpdateHandler.tsx b/libs/ui/src/presentation/handlers/pos/TagUpdateHandler.tsx new file mode 100644 index 00000000..d6aa598a --- /dev/null +++ b/libs/ui/src/presentation/handlers/pos/TagUpdateHandler.tsx @@ -0,0 +1,75 @@ +import { useRouter } from 'solito/router'; +import { AuthLogoutUsecase, TagUpdateUsecase } from '../../../domain'; +import { match, P } from 'ts-pattern'; +import { useEffect } from 'react'; +import { useToastController } from '@tamagui/toast'; +import { useUsecase, useAuthLogout } from '../hooks'; +import { + TagUpdateScreen, + TagUpdateScreenProps, +} from '../../views/screens/pos/TagUpdateScreen'; + +export type TagUpdateHandlerProps = { + authLogoutUsecase: AuthLogoutUsecase; + tagUpdateUsecase: TagUpdateUsecase; +}; + +export const TagUpdateHandler = ({ + authLogoutUsecase, + tagUpdateUsecase, +}: TagUpdateHandlerProps) => { + const authLogout = useAuthLogout(authLogoutUsecase); + const tagUpdate = useUsecase(tagUpdateUsecase); + const router = useRouter(); + const toast = useToastController(); + + useEffect(() => { + if (tagUpdate.state.type === 'submitSuccess') { + toast.show('Update Tag Success'); + router.push('/tags'); + } else if (tagUpdate.state.type === 'submitError') { + toast.show('Update Tag Error'); + } + }, [tagUpdate.state.type, toast, router]); + + return ( + authLogout.dispatch({ type: 'LOGOUT' })} + defaultValues={tagUpdate.state.values} + isSubmitDisabled={ + tagUpdate.state.type === 'submitting' || + tagUpdate.state.type === 'submitSuccess' + } + isSubmitting={tagUpdate.state.type === 'submitting'} + serverError={ + tagUpdate.state.type === 'submitError' + ? 'Failed to submit. Please try again.' + : undefined + } + onSubmit={(values) => tagUpdate.dispatch({ type: 'SUBMIT', values })} + variant={match(tagUpdate.state) + .returnType() + .with({ type: P.union('idle', 'loading') }, () => ({ + type: 'loading', + })) + .with( + { + type: P.union( + 'loaded', + 'submitError', + 'submitSuccess', + 'submitting' + ), + }, + () => ({ + type: 'loaded', + }) + ) + .with({ type: 'error' }, () => ({ + type: 'error', + onRetryButtonPress: () => tagUpdate.dispatch({ type: 'FETCH' }), + })) + .exhaustive()} + /> + ); +}; diff --git a/libs/ui/src/presentation/handlers/pos/index.ts b/libs/ui/src/presentation/handlers/pos/index.ts index ee1e8d89..cbb1b850 100644 --- a/libs/ui/src/presentation/handlers/pos/index.ts +++ b/libs/ui/src/presentation/handlers/pos/index.ts @@ -5,6 +5,9 @@ export * from './BudgetUpdateHandler'; export * from './CategoryCreateHandler'; export * from './CategoryListHandler'; export * from './CategoryUpdateHandler'; +export * from './TagCreateHandler'; +export * from './TagListHandler'; +export * from './TagUpdateHandler'; export * from './CouponCreateHandler'; export * from './CouponListHandler'; export * from './CouponUpdateHandler'; diff --git a/libs/ui/src/presentation/views/components/base/Sidebar/Sidebar.state.tsx b/libs/ui/src/presentation/views/components/base/Sidebar/Sidebar.state.tsx index 58767306..f0574e2c 100644 --- a/libs/ui/src/presentation/views/components/base/Sidebar/Sidebar.state.tsx +++ b/libs/ui/src/presentation/views/components/base/Sidebar/Sidebar.state.tsx @@ -39,6 +39,7 @@ const items: MenuItem[] = [ icon: Box, subItems: [ { title: 'Categories', path: '/categories' }, + { title: 'Tags', path: '/tags' }, { title: 'Products', path: '/products' }, { title: 'Materials', path: '/materials' }, { title: 'Suppliers', path: '/suppliers' }, diff --git a/libs/ui/src/presentation/views/components/index.ts b/libs/ui/src/presentation/views/components/index.ts index a40576d6..a6de8df5 100644 --- a/libs/ui/src/presentation/views/components/index.ts +++ b/libs/ui/src/presentation/views/components/index.ts @@ -3,6 +3,7 @@ export * from './kds'; export * from './base'; export * from './budgets'; export * from './categories'; +export * from './tags'; export * from './coupons'; export * from './tickets'; export * from './tables'; diff --git a/libs/ui/src/presentation/views/components/tags/TagColorPicker.tsx b/libs/ui/src/presentation/views/components/tags/TagColorPicker.tsx new file mode 100644 index 00000000..c988ce01 --- /dev/null +++ b/libs/ui/src/presentation/views/components/tags/TagColorPicker.tsx @@ -0,0 +1,49 @@ +import { Controller } from 'react-hook-form'; +import { Button, XStack } from 'tamagui'; +import { Check } from '@tamagui/lucide-icons'; +import { useFieldContext } from '../base'; +import { tagColors } from '../../../../domain'; +import { tagColorBackground, tagColorLabels } from './tagColors'; + +export type TagColorPickerProps = { + name?: string; +}; + +export const TagColorPicker = ({ name }: TagColorPickerProps) => { + const fieldContext = useFieldContext(); + const fieldName = fieldContext.name ?? name ?? ''; + return ( + ( + + {tagColors.map((color) => { + const checked = field.value === color; + return ( + + + + + + + + + ); +}; diff --git a/libs/ui/src/presentation/views/components/tags/TagFormView.stories.tsx b/libs/ui/src/presentation/views/components/tags/TagFormView.stories.tsx new file mode 100644 index 00000000..a8da29de --- /dev/null +++ b/libs/ui/src/presentation/views/components/tags/TagFormView.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { TagFormView } from './TagFormView'; + +const meta: Meta = { + title: 'Components/Tags/TagFormView', + component: TagFormView, +}; + +export default meta; +type Story = StoryObj; + +export const Loaded: Story = { + args: { + variant: { type: 'loaded' }, + defaultValues: { + name: '', + color: 'gray', + isHighlighted: false, + sortOrder: 0, + }, + onSubmit: fn(), + isSubmitDisabled: false, + isSubmitting: false, + }, +}; + +export const Populated: Story = { + args: { + ...Loaded.args, + defaultValues: { + name: 'Best Seller', + color: 'orange', + isHighlighted: true, + sortOrder: 2, + }, + }, +}; + +export const Loading: Story = { + args: { + ...Loaded.args, + variant: { type: 'loading' }, + isSubmitDisabled: true, + }, +}; + +export const Error: Story = { + args: { + ...Loaded.args, + variant: { type: 'error', onRetryButtonPress: fn() }, + isSubmitDisabled: true, + }, +}; + +export const SubmitDisabled: Story = { + args: { + ...Loaded.args, + isSubmitDisabled: true, + }, +}; + +export const Submitting: Story = { + args: { + ...Loaded.args, + isSubmitDisabled: true, + isSubmitting: true, + }, +}; diff --git a/libs/ui/src/presentation/views/components/tags/TagFormView.tsx b/libs/ui/src/presentation/views/components/tags/TagFormView.tsx new file mode 100644 index 00000000..552121dd --- /dev/null +++ b/libs/ui/src/presentation/views/components/tags/TagFormView.tsx @@ -0,0 +1,61 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button, Spinner } from 'tamagui'; +import { + Field, + FormErrorBanner, + InputText, + InputNumber, + Switch, + FormView, + FormVariant, +} from '../base'; +import { TagForm, tagFormSchema } from '../../../../domain'; +import { TagColorPicker } from './TagColorPicker'; + +const tagFormResolver = zodResolver(tagFormSchema); + +export type TagFormViewProps = { + variant: FormVariant; + defaultValues: TagForm; + onSubmit: (values: TagForm) => void; + isSubmitDisabled: boolean; + isSubmitting: boolean; + serverError?: string; +}; + +export const TagFormView = (props: TagFormViewProps) => ( + + {(form) => ( + <> + + + + + + + + + + + + + + + + )} + +); diff --git a/libs/ui/src/presentation/views/components/tags/TagList.stories.tsx b/libs/ui/src/presentation/views/components/tags/TagList.stories.tsx new file mode 100644 index 00000000..5b7fd798 --- /dev/null +++ b/libs/ui/src/presentation/views/components/tags/TagList.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { TagList } from './TagList'; +import { mockTags } from '../../../../../.storybook/mocks/mockData'; + +const defaultArgs = { + onRetryButtonPress: fn(), + onDeleteMenuPress: fn(), + onEditMenuPress: fn(), + onItemPress: fn(), +}; + +const meta: Meta = { + title: 'Components/Tags/TagList', + component: TagList, + args: defaultArgs, +}; + +export default meta; +type Story = StoryObj; + +export const Loaded: Story = { + args: { + variant: { type: 'loaded', tags: mockTags }, + }, +}; + +export const Loading: Story = { + args: { + variant: { type: 'loading' }, + }, +}; + +export const Empty: Story = { + args: { + variant: { type: 'empty' }, + }, +}; + +export const Error: Story = { + args: { + variant: { type: 'error' }, + }, +}; diff --git a/libs/ui/src/presentation/views/components/tags/TagList.tsx b/libs/ui/src/presentation/views/components/tags/TagList.tsx new file mode 100644 index 00000000..3867af66 --- /dev/null +++ b/libs/ui/src/presentation/views/components/tags/TagList.tsx @@ -0,0 +1,72 @@ +import { Spinner, YStack } from 'tamagui'; +import { FlatList } from 'react-native'; +import { match } from 'ts-pattern'; +import { EmptyView, ErrorView, SkeletonList } from '../base'; +import { Tag } from '../../../../domain'; +import { TagListItem } from './TagListItem'; + +export type TagListProps = { + onRetryButtonPress: () => void; + onEmptyActionPress?: () => void; + onDeleteMenuPress: (tag: Tag) => void; + onEditMenuPress: (tag: Tag) => void; + onItemPress: (tag: Tag) => void; + isRevalidating?: boolean; + variant: + | { type: 'loading' } + | { type: 'error' } + | { type: 'empty' } + | { type: 'loaded'; tags: Tag[] }; +}; + +export const TagList = ({ + onRetryButtonPress, + onEmptyActionPress, + onDeleteMenuPress, + onEditMenuPress, + onItemPress, + isRevalidating, + variant, +}: TagListProps) => { + return ( + + {isRevalidating && } + {match(variant) + .with({ type: 'loading' }, () => ) + .with({ type: 'empty' }, () => ( + + )) + .with({ type: 'loaded' }, ({ tags }) => ( + ( + onDeleteMenuPress(item)} + onEditMenuPress={() => onEditMenuPress(item)} + onPress={() => onItemPress(item)} + /> + )} + ItemSeparatorComponent={() => } + /> + )) + .with({ type: 'error' }, () => ( + + )) + .exhaustive()} + + ); +}; diff --git a/libs/ui/src/presentation/views/components/tags/TagListItem.stories.tsx b/libs/ui/src/presentation/views/components/tags/TagListItem.stories.tsx new file mode 100644 index 00000000..2c7a014b --- /dev/null +++ b/libs/ui/src/presentation/views/components/tags/TagListItem.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { TagListItem } from './TagListItem'; + +const meta: Meta = { + title: 'Components/Tags/TagListItem', + component: TagListItem, + args: { + name: 'Best Seller', + color: 'orange', + isHighlighted: true, + variantCount: 5, + onEditMenuPress: fn(), + onDeleteMenuPress: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Highlighted: Story = {}; + +export const NotHighlighted: Story = { + args: { name: 'Vegan', color: 'blue', isHighlighted: false, variantCount: 1 }, +}; + +export const Unassigned: Story = { + args: { variantCount: 0 }, +}; diff --git a/libs/ui/src/presentation/views/components/tags/TagListItem.tsx b/libs/ui/src/presentation/views/components/tags/TagListItem.tsx new file mode 100644 index 00000000..01c70df5 --- /dev/null +++ b/libs/ui/src/presentation/views/components/tags/TagListItem.tsx @@ -0,0 +1,45 @@ +import { Pencil, Trash } from '@tamagui/lucide-icons'; +import { XStackProps } from 'tamagui'; +import { ListItem } from '../base'; +import { TagColor } from '../../../../domain'; +import { TagColorPill } from './TagColorPill'; + +export type TagListItemProps = { + name: string; + color: TagColor; + isHighlighted: boolean; + variantCount: number; + onEditMenuPress: () => void; + onDeleteMenuPress: () => void; +} & XStackProps; + +export const TagListItem = ({ + name, + color, + isHighlighted, + variantCount, + onDeleteMenuPress, + onEditMenuPress, + ...xStackProps +}: TagListItemProps) => { + return ( + } + subtitle={`${variantCount} ${ + variantCount === 1 ? 'variant' : 'variants' + }`} + footerItems={[ + { + label: 'Order app section', + value: isHighlighted ? 'Highlighted' : 'Not highlighted', + }, + ]} + menus={[ + { title: 'Edit', icon: Pencil, onPress: onEditMenuPress }, + { title: 'Delete', icon: Trash, onPress: onDeleteMenuPress }, + ]} + {...xStackProps} + /> + ); +}; diff --git a/libs/ui/src/presentation/views/components/tags/index.ts b/libs/ui/src/presentation/views/components/tags/index.ts new file mode 100644 index 00000000..17ff2680 --- /dev/null +++ b/libs/ui/src/presentation/views/components/tags/index.ts @@ -0,0 +1,7 @@ +export * from './TagColorPicker'; +export * from './TagColorPill'; +export * from './TagDeleteAlert'; +export * from './TagFormView'; +export * from './TagList'; +export * from './TagListItem'; +export * from './tagColors'; diff --git a/libs/ui/src/presentation/views/components/tags/tagColors.ts b/libs/ui/src/presentation/views/components/tags/tagColors.ts new file mode 100644 index 00000000..e5966028 --- /dev/null +++ b/libs/ui/src/presentation/views/components/tags/tagColors.ts @@ -0,0 +1,15 @@ +import { TagColor } from '../../../../domain'; + +export const tagColorLabels: Record = { + red: 'Red', + orange: 'Orange', + yellow: 'Yellow', + green: 'Green', + blue: 'Blue', + purple: 'Purple', + pink: 'Pink', + gray: 'Gray', +}; + +export const tagColorBackground = (color: TagColor) => `$${color}5` as const; +export const tagColorForeground = (color: TagColor) => `$${color}11` as const; diff --git a/libs/ui/src/presentation/views/screens/pos/TagCreateScreen.stories.tsx b/libs/ui/src/presentation/views/screens/pos/TagCreateScreen.stories.tsx new file mode 100644 index 00000000..5b66cec3 --- /dev/null +++ b/libs/ui/src/presentation/views/screens/pos/TagCreateScreen.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { TagCreateScreen } from './TagCreateScreen'; + +const meta: Meta = { + title: 'Screens/POS/TagCreateScreen', + component: TagCreateScreen, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + defaultValues: { + name: '', + color: 'gray', + isHighlighted: false, + sortOrder: 0, + }, + onSubmit: fn(), + isSubmitDisabled: false, + isSubmitting: false, + onLogoutPress: fn(), + variant: { type: 'loaded' }, + }, +}; + +export const Loading: Story = { + args: { + ...Default.args, + isSubmitDisabled: true, + variant: { type: 'loading' }, + }, +}; diff --git a/libs/ui/src/presentation/views/screens/pos/TagCreateScreen.tsx b/libs/ui/src/presentation/views/screens/pos/TagCreateScreen.tsx new file mode 100644 index 00000000..b849becb --- /dev/null +++ b/libs/ui/src/presentation/views/screens/pos/TagCreateScreen.tsx @@ -0,0 +1,46 @@ +import { ScrollView } from 'tamagui'; +import { + TagFormView, + TagFormViewProps, + Layout, +} from '../../components'; +import { TagForm } from '../../../../domain'; + +export type TagCreateScreenProps = { + onLogoutPress: () => void; + defaultValues: TagForm; + isSubmitDisabled: boolean; + isSubmitting: boolean; + onSubmit: (values: TagForm) => void; + variant: TagFormViewProps['variant']; + serverError?: string; +}; + +export const TagCreateScreen = ({ + defaultValues, + isSubmitDisabled, + isSubmitting, + onLogoutPress, + onSubmit, + variant, + serverError, +}: TagCreateScreenProps) => { + return ( + + + + + + ); +}; diff --git a/libs/ui/src/presentation/views/screens/pos/TagListScreen.stories.tsx b/libs/ui/src/presentation/views/screens/pos/TagListScreen.stories.tsx new file mode 100644 index 00000000..86731983 --- /dev/null +++ b/libs/ui/src/presentation/views/screens/pos/TagListScreen.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { TagListScreen } from './TagListScreen'; +import { mockTags } from '../../../../../.storybook/mocks/mockData'; + +const defaultArgs = { + onLogoutPress: fn(), + onEditMenuPress: fn(), + onDeleteMenuPress: fn(), + onItemPress: fn(), + onRetryButtonPress: fn(), + isDeleteButtonDisabled: false, + isDeleteModalOpen: false, + onDeleteCancel: fn(), + onDeleteConfirm: fn(), +}; + +const meta: Meta = { + title: 'Screens/POS/TagListScreen', + component: TagListScreen, + parameters: { layout: 'fullscreen' }, + args: defaultArgs, +}; + +export default meta; +type Story = StoryObj; + +export const Loaded: Story = { + args: { variant: { type: 'loaded', tags: mockTags } }, +}; + +export const Loading: Story = { + args: { variant: { type: 'loading' } }, +}; + +export const Empty: Story = { + args: { variant: { type: 'empty' } }, +}; + +export const Error: Story = { + args: { variant: { type: 'error' } }, +}; + +export const DeleteModalOpen: Story = { + args: { + variant: { type: 'loaded', tags: mockTags }, + isDeleteModalOpen: true, + deleteVariantCount: 5, + }, +}; diff --git a/libs/ui/src/presentation/views/screens/pos/TagListScreen.tsx b/libs/ui/src/presentation/views/screens/pos/TagListScreen.tsx new file mode 100644 index 00000000..2a219ad7 --- /dev/null +++ b/libs/ui/src/presentation/views/screens/pos/TagListScreen.tsx @@ -0,0 +1,71 @@ +import { Button } from 'tamagui'; +import { Link } from 'solito/link'; +import { Plus } from '@tamagui/lucide-icons'; +import { + TagDeleteAlert, + TagList, + Layout, + TagListProps, +} from '../../components'; +import { Tag } from '../../../../domain'; + +export type TagListScreenProps = { + onLogoutPress: () => void; + onEditMenuPress: (tag: Tag) => void; + onDeleteMenuPress: (tag: Tag) => void; + onItemPress: (tag: Tag) => void; + onRetryButtonPress: () => void; + variant: TagListProps['variant']; + isRevalidating?: boolean; + isDeleteButtonDisabled: boolean; + isDeleteModalOpen: boolean; + deleteVariantCount?: number; + onDeleteCancel: () => void; + onDeleteConfirm: () => void; + onEmptyActionPress?: () => void; +}; + +export const TagListScreen = ({ + onLogoutPress, + onEditMenuPress, + onDeleteMenuPress, + onItemPress, + onRetryButtonPress, + variant, + isRevalidating, + isDeleteButtonDisabled, + isDeleteModalOpen, + deleteVariantCount, + onDeleteCancel, + onDeleteConfirm, + onEmptyActionPress, +}: TagListScreenProps) => { + return ( + + + ); + })} + + )} + + + )} diff --git a/libs/ui/src/presentation/views/screens/pos/VariantCreateScreen.stories.tsx b/libs/ui/src/presentation/views/screens/pos/VariantCreateScreen.stories.tsx index cff63e45..b6b4a958 100644 --- a/libs/ui/src/presentation/views/screens/pos/VariantCreateScreen.stories.tsx +++ b/libs/ui/src/presentation/views/screens/pos/VariantCreateScreen.stories.tsx @@ -2,7 +2,11 @@ import type { Meta, StoryObj } from '@storybook/react'; import { fn } from '@storybook/test'; import { VariantCreateScreen } from './VariantCreateScreen'; import type { VariantForm } from '../../../../domain'; -import { mockProduct, mockMaterials } from '../../../../../.storybook/mocks/mockData'; +import { + mockProduct, + mockMaterials, + mockTags, +} from '../../../../../.storybook/mocks/mockData'; const defaultValues: VariantForm = { name: '', @@ -14,6 +18,7 @@ const defaultValues: VariantForm = { productId: mockProduct.id, values: [], pricingTiers: [], + tagIds: [], }; const meta: Meta = { @@ -34,6 +39,7 @@ export const Default: Story = { onLogoutPress: fn(), variant: { type: 'loaded' }, product: mockProduct, + tags: mockTags, materialList: { currentPage: 1, itemPerPage: 10, @@ -53,6 +59,7 @@ export const Loading: Story = { isSubmitDisabled: true, variant: { type: 'loading' }, product: null, + tags: [], materialList: { ...Default.args.materialList, totalItem: 0, diff --git a/libs/ui/src/presentation/views/screens/pos/VariantCreateScreen.tsx b/libs/ui/src/presentation/views/screens/pos/VariantCreateScreen.tsx index c63d3e54..516d6d2a 100644 --- a/libs/ui/src/presentation/views/screens/pos/VariantCreateScreen.tsx +++ b/libs/ui/src/presentation/views/screens/pos/VariantCreateScreen.tsx @@ -6,7 +6,7 @@ import { MaterialList, MaterialListProps, } from '../../components'; -import { Product, VariantForm } from '../../../../domain'; +import { Product, Tag, VariantForm } from '../../../../domain'; export type VariantCreateScreenProps = { defaultValues: VariantForm; @@ -16,6 +16,7 @@ export type VariantCreateScreenProps = { onLogoutPress: () => void; variant: VariantFormViewProps['variant']; product: Product | null; + tags: Tag[]; materialList: { currentPage: number; itemPerPage: number; @@ -43,6 +44,7 @@ export const VariantCreateScreen = (props: VariantCreateScreenProps) => { isSubmitDisabled={props.isSubmitDisabled} isSubmitting={props.isSubmitting} product={props.product} + tags={props.tags} variant={props.variant} serverError={props.serverError} MaterialList={(fieldArray, onAddMaterial) => ( diff --git a/libs/ui/src/presentation/views/screens/pos/VariantUpdateScreen.stories.tsx b/libs/ui/src/presentation/views/screens/pos/VariantUpdateScreen.stories.tsx index e4872904..ef9edd0f 100644 --- a/libs/ui/src/presentation/views/screens/pos/VariantUpdateScreen.stories.tsx +++ b/libs/ui/src/presentation/views/screens/pos/VariantUpdateScreen.stories.tsx @@ -5,6 +5,7 @@ import type { VariantForm } from '../../../../domain'; import { mockProduct, mockMaterials, + mockTags, mockVariant, } from '../../../../../.storybook/mocks/mockData'; @@ -21,6 +22,7 @@ const defaultValues: VariantForm = { optionValueId: value.optionValueId, })), pricingTiers: mockVariant.pricingTiers, + tagIds: [2], }; const meta: Meta = { @@ -41,6 +43,7 @@ export const Default: Story = { onLogoutPress: fn(), variant: { type: 'loaded' }, product: mockProduct, + tags: mockTags, materialList: { currentPage: 1, itemPerPage: 10, @@ -66,10 +69,12 @@ export const Loading: Story = { productId: 1, values: [], pricingTiers: [], + tagIds: [], }, isSubmitDisabled: true, variant: { type: 'loading' }, product: null, + tags: [], materialList: { ...Default.args.materialList, totalItem: 0, diff --git a/libs/ui/src/presentation/views/screens/pos/VariantUpdateScreen.tsx b/libs/ui/src/presentation/views/screens/pos/VariantUpdateScreen.tsx index be576405..f83e9541 100644 --- a/libs/ui/src/presentation/views/screens/pos/VariantUpdateScreen.tsx +++ b/libs/ui/src/presentation/views/screens/pos/VariantUpdateScreen.tsx @@ -6,7 +6,7 @@ import { MaterialListProps, MaterialList, } from '../../components'; -import { Product, VariantForm } from '../../../../domain'; +import { Product, Tag, VariantForm } from '../../../../domain'; export type VariantUpdateScreenProps = { defaultValues: VariantForm; @@ -16,6 +16,7 @@ export type VariantUpdateScreenProps = { onLogoutPress: () => void; variant: VariantFormViewProps['variant']; product: Product | null; + tags: Tag[]; materialList: { currentPage: number; itemPerPage: number; @@ -44,6 +45,7 @@ export const VariantUpdateScreen = (props: VariantUpdateScreenProps) => { isSubmitting={props.isSubmitting} variant={props.variant} product={props.product} + tags={props.tags} serverError={props.serverError} MaterialList={(fieldArray, onAddMaterial) => ( Date: Fri, 2 Oct 2026 06:33:25 +0700 Subject: [PATCH 09/15] feat(pos): add tag assignment screen (#673) Add TagAssignmentUsecase (load all variants, TOGGLE_VARIANT, tri-state TOGGLE_PRODUCT, SEARCH, SAVE via TagRepository.setTagVariants), TagAssignmentScreen grouped by category > product > variants with single-variant products collapsed to one checkbox, handler, composition root, page at /tags/[tagId]/assign and an "Assign products" action on the tag list item. Claude-Session: https://claude.ai/code/session_01438CoGpchErzXVWnCgQi6A Co-authored-by: Claude --- .../pos-web/src/pages/tags/[tagId]/assign.tsx | 28 ++ libs/ui/src/__mocks__/tamagui.tsx | 15 +- libs/ui/src/app/pos/TagAssignment.tsx | 37 +++ libs/ui/src/app/pos/index.ts | 1 + libs/ui/src/data/api/tag.ts | 5 + libs/ui/src/data/mock/tag.ts | 13 + libs/ui/src/data/mock/variant.ts | 68 ++++- libs/ui/src/domain/repositories/tag.ts | 2 + libs/ui/src/domain/usecases/index.ts | 1 + .../src/domain/usecases/tagAssignment.test.ts | 190 ++++++++++++++ libs/ui/src/domain/usecases/tagAssignment.ts | 206 +++++++++++++++ .../pos/TagAssignmentHandler.test.tsx | 240 ++++++++++++++++++ .../handlers/pos/TagAssignmentHandler.tsx | 136 ++++++++++ .../handlers/pos/TagListHandler.test.tsx | 16 ++ .../handlers/pos/TagListHandler.tsx | 3 + .../ui/src/presentation/handlers/pos/index.ts | 1 + .../tags/TagAssignmentList.stories.tsx | 51 ++++ .../components/tags/TagAssignmentList.tsx | 187 ++++++++++++++ .../views/components/tags/TagList.stories.tsx | 1 + .../views/components/tags/TagList.tsx | 3 + .../components/tags/TagListItem.stories.tsx | 1 + .../views/components/tags/TagListItem.tsx | 9 +- .../views/components/tags/index.ts | 1 + .../pos/TagAssignmentScreen.stories.tsx | 95 +++++++ .../views/screens/pos/TagAssignmentScreen.tsx | 85 +++++++ .../screens/pos/TagListScreen.stories.tsx | 1 + .../views/screens/pos/TagListScreen.tsx | 3 + .../presentation/views/screens/pos/index.ts | 1 + 28 files changed, 1396 insertions(+), 4 deletions(-) create mode 100644 apps/pos-web/src/pages/tags/[tagId]/assign.tsx create mode 100644 libs/ui/src/app/pos/TagAssignment.tsx create mode 100644 libs/ui/src/domain/usecases/tagAssignment.test.ts create mode 100644 libs/ui/src/domain/usecases/tagAssignment.ts create mode 100644 libs/ui/src/presentation/handlers/pos/TagAssignmentHandler.test.tsx create mode 100644 libs/ui/src/presentation/handlers/pos/TagAssignmentHandler.tsx create mode 100644 libs/ui/src/presentation/views/components/tags/TagAssignmentList.stories.tsx create mode 100644 libs/ui/src/presentation/views/components/tags/TagAssignmentList.tsx create mode 100644 libs/ui/src/presentation/views/screens/pos/TagAssignmentScreen.stories.tsx create mode 100644 libs/ui/src/presentation/views/screens/pos/TagAssignmentScreen.tsx diff --git a/apps/pos-web/src/pages/tags/[tagId]/assign.tsx b/apps/pos-web/src/pages/tags/[tagId]/assign.tsx new file mode 100644 index 00000000..00fabc3c --- /dev/null +++ b/apps/pos-web/src/pages/tags/[tagId]/assign.tsx @@ -0,0 +1,28 @@ +import { + TagAssignment, + TagAssignmentProps, +} from '@gatherloop-pos/ui/pos'; +import { GetServerSideProps } from 'next'; + +export const getServerSideProps: GetServerSideProps< + TagAssignmentProps, + { tagId: string } +> = async (ctx) => { + const isLoggedIn = ctx.req.headers.cookie?.includes('Authorization'); + if (!isLoggedIn) { + return { + redirect: { + destination: '/auth/login', + permanent: false, + }, + }; + } + + const tagId = parseInt(ctx.params?.tagId ?? ''); + + return { + props: { tagAssignmentParams: { tagId } }, + }; +}; + +export default TagAssignment; diff --git a/libs/ui/src/__mocks__/tamagui.tsx b/libs/ui/src/__mocks__/tamagui.tsx index fc995c28..1a69a240 100644 --- a/libs/ui/src/__mocks__/tamagui.tsx +++ b/libs/ui/src/__mocks__/tamagui.tsx @@ -236,10 +236,21 @@ export const Dialog = Object.assign(DialogBase, { Close: DialogClose, }); -const CheckboxBase = ({ children, onCheckedChange, checked }: AnyProps) => +const CheckboxBase = ({ + children, + onCheckedChange, + checked, + accessibilityLabel, +}: AnyProps) => React.createElement( 'div', - { 'data-component': 'Checkbox', onClick: () => onCheckedChange?.(!checked) }, + { + 'data-component': 'Checkbox', + role: 'checkbox', + 'aria-checked': checked === 'indeterminate' ? 'mixed' : Boolean(checked), + ...(accessibilityLabel ? { 'aria-label': accessibilityLabel } : {}), + onClick: () => onCheckedChange?.(!checked), + }, children ); const CheckboxIndicator = ({ children }: AnyProps) => diff --git a/libs/ui/src/app/pos/TagAssignment.tsx b/libs/ui/src/app/pos/TagAssignment.tsx new file mode 100644 index 00000000..b9911eab --- /dev/null +++ b/libs/ui/src/app/pos/TagAssignment.tsx @@ -0,0 +1,37 @@ +import { + ApiAuthRepository, + ApiTagRepository, + ApiVariantRepository, +} from '../../data'; +import { + AuthLogoutUsecase, + TagAssignmentParams, + TagAssignmentUsecase, +} from '../../domain'; +import { TagAssignmentHandler } from '../../presentation'; +import { QueryClient } from '@tanstack/react-query'; + +export type TagAssignmentProps = { + tagAssignmentParams: TagAssignmentParams; +}; + +export function TagAssignment({ tagAssignmentParams }: TagAssignmentProps) { + const client = new QueryClient(); + const tagRepository = new ApiTagRepository(client); + const variantRepository = new ApiVariantRepository(client); + const authRepository = new ApiAuthRepository(); + + const authLogoutUsecase = new AuthLogoutUsecase(authRepository); + const tagAssignmentUsecase = new TagAssignmentUsecase( + tagRepository, + variantRepository, + tagAssignmentParams + ); + + return ( + + ); +} diff --git a/libs/ui/src/app/pos/index.ts b/libs/ui/src/app/pos/index.ts index dee6751f..a9aa397b 100644 --- a/libs/ui/src/app/pos/index.ts +++ b/libs/ui/src/app/pos/index.ts @@ -58,3 +58,4 @@ export * from './StockCheckCreate'; export * from './StockCheckUpdate'; export * from './PurchaseList'; export * from './Availability'; +export * from './TagAssignment'; diff --git a/libs/ui/src/data/api/tag.ts b/libs/ui/src/data/api/tag.ts index b573e145..9fa4a668 100644 --- a/libs/ui/src/data/api/tag.ts +++ b/libs/ui/src/data/api/tag.ts @@ -7,6 +7,7 @@ import { tagFindByIdQueryKey, tagList, tagListQueryKey, + tagSetVariants, tagUpdateById, } from '../../../../api-contract/src'; import { Tag, TagRepository } from '../../domain'; @@ -37,6 +38,10 @@ export class ApiTagRepository implements TagRepository { return tagUpdateById(tagId, toApiTag(formValues)).then(); }; + setTagVariants: TagRepository['setTagVariants'] = (tagId, variantIds) => { + return tagSetVariants(tagId, { variantIds }).then(); + }; + deleteTagById: TagRepository['deleteTagById'] = (tagId) => { return tagDeleteById(tagId).then(); }; diff --git a/libs/ui/src/data/mock/tag.ts b/libs/ui/src/data/mock/tag.ts index 1c7890ab..39f6c751 100644 --- a/libs/ui/src/data/mock/tag.ts +++ b/libs/ui/src/data/mock/tag.ts @@ -25,6 +25,8 @@ export const mockTags: Tag[] = [ export class MockTagRepository implements TagRepository { tags: Tag[] = [...mockTags]; + lastSubmittedVariantIds: number[] | null = null; + private nextId = 3; private shouldFail = false; @@ -76,8 +78,19 @@ export class MockTagRepository implements TagRepository { this.tags[idx] = { ...this.tags[idx], ...formValues }; } + async setTagVariants(tagId: number, variantIds: number[]): Promise { + if (this.shouldFail) { + throw new Error('Failed to set tag variants'); + } + const idx = this.tags.findIndex((t) => t.id === tagId); + if (idx === -1) throw new Error('Tag not found'); + this.lastSubmittedVariantIds = variantIds; + this.tags[idx] = { ...this.tags[idx], variantCount: variantIds.length }; + } + reset() { this.tags = [...mockTags]; + this.lastSubmittedVariantIds = null; this.nextId = 3; this.shouldFail = false; } diff --git a/libs/ui/src/data/mock/variant.ts b/libs/ui/src/data/mock/variant.ts index 368e6f66..73639a75 100644 --- a/libs/ui/src/data/mock/variant.ts +++ b/libs/ui/src/data/mock/variant.ts @@ -5,7 +5,12 @@ import { mockTags } from './tag'; const mockProduct = { id: 1, name: 'Product 1', - category: { id: 1, name: 'Category 1', station: 'NONE' as const, createdAt: '2024-03-20T00:00:00.000Z' }, + category: { + id: 1, + name: 'Category 1', + station: 'NONE' as const, + createdAt: '2024-03-20T00:00:00.000Z', + }, imageUrl: 'https://example.com/1.jpg', saleType: 'purchase' as const, status: 'published' as const, @@ -16,6 +21,67 @@ const mockProduct = { isSellable: true, }; +type MenuProductSeed = { + id: number; + name: string; + category: string; + variants: [number, string][]; +}; + +const menuProductSeeds: MenuProductSeed[] = [ + { + id: 11, + name: 'Pancong', + category: 'Snacks', + variants: [ + [101, 'Choco'], + [102, 'Matcha'], + [103, 'Vanilla'], + [104, 'Ice Cream'], + ], + }, + { + id: 12, + name: 'Salted Caramel Macchiato', + category: 'Drinks', + variants: [[105, 'Original']], + }, + { + id: 13, + name: 'Coffee Latte', + category: 'Drinks', + variants: [ + [106, 'Hot'], + [107, 'Iced'], + ], + }, +]; + +export const createMenuVariants = (): Variant[] => + menuProductSeeds.flatMap((seed) => + seed.variants.map(([id, name]) => ({ + id, + name, + price: 20000, + materials: [], + product: { + ...mockProduct, + id: seed.id, + name: seed.name, + category: { + ...mockProduct.category, + id: seed.category === 'Snacks' ? 11 : 12, + name: seed.category, + }, + }, + createdAt: '2024-03-20T00:00:00.000Z', + values: [], + pricingTiers: [], + isAvailable: true, + isSellable: true, + })) + ); + const initialVariants: Variant[] = [ { id: 1, diff --git a/libs/ui/src/domain/repositories/tag.ts b/libs/ui/src/domain/repositories/tag.ts index eb91e08c..d5132442 100644 --- a/libs/ui/src/domain/repositories/tag.ts +++ b/libs/ui/src/domain/repositories/tag.ts @@ -10,4 +10,6 @@ export interface TagRepository { createTag: (formValues: TagForm) => Promise; updateTag: (formValues: TagForm, tagId: number) => Promise; + + setTagVariants: (tagId: number, variantIds: number[]) => Promise; } diff --git a/libs/ui/src/domain/usecases/index.ts b/libs/ui/src/domain/usecases/index.ts index ae5b4c43..4953ae80 100644 --- a/libs/ui/src/domain/usecases/index.ts +++ b/libs/ui/src/domain/usecases/index.ts @@ -99,3 +99,4 @@ export * from './tagList'; export * from './tagDelete'; export * from './tagUpdate'; export * from './tagCreate'; +export * from './tagAssignment'; diff --git a/libs/ui/src/domain/usecases/tagAssignment.test.ts b/libs/ui/src/domain/usecases/tagAssignment.test.ts new file mode 100644 index 00000000..0500ccf6 --- /dev/null +++ b/libs/ui/src/domain/usecases/tagAssignment.test.ts @@ -0,0 +1,190 @@ +import { + TagAssignmentUsecase, + TagAssignmentState, + TagAssignmentAction, + TagAssignmentParams, +} from './tagAssignment'; +import { + MockTagRepository, + MockVariantRepository, + createMenuVariants, + mockTags, +} from '../../data/mock'; +import { UsecaseTester, flushPromises } from '../../utils/usecase'; + +const PANCONG_ID = 11; +const SALTED_CARAMEL_ID = 12; +const COFFEE_LATTE_ID = 13; +const PANCONG_ICE_CREAM_ID = 104; +const SALTED_CARAMEL_ORIGINAL_ID = 105; +const COFFEE_LATTE_IDS = [106, 107]; + +const createTester = ( + options: { taggedVariantIds?: number[]; shouldFail?: boolean } = {} +) => { + const tagRepository = new MockTagRepository(); + const variantRepository = new MockVariantRepository(); + variantRepository.variants = createMenuVariants().map((variant) => + (options.taggedVariantIds ?? []).includes(variant.id) + ? { + ...variant, + tags: [{ tag: mockTags[0], taggedAt: '2024-03-22T00:00:00.000Z' }], + } + : variant + ); + if (options.shouldFail) tagRepository.setShouldFail(true); + + const usecase = new TagAssignmentUsecase(tagRepository, variantRepository, { + tagId: mockTags[0].id, + }); + const tester = new UsecaseTester< + TagAssignmentUsecase, + TagAssignmentState, + TagAssignmentAction, + TagAssignmentParams + >(usecase); + return { tester, tagRepository }; +}; + +describe('TagAssignmentUsecase', () => { + describe('loading', () => { + it('should transition loading → loaded with already-tagged variants selected', async () => { + const { tester } = createTester({ + taggedVariantIds: [PANCONG_ICE_CREAM_ID, SALTED_CARAMEL_ORIGINAL_ID], + }); + expect(tester.state.type).toBe('loading'); + + await flushPromises(); + expect(tester.state.type).toBe('loaded'); + expect(tester.state.tag?.id).toBe(mockTags[0].id); + expect(tester.state.variants).toHaveLength(7); + expect(tester.state.selectedVariantIds).toEqual([ + PANCONG_ICE_CREAM_ID, + SALTED_CARAMEL_ORIGINAL_ID, + ]); + }); + + it('should transition loading → error → loading → loaded on retry', async () => { + const { tester, tagRepository } = createTester({ shouldFail: true }); + + await flushPromises(); + expect(tester.state.type).toBe('error'); + expect(tester.state.errorMessage).toBe('Failed to fetch tag assignments'); + + tagRepository.setShouldFail(false); + tester.dispatch({ type: 'FETCH' }); + expect(tester.state.type).toBe('loading'); + + await flushPromises(); + expect(tester.state.type).toBe('loaded'); + }); + }); + + describe('toggling', () => { + it('should select every variant when ticking a product', async () => { + const { tester } = createTester(); + await flushPromises(); + + tester.dispatch({ type: 'TOGGLE_PRODUCT', productId: COFFEE_LATTE_ID }); + expect(tester.state.selectedVariantIds).toEqual(COFFEE_LATTE_IDS); + }); + + it('should clear every variant when unticking a fully selected product', async () => { + const { tester } = createTester({ taggedVariantIds: COFFEE_LATTE_IDS }); + await flushPromises(); + + tester.dispatch({ type: 'TOGGLE_PRODUCT', productId: COFFEE_LATTE_ID }); + expect(tester.state.selectedVariantIds).toEqual([]); + }); + + it('should leave a product partially selected when unticking one of its variants', async () => { + const { tester } = createTester({ taggedVariantIds: COFFEE_LATTE_IDS }); + await flushPromises(); + + tester.dispatch({ + type: 'TOGGLE_VARIANT', + variantId: COFFEE_LATTE_IDS[1], + }); + expect(tester.state.selectedVariantIds).toEqual([COFFEE_LATTE_IDS[0]]); + }); + + it('should select the remaining variants when ticking a partially selected product', async () => { + const { tester } = createTester({ + taggedVariantIds: [PANCONG_ICE_CREAM_ID], + }); + await flushPromises(); + + tester.dispatch({ type: 'TOGGLE_PRODUCT', productId: PANCONG_ID }); + expect([...tester.state.selectedVariantIds].sort()).toEqual([ + 101, 102, 103, 104, + ]); + }); + + it('should toggle a single-variant product through its only variant', async () => { + const { tester } = createTester(); + await flushPromises(); + + tester.dispatch({ type: 'TOGGLE_PRODUCT', productId: SALTED_CARAMEL_ID }); + expect(tester.state.selectedVariantIds).toEqual([ + SALTED_CARAMEL_ORIGINAL_ID, + ]); + + tester.dispatch({ type: 'TOGGLE_PRODUCT', productId: SALTED_CARAMEL_ID }); + expect(tester.state.selectedVariantIds).toEqual([]); + }); + }); + + describe('search', () => { + it('should store the search query without touching the selection', async () => { + const { tester } = createTester({ + taggedVariantIds: [PANCONG_ICE_CREAM_ID], + }); + await flushPromises(); + + tester.dispatch({ type: 'SEARCH', query: 'latte' }); + expect(tester.state.query).toBe('latte'); + expect(tester.state.selectedVariantIds).toEqual([PANCONG_ICE_CREAM_ID]); + }); + }); + + describe('saving', () => { + it('should send exactly the ticked variant ids and reach saveSuccess', async () => { + const { tester, tagRepository } = createTester({ + taggedVariantIds: [PANCONG_ICE_CREAM_ID], + }); + await flushPromises(); + + tester.dispatch({ type: 'TOGGLE_PRODUCT', productId: SALTED_CARAMEL_ID }); + tester.dispatch({ + type: 'TOGGLE_VARIANT', + variantId: PANCONG_ICE_CREAM_ID, + }); + tester.dispatch({ type: 'SAVE' }); + expect(tester.state.type).toBe('saving'); + + await flushPromises(); + expect(tester.state.type).toBe('saveSuccess'); + expect(tagRepository.lastSubmittedVariantIds).toEqual([ + SALTED_CARAMEL_ORIGINAL_ID, + ]); + }); + + it('should reach saveError and allow saving again', async () => { + const { tester, tagRepository } = createTester(); + await flushPromises(); + + tagRepository.setShouldFail(true); + tester.dispatch({ type: 'SAVE' }); + await flushPromises(); + expect(tester.state.type).toBe('saveError'); + expect(tester.state.errorMessage).toBe('Save failed'); + + tagRepository.setShouldFail(false); + tester.dispatch({ type: 'SAVE' }); + expect(tester.state.type).toBe('saving'); + + await flushPromises(); + expect(tester.state.type).toBe('saveSuccess'); + }); + }); +}); diff --git a/libs/ui/src/domain/usecases/tagAssignment.ts b/libs/ui/src/domain/usecases/tagAssignment.ts new file mode 100644 index 00000000..c51b28db --- /dev/null +++ b/libs/ui/src/domain/usecases/tagAssignment.ts @@ -0,0 +1,206 @@ +import { match, P } from 'ts-pattern'; +import { Tag, Variant } from '../entities'; +import { TagRepository, VariantRepository } from '../repositories'; +import { Usecase } from './IUsecase'; + +type Context = { + tag: Tag | null; + variants: Variant[]; + selectedVariantIds: number[]; + query: string; + errorMessage: string | null; +}; + +export type TagAssignmentState = ( + | { type: 'idle' } + | { type: 'loading' } + | { type: 'loaded' } + | { type: 'error' } + | { type: 'saving' } + | { type: 'saveSuccess' } + | { type: 'saveError' } +) & + Context; + +export type TagAssignmentAction = + | { type: 'FETCH' } + | { type: 'FETCH_SUCCESS'; tag: Tag; variants: Variant[] } + | { type: 'FETCH_ERROR'; errorMessage: string } + | { type: 'SEARCH'; query: string } + | { type: 'TOGGLE_VARIANT'; variantId: number } + | { type: 'TOGGLE_PRODUCT'; productId: number } + | { type: 'SAVE' } + | { type: 'SAVE_SUCCESS' } + | { type: 'SAVE_ERROR'; errorMessage: string }; + +export type TagAssignmentParams = { + tagId: number; +}; + +const toggleVariantIds = (selectedVariantIds: number[], variantId: number) => + selectedVariantIds.includes(variantId) + ? selectedVariantIds.filter((id) => id !== variantId) + : [...selectedVariantIds, variantId]; + +const toggleProductVariantIds = ( + state: TagAssignmentState, + productId: number +) => { + const productVariantIds = state.variants + .filter((variant) => variant.product.id === productId) + .map((variant) => variant.id); + const isEveryVariantSelected = + productVariantIds.length > 0 && + productVariantIds.every((id) => state.selectedVariantIds.includes(id)); + const otherVariantIds = state.selectedVariantIds.filter( + (id) => !productVariantIds.includes(id) + ); + return isEveryVariantSelected + ? otherVariantIds + : [...otherVariantIds, ...productVariantIds]; +}; + +export class TagAssignmentUsecase extends Usecase< + TagAssignmentState, + TagAssignmentAction, + TagAssignmentParams +> { + params: TagAssignmentParams; + tagRepository: TagRepository; + variantRepository: VariantRepository; + + constructor( + tagRepository: TagRepository, + variantRepository: VariantRepository, + params: TagAssignmentParams + ) { + super(); + this.tagRepository = tagRepository; + this.variantRepository = variantRepository; + this.params = params; + } + + getInitialState(): TagAssignmentState { + return { + type: 'idle', + tag: null, + variants: [], + selectedVariantIds: [], + query: '', + errorMessage: null, + }; + } + + getNextState( + state: TagAssignmentState, + action: TagAssignmentAction + ): TagAssignmentState { + return match([state, action]) + .returnType() + .with( + [{ type: P.union('idle', 'error') }, { type: 'FETCH' }], + ([state]) => ({ ...state, type: 'loading', errorMessage: null }) + ) + .with( + [{ type: 'loading' }, { type: 'FETCH_SUCCESS' }], + ([state, { tag, variants }]) => ({ + ...state, + type: 'loaded', + tag, + variants, + selectedVariantIds: variants + .filter((variant) => + (variant.tags ?? []).some( + (variantTag) => variantTag.tag.id === tag.id + ) + ) + .map((variant) => variant.id), + }) + ) + .with( + [{ type: 'loading' }, { type: 'FETCH_ERROR' }], + ([state, { errorMessage }]) => ({ + ...state, + type: 'error', + errorMessage, + }) + ) + .with( + [{ type: P.union('loaded', 'saveError') }, { type: 'SEARCH' }], + ([state, { query }]) => ({ ...state, query }) + ) + .with( + [{ type: P.union('loaded', 'saveError') }, { type: 'TOGGLE_VARIANT' }], + ([state, { variantId }]) => ({ + ...state, + selectedVariantIds: toggleVariantIds( + state.selectedVariantIds, + variantId + ), + }) + ) + .with( + [{ type: P.union('loaded', 'saveError') }, { type: 'TOGGLE_PRODUCT' }], + ([state, { productId }]) => ({ + ...state, + selectedVariantIds: toggleProductVariantIds(state, productId), + }) + ) + .with( + [{ type: P.union('loaded', 'saveError') }, { type: 'SAVE' }], + ([state]) => ({ ...state, type: 'saving', errorMessage: null }) + ) + .with([{ type: 'saving' }, { type: 'SAVE_SUCCESS' }], ([state]) => ({ + ...state, + type: 'saveSuccess', + })) + .with( + [{ type: 'saving' }, { type: 'SAVE_ERROR' }], + ([state, { errorMessage }]) => ({ + ...state, + type: 'saveError', + errorMessage, + }) + ) + .otherwise(() => state); + } + + onStateChange( + state: TagAssignmentState, + dispatch: (action: TagAssignmentAction) => void + ): void { + match(state) + .with({ type: 'idle' }, () => dispatch({ type: 'FETCH' })) + .with({ type: 'loading' }, () => { + Promise.all([ + this.tagRepository.fetchTagById(this.params.tagId), + this.variantRepository.fetchVariantList({ + page: 1, + itemPerPage: 1000, + query: '', + sortBy: 'created_at', + orderBy: 'asc', + optionValueIds: [], + }), + ]) + .then(([tag, { variants }]) => + dispatch({ type: 'FETCH_SUCCESS', tag, variants }) + ) + .catch(() => + dispatch({ + type: 'FETCH_ERROR', + errorMessage: 'Failed to fetch tag assignments', + }) + ); + }) + .with({ type: 'saving' }, ({ selectedVariantIds }) => { + this.tagRepository + .setTagVariants(this.params.tagId, selectedVariantIds) + .then(() => dispatch({ type: 'SAVE_SUCCESS' })) + .catch(() => + dispatch({ type: 'SAVE_ERROR', errorMessage: 'Save failed' }) + ); + }) + .otherwise(() => undefined); + } +} diff --git a/libs/ui/src/presentation/handlers/pos/TagAssignmentHandler.test.tsx b/libs/ui/src/presentation/handlers/pos/TagAssignmentHandler.test.tsx new file mode 100644 index 00000000..daca2e7f --- /dev/null +++ b/libs/ui/src/presentation/handlers/pos/TagAssignmentHandler.test.tsx @@ -0,0 +1,240 @@ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TagAssignmentHandler } from './TagAssignmentHandler'; +import { + MockAuthRepository, + MockTagRepository, + MockVariantRepository, + createMenuVariants, + mockTags, +} from '../../../data/mock'; +import { AuthLogoutUsecase, TagAssignmentUsecase } from '../../../domain'; +import { flushPromises } from '../../../utils/testUtils'; + +const mockRouterPush = jest.fn(); +jest.mock('solito/router', () => ({ + useRouter: () => ({ + push: mockRouterPush, + replace: jest.fn(), + back: jest.fn(), + }), +})); + +const mockToastShow = jest.fn(); +jest.mock('@tamagui/toast', () => ({ + useToastController: () => ({ show: mockToastShow }), +})); + +const createProps = ( + options: { taggedVariantIds?: number[]; shouldFail?: boolean } = {} +) => { + const tagRepo = new MockTagRepository(); + const variantRepo = new MockVariantRepository(); + variantRepo.variants = createMenuVariants().map((variant) => + (options.taggedVariantIds ?? []).includes(variant.id) + ? { + ...variant, + tags: [{ tag: mockTags[0], taggedAt: '2024-03-22T00:00:00.000Z' }], + } + : variant + ); + if (options.shouldFail) tagRepo.setShouldFail(true); + + return { + tagRepo, + props: { + authLogoutUsecase: new AuthLogoutUsecase(new MockAuthRepository()), + tagAssignmentUsecase: new TagAssignmentUsecase(tagRepo, variantRepo, { + tagId: mockTags[0].id, + }), + }, + }; +}; + +const renderLoaded = async ( + options: Parameters[0] = {} +) => { + const { tagRepo, props } = createProps(options); + render(); + await act(async () => { + await flushPromises(); + }); + return { tagRepo }; +}; + +const checkbox = (name: string) => screen.getByRole('checkbox', { name }); + +describe('TagAssignmentHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('loading and data states', () => { + it('should show skeleton list during initial loading', async () => { + const { props } = createProps(); + render(); + expect(screen.getByTestId('skeleton-list')).toBeTruthy(); + await act(async () => { + await flushPromises(); + }); + }); + + it('should group products by category', async () => { + await renderLoaded(); + + expect(screen.getByRole('heading', { name: 'Snacks' })).toBeTruthy(); + expect(screen.getByRole('heading', { name: 'Drinks' })).toBeTruthy(); + expect(checkbox('Pancong')).toBeTruthy(); + expect(checkbox('Salted Caramel Macchiato')).toBeTruthy(); + expect(checkbox('Coffee Latte')).toBeTruthy(); + }); + + it('should show an error view and refetch on retry', async () => { + const user = userEvent.setup(); + const { tagRepo } = await renderLoaded({ shouldFail: true }); + + expect( + screen.getByRole('heading', { name: 'Failed to Fetch Products' }) + ).toBeTruthy(); + + tagRepo.setShouldFail(false); + await user.click(screen.getByRole('button', { name: 'Retry' })); + await act(async () => { + await flushPromises(); + }); + + expect(checkbox('Pancong')).toBeTruthy(); + }); + }); + + describe('checkbox states', () => { + it('should render unchecked, checked and indeterminate products', async () => { + await renderLoaded({ taggedVariantIds: [104, 105, 106, 107] }); + + expect(checkbox('Pancong').getAttribute('aria-checked')).toBe('mixed'); + expect( + checkbox('Salted Caramel Macchiato').getAttribute('aria-checked') + ).toBe('true'); + expect(checkbox('Coffee Latte').getAttribute('aria-checked')).toBe( + 'true' + ); + }); + + it('should hide the variant layer of a single-variant product', async () => { + await renderLoaded(); + + expect( + screen.queryByRole('button', { + name: 'Show variants of Salted Caramel Macchiato', + }) + ).toBeNull(); + expect( + screen.queryByRole('checkbox', { + name: 'Salted Caramel Macchiato Original', + }) + ).toBeNull(); + }); + + it('should tick every variant when a product is ticked', async () => { + const user = userEvent.setup(); + await renderLoaded(); + + await user.click(checkbox('Coffee Latte')); + await user.click( + screen.getByRole('button', { name: 'Show variants of Coffee Latte' }) + ); + + expect(checkbox('Coffee Latte').getAttribute('aria-checked')).toBe( + 'true' + ); + expect(checkbox('Coffee Latte Hot').getAttribute('aria-checked')).toBe( + 'true' + ); + expect(checkbox('Coffee Latte Iced').getAttribute('aria-checked')).toBe( + 'true' + ); + }); + + it('should make a product indeterminate when one of its variants is unticked', async () => { + const user = userEvent.setup(); + await renderLoaded({ taggedVariantIds: [106, 107] }); + + await user.click( + screen.getByRole('button', { name: 'Show variants of Coffee Latte' }) + ); + await user.click(checkbox('Coffee Latte Iced')); + + expect(checkbox('Coffee Latte').getAttribute('aria-checked')).toBe( + 'mixed' + ); + expect(checkbox('Coffee Latte Hot').getAttribute('aria-checked')).toBe( + 'true' + ); + expect(checkbox('Coffee Latte Iced').getAttribute('aria-checked')).toBe( + 'false' + ); + }); + }); + + describe('search', () => { + it('should filter products by name', async () => { + const user = userEvent.setup(); + await renderLoaded(); + + await user.type( + screen.getByPlaceholderText('Search Products by Name'), + 'latte' + ); + + expect(checkbox('Coffee Latte')).toBeTruthy(); + expect(screen.queryByRole('checkbox', { name: 'Pancong' })).toBeNull(); + expect(screen.queryByRole('heading', { name: 'Snacks' })).toBeNull(); + }); + + it('should show the empty view when nothing matches', async () => { + const user = userEvent.setup(); + await renderLoaded(); + + await user.type( + screen.getByPlaceholderText('Search Products by Name'), + 'zzz' + ); + + expect( + screen.getByRole('heading', { name: 'Oops, Product is Empty' }) + ).toBeTruthy(); + }); + }); + + describe('saving', () => { + it('should send exactly the ticked variant ids, toast and navigate back to the tag list', async () => { + const user = userEvent.setup(); + const { tagRepo } = await renderLoaded({ taggedVariantIds: [104] }); + + await user.click(checkbox('Salted Caramel Macchiato')); + await user.click(screen.getByRole('button', { name: 'Save' })); + await act(async () => { + await flushPromises(); + }); + + expect(tagRepo.lastSubmittedVariantIds).toEqual([104, 105]); + expect(mockToastShow).toHaveBeenCalledWith('Assign Tag Success'); + expect(mockRouterPush).toHaveBeenCalledWith('/tags'); + }); + + it('should show an error toast and stay on the page when saving fails', async () => { + const user = userEvent.setup(); + const { tagRepo } = await renderLoaded(); + + tagRepo.setShouldFail(true); + await user.click(screen.getByRole('button', { name: 'Save' })); + await act(async () => { + await flushPromises(); + }); + + expect(mockToastShow).toHaveBeenCalledWith('Assign Tag Error'); + expect(mockRouterPush).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/ui/src/presentation/handlers/pos/TagAssignmentHandler.tsx b/libs/ui/src/presentation/handlers/pos/TagAssignmentHandler.tsx new file mode 100644 index 00000000..50c05aee --- /dev/null +++ b/libs/ui/src/presentation/handlers/pos/TagAssignmentHandler.tsx @@ -0,0 +1,136 @@ +import { useRouter } from 'solito/router'; +import { match, P } from 'ts-pattern'; +import { useEffect } from 'react'; +import { useToastController } from '@tamagui/toast'; +import { + AuthLogoutUsecase, + TagAssignmentState, + TagAssignmentUsecase, + Variant, +} from '../../../domain'; +import { useUsecase, useAuthLogout } from '../hooks'; +import { + TagAssignmentScreen, + TagAssignmentScreenProps, +} from '../../views/screens/pos/TagAssignmentScreen'; +import { TagAssignmentCategoryItem } from '../../views/components'; + +export type TagAssignmentHandlerProps = { + authLogoutUsecase: AuthLogoutUsecase; + tagAssignmentUsecase: TagAssignmentUsecase; +}; + +const matchesQuery = (variant: Variant, query: string) => + [variant.product.name, variant.product.category.name, variant.name].some( + (text) => text.toLowerCase().includes(query) + ); + +const toCategoryItems = ({ + variants, + selectedVariantIds, + query, +}: TagAssignmentState): TagAssignmentCategoryItem[] => { + const normalizedQuery = query.trim().toLowerCase(); + const categories = new Map(); + + variants + .filter((variant) => matchesQuery(variant, normalizedQuery)) + .forEach(({ product }) => { + const category = categories.get(product.category.id) ?? { + id: product.category.id, + name: product.category.name, + products: [], + }; + if (!category.products.some(({ id }) => id === product.id)) { + const productVariants = variants.filter( + (variant) => variant.product.id === product.id + ); + const checkedCount = productVariants.filter((variant) => + selectedVariantIds.includes(variant.id) + ).length; + category.products.push({ + id: product.id, + name: product.name, + checkState: + checkedCount === 0 + ? false + : checkedCount === productVariants.length + ? true + : 'indeterminate', + variants: productVariants.map((variant) => ({ + id: variant.id, + name: variant.name, + isChecked: selectedVariantIds.includes(variant.id), + })), + }); + } + categories.set(category.id, category); + }); + + return [...categories.values()]; +}; + +export const TagAssignmentHandler = ({ + authLogoutUsecase, + tagAssignmentUsecase, +}: TagAssignmentHandlerProps) => { + const authLogout = useAuthLogout(authLogoutUsecase); + const tagAssignment = useUsecase(tagAssignmentUsecase); + const router = useRouter(); + const toast = useToastController(); + + useEffect(() => { + if (tagAssignment.state.type === 'saveSuccess') { + toast.show('Assign Tag Success'); + router.push('/tags'); + } else if (tagAssignment.state.type === 'saveError') { + toast.show('Assign Tag Error'); + } + }, [tagAssignment.state.type, toast, router]); + + return ( + authLogout.dispatch({ type: 'LOGOUT' })} + tagName={tagAssignment.state.tag?.name} + searchValue={tagAssignment.state.query} + onSearchValueChange={(query) => + tagAssignment.dispatch({ type: 'SEARCH', query }) + } + onRetryButtonPress={() => tagAssignment.dispatch({ type: 'FETCH' })} + onProductToggle={(productId) => + tagAssignment.dispatch({ type: 'TOGGLE_PRODUCT', productId }) + } + onVariantToggle={(variantId) => + tagAssignment.dispatch({ type: 'TOGGLE_VARIANT', variantId }) + } + selectedVariantCount={tagAssignment.state.selectedVariantIds.length} + onSavePress={() => tagAssignment.dispatch({ type: 'SAVE' })} + isSaveDisabled={ + tagAssignment.state.type !== 'loaded' && + tagAssignment.state.type !== 'saveError' + } + isSaving={tagAssignment.state.type === 'saving'} + serverError={ + tagAssignment.state.type === 'saveError' + ? 'Failed to save. Please try again.' + : undefined + } + variant={match(tagAssignment.state) + .returnType() + .with({ type: P.union('idle', 'loading') }, () => ({ + type: 'loading', + })) + .with({ type: 'error' }, () => ({ type: 'error' })) + .with( + { type: P.union('loaded', 'saving', 'saveSuccess', 'saveError') }, + (state) => { + const categories = toCategoryItems(state); + return categories.length > 0 + ? { type: 'loaded', categories } + : { type: 'empty' }; + } + ) + .exhaustive()} + /> + ); +}; diff --git a/libs/ui/src/presentation/handlers/pos/TagListHandler.test.tsx b/libs/ui/src/presentation/handlers/pos/TagListHandler.test.tsx index 519c996a..724c190f 100644 --- a/libs/ui/src/presentation/handlers/pos/TagListHandler.test.tsx +++ b/libs/ui/src/presentation/handlers/pos/TagListHandler.test.tsx @@ -287,6 +287,22 @@ describe('TagListHandler', () => { expect(mockRouterPush).toHaveBeenCalledWith('/tags/1'); }); + it('should navigate to tag assignment page when assign menu is pressed', async () => { + const user = userEvent.setup(); + render(); + + await act(async () => { + await flushPromises(); + }); + + const assignMenuItems = screen.getAllByRole('button', { + name: 'Assign products', + }); + await user.click(assignMenuItems[0]); + + expect(mockRouterPush).toHaveBeenCalledWith('/tags/1/assign'); + }); + it('should navigate to tag page when item is pressed', async () => { const user = userEvent.setup(); render(); diff --git a/libs/ui/src/presentation/handlers/pos/TagListHandler.tsx b/libs/ui/src/presentation/handlers/pos/TagListHandler.tsx index edc1e4ea..386114ed 100644 --- a/libs/ui/src/presentation/handlers/pos/TagListHandler.tsx +++ b/libs/ui/src/presentation/handlers/pos/TagListHandler.tsx @@ -55,6 +55,9 @@ export const TagListHandler = ({ onEditMenuPress={(tag: Tag) => router.push(`/tags/${tag.id}`) } + onAssignMenuPress={(tag: Tag) => + router.push(`/tags/${tag.id}/assign`) + } onItemPress={(tag: Tag) => router.push(`/tags/${tag.id}`) } diff --git a/libs/ui/src/presentation/handlers/pos/index.ts b/libs/ui/src/presentation/handlers/pos/index.ts index cbb1b850..b79128df 100644 --- a/libs/ui/src/presentation/handlers/pos/index.ts +++ b/libs/ui/src/presentation/handlers/pos/index.ts @@ -60,3 +60,4 @@ export * from './StockCheckCreateHandler'; export * from './StockCheckUpdateHandler'; export * from './PurchaseListHandler'; export * from './AvailabilityHandler'; +export * from './TagAssignmentHandler'; diff --git a/libs/ui/src/presentation/views/components/tags/TagAssignmentList.stories.tsx b/libs/ui/src/presentation/views/components/tags/TagAssignmentList.stories.tsx new file mode 100644 index 00000000..75944f57 --- /dev/null +++ b/libs/ui/src/presentation/views/components/tags/TagAssignmentList.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { TagAssignmentList } from './TagAssignmentList'; + +const meta: Meta = { + title: 'Components/Tags/TagAssignmentList', + component: TagAssignmentList, + args: { + onRetryButtonPress: fn(), + onProductToggle: fn(), + onVariantToggle: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Loaded: Story = { + args: { + variant: { + type: 'loaded', + categories: [ + { + id: 1, + name: 'Drinks', + products: [ + { + id: 12, + name: 'Salted Caramel Macchiato', + checkState: false, + variants: [{ id: 105, name: 'Original', isChecked: false }], + }, + { + id: 13, + name: 'Coffee Latte', + checkState: 'indeterminate', + variants: [ + { id: 106, name: 'Hot', isChecked: true }, + { id: 107, name: 'Iced', isChecked: false }, + ], + }, + ], + }, + ], + }, + }, +}; + +export const Loading: Story = { args: { variant: { type: 'loading' } } }; +export const Empty: Story = { args: { variant: { type: 'empty' } } }; +export const Error: Story = { args: { variant: { type: 'error' } } }; diff --git a/libs/ui/src/presentation/views/components/tags/TagAssignmentList.tsx b/libs/ui/src/presentation/views/components/tags/TagAssignmentList.tsx new file mode 100644 index 00000000..513e9575 --- /dev/null +++ b/libs/ui/src/presentation/views/components/tags/TagAssignmentList.tsx @@ -0,0 +1,187 @@ +import { useState } from 'react'; +import { Check, ChevronDown, ChevronRight, Minus } from '@tamagui/lucide-icons'; +import { + Checkbox, + H5, + Paragraph, + ScrollView, + Text, + XStack, + YStack, +} from 'tamagui'; +import { match } from 'ts-pattern'; +import { EmptyView, ErrorView, SkeletonList } from '../base'; + +export type TagAssignmentCheckState = boolean | 'indeterminate'; + +export type TagAssignmentVariantItem = { + id: number; + name: string; + isChecked: boolean; +}; + +export type TagAssignmentProductItem = { + id: number; + name: string; + checkState: TagAssignmentCheckState; + variants: TagAssignmentVariantItem[]; +}; + +export type TagAssignmentCategoryItem = { + id: number; + name: string; + products: TagAssignmentProductItem[]; +}; + +export type TagAssignmentListProps = { + onRetryButtonPress: () => void; + onProductToggle: (productId: number) => void; + onVariantToggle: (variantId: number) => void; + variant: + | { type: 'loading' } + | { type: 'error' } + | { type: 'empty' } + | { type: 'loaded'; categories: TagAssignmentCategoryItem[] }; +}; + +type TagAssignmentCheckboxProps = { + label: string; + checked: TagAssignmentCheckState; + onToggle: () => void; +}; + +const TagAssignmentCheckbox = ({ + label, + checked, + onToggle, +}: TagAssignmentCheckboxProps) => ( + + + {checked === 'indeterminate' ? : } + + +); + +type TagAssignmentProductRowProps = { + product: TagAssignmentProductItem; + onProductToggle: (productId: number) => void; + onVariantToggle: (variantId: number) => void; +}; + +const TagAssignmentProductRow = ({ + product, + onProductToggle, + onVariantToggle, +}: TagAssignmentProductRowProps) => { + const [isExpanded, setIsExpanded] = useState(false); + const hasVariantLayer = product.variants.length > 1; + const checkedVariantCount = product.variants.filter( + (variant) => variant.isChecked + ).length; + + return ( + + + onProductToggle(product.id)} + /> + + {product.name} + + {hasVariantLayer && ( + setIsExpanded(!isExpanded)} + accessibilityRole="button" + accessibilityLabel={`${isExpanded ? 'Hide' : 'Show'} variants of ${ + product.name + }`} + > + + {`${checkedVariantCount}/${product.variants.length} variants`} + + {isExpanded ? ( + + ) : ( + + )} + + )} + + {hasVariantLayer && isExpanded && ( + + {product.variants.map((variant) => ( + + onVariantToggle(variant.id)} + /> + {variant.name} + + ))} + + )} + + ); +}; + +export const TagAssignmentList = ({ + onRetryButtonPress, + onProductToggle, + onVariantToggle, + variant, +}: TagAssignmentListProps) => { + return ( + + {match(variant) + .with({ type: 'loading' }, () => ) + .with({ type: 'empty' }, () => ( + + )) + .with({ type: 'loaded' }, ({ categories }) => ( + + + {categories.map((category) => ( + +
{category.name}
+ {category.products.map((product) => ( + + ))} +
+ ))} +
+
+ )) + .with({ type: 'error' }, () => ( + + )) + .exhaustive()} +
+ ); +}; diff --git a/libs/ui/src/presentation/views/components/tags/TagList.stories.tsx b/libs/ui/src/presentation/views/components/tags/TagList.stories.tsx index 5b7fd798..e004b77c 100644 --- a/libs/ui/src/presentation/views/components/tags/TagList.stories.tsx +++ b/libs/ui/src/presentation/views/components/tags/TagList.stories.tsx @@ -7,6 +7,7 @@ const defaultArgs = { onRetryButtonPress: fn(), onDeleteMenuPress: fn(), onEditMenuPress: fn(), + onAssignMenuPress: fn(), onItemPress: fn(), }; diff --git a/libs/ui/src/presentation/views/components/tags/TagList.tsx b/libs/ui/src/presentation/views/components/tags/TagList.tsx index 3867af66..4b0df871 100644 --- a/libs/ui/src/presentation/views/components/tags/TagList.tsx +++ b/libs/ui/src/presentation/views/components/tags/TagList.tsx @@ -10,6 +10,7 @@ export type TagListProps = { onEmptyActionPress?: () => void; onDeleteMenuPress: (tag: Tag) => void; onEditMenuPress: (tag: Tag) => void; + onAssignMenuPress: (tag: Tag) => void; onItemPress: (tag: Tag) => void; isRevalidating?: boolean; variant: @@ -24,6 +25,7 @@ export const TagList = ({ onEmptyActionPress, onDeleteMenuPress, onEditMenuPress, + onAssignMenuPress, onItemPress, isRevalidating, variant, @@ -53,6 +55,7 @@ export const TagList = ({ variantCount={item.variantCount} onDeleteMenuPress={() => onDeleteMenuPress(item)} onEditMenuPress={() => onEditMenuPress(item)} + onAssignMenuPress={() => onAssignMenuPress(item)} onPress={() => onItemPress(item)} /> )} diff --git a/libs/ui/src/presentation/views/components/tags/TagListItem.stories.tsx b/libs/ui/src/presentation/views/components/tags/TagListItem.stories.tsx index 2c7a014b..f551fc59 100644 --- a/libs/ui/src/presentation/views/components/tags/TagListItem.stories.tsx +++ b/libs/ui/src/presentation/views/components/tags/TagListItem.stories.tsx @@ -11,6 +11,7 @@ const meta: Meta = { isHighlighted: true, variantCount: 5, onEditMenuPress: fn(), + onAssignMenuPress: fn(), onDeleteMenuPress: fn(), }, }; diff --git a/libs/ui/src/presentation/views/components/tags/TagListItem.tsx b/libs/ui/src/presentation/views/components/tags/TagListItem.tsx index 01c70df5..e267a4ac 100644 --- a/libs/ui/src/presentation/views/components/tags/TagListItem.tsx +++ b/libs/ui/src/presentation/views/components/tags/TagListItem.tsx @@ -1,4 +1,4 @@ -import { Pencil, Trash } from '@tamagui/lucide-icons'; +import { ListChecks, Pencil, Trash } from '@tamagui/lucide-icons'; import { XStackProps } from 'tamagui'; import { ListItem } from '../base'; import { TagColor } from '../../../../domain'; @@ -9,6 +9,7 @@ export type TagListItemProps = { color: TagColor; isHighlighted: boolean; variantCount: number; + onAssignMenuPress: () => void; onEditMenuPress: () => void; onDeleteMenuPress: () => void; } & XStackProps; @@ -20,6 +21,7 @@ export const TagListItem = ({ variantCount, onDeleteMenuPress, onEditMenuPress, + onAssignMenuPress, ...xStackProps }: TagListItemProps) => { return ( @@ -36,6 +38,11 @@ export const TagListItem = ({ }, ]} menus={[ + { + title: 'Assign products', + icon: ListChecks, + onPress: onAssignMenuPress, + }, { title: 'Edit', icon: Pencil, onPress: onEditMenuPress }, { title: 'Delete', icon: Trash, onPress: onDeleteMenuPress }, ]} diff --git a/libs/ui/src/presentation/views/components/tags/index.ts b/libs/ui/src/presentation/views/components/tags/index.ts index 17ff2680..3efa6e3f 100644 --- a/libs/ui/src/presentation/views/components/tags/index.ts +++ b/libs/ui/src/presentation/views/components/tags/index.ts @@ -5,3 +5,4 @@ export * from './TagFormView'; export * from './TagList'; export * from './TagListItem'; export * from './tagColors'; +export * from './TagAssignmentList'; diff --git a/libs/ui/src/presentation/views/screens/pos/TagAssignmentScreen.stories.tsx b/libs/ui/src/presentation/views/screens/pos/TagAssignmentScreen.stories.tsx new file mode 100644 index 00000000..a18c3711 --- /dev/null +++ b/libs/ui/src/presentation/views/screens/pos/TagAssignmentScreen.stories.tsx @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { TagAssignmentScreen } from './TagAssignmentScreen'; + +const defaultArgs = { + onLogoutPress: fn(), + tagName: 'New', + searchValue: '', + onSearchValueChange: fn(), + onRetryButtonPress: fn(), + onProductToggle: fn(), + onVariantToggle: fn(), + selectedVariantCount: 3, + onSavePress: fn(), + isSaveDisabled: false, + isSaving: false, +}; + +const meta: Meta = { + title: 'Screens/POS/TagAssignmentScreen', + component: TagAssignmentScreen, + parameters: { layout: 'fullscreen' }, + args: defaultArgs, +}; + +export default meta; +type Story = StoryObj; + +export const Loaded: Story = { + args: { + variant: { + type: 'loaded', + categories: [ + { + id: 1, + name: 'Snacks', + products: [ + { + id: 11, + name: 'Pancong', + checkState: 'indeterminate', + variants: [ + { id: 101, name: 'Choco', isChecked: false }, + { id: 102, name: 'Matcha', isChecked: false }, + { id: 103, name: 'Vanilla', isChecked: false }, + { id: 104, name: 'Ice Cream', isChecked: true }, + ], + }, + ], + }, + { + id: 2, + name: 'Drinks', + products: [ + { + id: 12, + name: 'Salted Caramel Macchiato', + checkState: true, + variants: [{ id: 105, name: 'Original', isChecked: true }], + }, + { + id: 13, + name: 'Coffee Latte', + checkState: true, + variants: [ + { id: 106, name: 'Hot', isChecked: true }, + { id: 107, name: 'Iced', isChecked: true }, + ], + }, + ], + }, + ], + }, + }, +}; + +export const Loading: Story = { + args: { variant: { type: 'loading' }, isSaveDisabled: true }, +}; + +export const Empty: Story = { + args: { variant: { type: 'empty' }, searchValue: 'zzz' }, +}; + +export const Error: Story = { + args: { variant: { type: 'error' }, isSaveDisabled: true }, +}; + +export const Saving: Story = { + args: { ...Loaded.args, isSaving: true, isSaveDisabled: true }, +}; + +export const SaveFailed: Story = { + args: { ...Loaded.args, serverError: 'Failed to save. Please try again.' }, +}; diff --git a/libs/ui/src/presentation/views/screens/pos/TagAssignmentScreen.tsx b/libs/ui/src/presentation/views/screens/pos/TagAssignmentScreen.tsx new file mode 100644 index 00000000..b0b89868 --- /dev/null +++ b/libs/ui/src/presentation/views/screens/pos/TagAssignmentScreen.tsx @@ -0,0 +1,85 @@ +import { Button, Input, Paragraph, Spinner, XStack, YStack } from 'tamagui'; +import { X } from '@tamagui/lucide-icons'; +import { + FormErrorBanner, + Layout, + TagAssignmentList, + TagAssignmentListProps, +} from '../../components'; + +export type TagAssignmentScreenProps = { + onLogoutPress: () => void; + tagName?: string; + searchValue: string; + onSearchValueChange: (value: string) => void; + variant: TagAssignmentListProps['variant']; + onRetryButtonPress: () => void; + onProductToggle: (productId: number) => void; + onVariantToggle: (variantId: number) => void; + selectedVariantCount: number; + onSavePress: () => void; + isSaveDisabled: boolean; + isSaving: boolean; + serverError?: string; +}; + +export const TagAssignmentScreen = ({ + onLogoutPress, + tagName, + searchValue, + onSearchValueChange, + variant, + onRetryButtonPress, + onProductToggle, + onVariantToggle, + selectedVariantCount, + onSavePress, + isSaveDisabled, + isSaving, + serverError, +}: TagAssignmentScreenProps) => { + return ( + + + + + + + + + ); +}; diff --git a/libs/ui/src/presentation/views/screens/pos/TagListScreen.stories.tsx b/libs/ui/src/presentation/views/screens/pos/TagListScreen.stories.tsx index 86731983..6fd22438 100644 --- a/libs/ui/src/presentation/views/screens/pos/TagListScreen.stories.tsx +++ b/libs/ui/src/presentation/views/screens/pos/TagListScreen.stories.tsx @@ -6,6 +6,7 @@ import { mockTags } from '../../../../../.storybook/mocks/mockData'; const defaultArgs = { onLogoutPress: fn(), onEditMenuPress: fn(), + onAssignMenuPress: fn(), onDeleteMenuPress: fn(), onItemPress: fn(), onRetryButtonPress: fn(), diff --git a/libs/ui/src/presentation/views/screens/pos/TagListScreen.tsx b/libs/ui/src/presentation/views/screens/pos/TagListScreen.tsx index 2a219ad7..2d01d2c7 100644 --- a/libs/ui/src/presentation/views/screens/pos/TagListScreen.tsx +++ b/libs/ui/src/presentation/views/screens/pos/TagListScreen.tsx @@ -12,6 +12,7 @@ import { Tag } from '../../../../domain'; export type TagListScreenProps = { onLogoutPress: () => void; onEditMenuPress: (tag: Tag) => void; + onAssignMenuPress: (tag: Tag) => void; onDeleteMenuPress: (tag: Tag) => void; onItemPress: (tag: Tag) => void; onRetryButtonPress: () => void; @@ -28,6 +29,7 @@ export type TagListScreenProps = { export const TagListScreen = ({ onLogoutPress, onEditMenuPress, + onAssignMenuPress, onDeleteMenuPress, onItemPress, onRetryButtonPress, @@ -55,6 +57,7 @@ export const TagListScreen = ({ variant={variant} isRevalidating={isRevalidating} onEditMenuPress={onEditMenuPress} + onAssignMenuPress={onAssignMenuPress} onDeleteMenuPress={onDeleteMenuPress} onItemPress={onItemPress} onEmptyActionPress={onEmptyActionPress} diff --git a/libs/ui/src/presentation/views/screens/pos/index.ts b/libs/ui/src/presentation/views/screens/pos/index.ts index fe4c611d..efec909b 100644 --- a/libs/ui/src/presentation/views/screens/pos/index.ts +++ b/libs/ui/src/presentation/views/screens/pos/index.ts @@ -60,3 +60,4 @@ export * from './StockCheckCreateScreen'; export * from './StockCheckUpdateScreen'; export * from './PurchaseListScreen'; export * from './AvailabilityScreen'; +export * from './TagAssignmentScreen'; From ffcccd38cd487f122dff247177895a78c2fb7331 Mon Sep 17 00:00:00 2001 From: M Nindra Zaka Date: Fri, 2 Oct 2026 06:34:34 +0700 Subject: [PATCH 10/15] feat(ui): add product/variant tags, TagBadge and buildTagHighlights (#674) Claude-Session: https://claude.ai/code/session_019gSoWCarkoZ2wv1oRZCgex Co-authored-by: Claude --- libs/ui/.storybook/mocks/mockData.ts | 6 + libs/ui/src/data/api/product.transformer.ts | 2 + libs/ui/src/data/api/tag.transformer.test.ts | 75 ++++++ libs/ui/src/data/api/tag.transformer.ts | 16 +- .../src/data/api/transaction.transformer.ts | 6 + libs/ui/src/data/api/variant.transformer.ts | 3 +- libs/ui/src/data/mock/cart.ts | 3 + libs/ui/src/data/mock/menu.ts | 5 + libs/ui/src/data/mock/product.ts | 3 + libs/ui/src/data/mock/rental.ts | 2 + libs/ui/src/data/mock/transaction.ts | 2 + libs/ui/src/data/mock/variant.ts | 3 + libs/ui/src/domain/entities/Product.ts | 2 +- libs/ui/src/domain/entities/Variant.ts | 2 +- .../domain/usecases/menuItemDetail.test.ts | 2 + .../components/cart/CartLineItem.stories.tsx | 2 + .../menu/MenuProductCard.stories.tsx | 6 + .../components/tags/TagBadge.stories.tsx | 38 +++ .../views/components/tags/TagBadge.tsx | 22 ++ .../views/components/tags/index.ts | 1 + .../TransactionVerificationSheet.stories.tsx | 2 + .../order/CartItemEditScreen.stories.tsx | 2 + .../screens/order/CartScreen.stories.tsx | 2 + .../order/MenuItemDetailScreen.stories.tsx | 3 + .../screens/order/MenuListScreen.stories.tsx | 2 + libs/ui/src/utils/buildTagHighlights.test.ts | 242 ++++++++++++++++++ libs/ui/src/utils/buildTagHighlights.ts | 84 ++++++ libs/ui/src/utils/index.ts | 1 + libs/ui/src/utils/matchMenuSearch.test.ts | 3 + .../resolveOptionValueAvailability.test.ts | 2 + 30 files changed, 539 insertions(+), 5 deletions(-) create mode 100644 libs/ui/src/data/api/tag.transformer.test.ts create mode 100644 libs/ui/src/presentation/views/components/tags/TagBadge.stories.tsx create mode 100644 libs/ui/src/presentation/views/components/tags/TagBadge.tsx create mode 100644 libs/ui/src/utils/buildTagHighlights.test.ts create mode 100644 libs/ui/src/utils/buildTagHighlights.ts diff --git a/libs/ui/.storybook/mocks/mockData.ts b/libs/ui/.storybook/mocks/mockData.ts index e4d69fe5..9e491b0e 100644 --- a/libs/ui/.storybook/mocks/mockData.ts +++ b/libs/ui/.storybook/mocks/mockData.ts @@ -143,6 +143,7 @@ export const mockProduct: Product = { isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }; export const mockProducts: Product[] = [ @@ -160,6 +161,7 @@ export const mockProducts: Product[] = [ isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }, { id: 3, @@ -174,6 +176,7 @@ export const mockProducts: Product[] = [ isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }, ]; @@ -208,6 +211,7 @@ export const mockVariant: Variant = { pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }; export const mockVariants: Variant[] = [ @@ -227,6 +231,7 @@ export const mockVariants: Variant[] = [ pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }, ]; @@ -527,6 +532,7 @@ const mockRentalVariant: Variant = { ], isAvailable: true, isSellable: true, + tags: [], }; export const mockRental: Rental = { diff --git a/libs/ui/src/data/api/product.transformer.ts b/libs/ui/src/data/api/product.transformer.ts index 4ed7c3ca..0a0519f4 100644 --- a/libs/ui/src/data/api/product.transformer.ts +++ b/libs/ui/src/data/api/product.transformer.ts @@ -2,6 +2,7 @@ import { Product as ApiProduct } from '../../../../api-contract/src'; import { Product, ProductForm } from '../../domain'; import { toCategory } from './category.transformer'; +import { toProductTag } from './tag.transformer'; export function toProduct(product: ApiProduct): Product { return { @@ -20,6 +21,7 @@ export function toProduct(product: ApiProduct): Product { availableQuantity: product.availableQuantity, isSellable: product.isSellable, sellableQuantity: product.sellableQuantity, + tags: product.tags.map(toProductTag), }; } diff --git a/libs/ui/src/data/api/tag.transformer.test.ts b/libs/ui/src/data/api/tag.transformer.test.ts new file mode 100644 index 00000000..73da1865 --- /dev/null +++ b/libs/ui/src/data/api/tag.transformer.test.ts @@ -0,0 +1,75 @@ +import { toProduct } from './product.transformer'; +import { toVariant } from './variant.transformer'; + +const apiTag = { + id: 1, + name: 'New', + color: 'green' as const, + isHighlighted: true, + sortOrder: 1, + variantCount: 1, + createdAt: '2024-03-20T00:00:00.000Z', +}; + +const apiProduct = { + id: 1, + name: 'Pancong', + categoryId: 1, + category: { + id: 1, + name: 'Menu', + station: 'KITCHEN' as const, + createdAt: '2024-03-20T00:00:00.000Z', + }, + imageUrl: 'https://example.com/pancong.jpg', + createdAt: '2024-03-20T00:00:00.000Z', + options: [], + saleType: 'purchase' as const, + status: 'published' as const, + isAvailable: true, + availabilityTracking: 'none' as const, + isSellable: true, + tags: [ + { + tag: apiTag, + scope: 'variant' as const, + variantIds: [14], + taggedAt: '2024-03-01T00:00:00.000Z', + }, + ], +}; + +describe('tag transformers', () => { + it('maps product tags', () => { + expect(toProduct(apiProduct).tags).toEqual([ + { + tag: apiTag, + scope: 'variant', + variantIds: [14], + taggedAt: '2024-03-01T00:00:00.000Z', + }, + ]); + }); + + it('maps variant tags and the nested product tags', () => { + const variant = toVariant({ + id: 14, + name: 'Ice Cream', + price: 15000, + productId: 1, + product: apiProduct, + createdAt: '2024-03-20T00:00:00.000Z', + materials: [], + values: [], + pricingTiers: [], + isAvailable: true, + isSellable: true, + tags: [{ tag: apiTag, taggedAt: '2024-03-01T00:00:00.000Z' }], + } as unknown as Parameters[0]); + + expect(variant.tags).toEqual([ + { tag: apiTag, taggedAt: '2024-03-01T00:00:00.000Z' }, + ]); + expect(variant.product.tags).toHaveLength(1); + }); +}); diff --git a/libs/ui/src/data/api/tag.transformer.ts b/libs/ui/src/data/api/tag.transformer.ts index 96a85993..32551bc5 100644 --- a/libs/ui/src/data/api/tag.transformer.ts +++ b/libs/ui/src/data/api/tag.transformer.ts @@ -1,6 +1,9 @@ // eslint-disable-next-line @nx/enforce-module-boundaries -import { Tag as ApiTag } from '../../../../api-contract/src'; -import { Tag, TagForm } from '../../domain'; +import { + Tag as ApiTag, + ProductTag as ApiProductTag, +} from '../../../../api-contract/src'; +import { ProductTag, Tag, TagForm } from '../../domain'; export function toTag(tag: ApiTag): Tag { return { @@ -14,6 +17,15 @@ export function toTag(tag: ApiTag): Tag { }; } +export function toProductTag(productTag: ApiProductTag): ProductTag { + return { + tag: toTag(productTag.tag), + scope: productTag.scope, + variantIds: productTag.variantIds, + taggedAt: productTag.taggedAt, + }; +} + export function toApiTag(form: TagForm) { return { name: form.name, diff --git a/libs/ui/src/data/api/transaction.transformer.ts b/libs/ui/src/data/api/transaction.transformer.ts index 6dc9f49b..afe61d4f 100644 --- a/libs/ui/src/data/api/transaction.transformer.ts +++ b/libs/ui/src/data/api/transaction.transformer.ts @@ -10,6 +10,7 @@ import { TransactionStatistic, TransactionVerification, } from '../../domain'; +import { toProductTag, toTag } from './tag.transformer'; import { toWallet } from './wallet.transformer'; export function toTransactionStatistic( @@ -85,6 +86,7 @@ export function toTransaction(transaction: ApiTransaction): Transaction { availableQuantity: item.variant.product.availableQuantity, isSellable: item.variant.product.isSellable, sellableQuantity: item.variant.product.sellableQuantity, + tags: item.variant.product.tags.map(toProductTag), }, values: item.variant.values.map((value) => ({ id: value.id, @@ -105,6 +107,10 @@ export function toTransaction(transaction: ApiTransaction): Transaction { availableQuantity: item.variant.availableQuantity, isSellable: item.variant.isSellable, sellableQuantity: item.variant.sellableQuantity, + tags: item.variant.tags.map(({ tag, taggedAt }) => ({ + tag: toTag(tag), + taggedAt, + })), }, })), transactionCoupons: transaction.transactionCoupons.map((item) => ({ diff --git a/libs/ui/src/data/api/variant.transformer.ts b/libs/ui/src/data/api/variant.transformer.ts index d3494647..360edb74 100644 --- a/libs/ui/src/data/api/variant.transformer.ts +++ b/libs/ui/src/data/api/variant.transformer.ts @@ -2,7 +2,7 @@ import { Variant as ApiVariant } from '../../../../api-contract/src'; import { Variant, VariantForm } from '../../domain'; import { toMaterial } from './material.transformer'; -import { toTag } from './tag.transformer'; +import { toProductTag, toTag } from './tag.transformer'; export function toVariant(variant: ApiVariant): Variant { return { @@ -39,6 +39,7 @@ export function toVariant(variant: ApiVariant): Variant { availabilityTracking: variant.product.availabilityTracking, availableQuantity: variant.product.availableQuantity, isSellable: variant.product.isSellable, + tags: variant.product.tags.map(toProductTag), sellableQuantity: variant.product.sellableQuantity, }, values: variant.values.map((value) => ({ diff --git a/libs/ui/src/data/mock/cart.ts b/libs/ui/src/data/mock/cart.ts index 4e07b989..b35cd32c 100644 --- a/libs/ui/src/data/mock/cart.ts +++ b/libs/ui/src/data/mock/cart.ts @@ -35,6 +35,7 @@ const product: Product = { isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }; const variants: Record = { @@ -56,6 +57,7 @@ const variants: Record = { pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }, 2: { id: 2, @@ -75,6 +77,7 @@ const variants: Record = { pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }, }; diff --git a/libs/ui/src/data/mock/menu.ts b/libs/ui/src/data/mock/menu.ts index 6e34fb05..34331a18 100644 --- a/libs/ui/src/data/mock/menu.ts +++ b/libs/ui/src/data/mock/menu.ts @@ -40,6 +40,7 @@ const initialProducts: Product[] = [ isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }, { id: 2, @@ -54,6 +55,7 @@ const initialProducts: Product[] = [ isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }, ]; @@ -76,6 +78,7 @@ const initialVariants: Variant[] = [ pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }, { id: 2, @@ -95,6 +98,7 @@ const initialVariants: Variant[] = [ pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }, { id: 3, @@ -107,6 +111,7 @@ const initialVariants: Variant[] = [ pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }, ]; diff --git a/libs/ui/src/data/mock/product.ts b/libs/ui/src/data/mock/product.ts index 7a33a4f1..f06f95bd 100644 --- a/libs/ui/src/data/mock/product.ts +++ b/libs/ui/src/data/mock/product.ts @@ -23,6 +23,7 @@ const initialProducts: Product[] = [ isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }, { id: 2, @@ -45,6 +46,7 @@ const initialProducts: Product[] = [ isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }, ]; @@ -114,6 +116,7 @@ export class MockProductRepository implements ProductRepository { isAvailable: true, availabilityTracking: formValues.availabilityTracking, isSellable: true, + tags: [], }); } diff --git a/libs/ui/src/data/mock/rental.ts b/libs/ui/src/data/mock/rental.ts index d7061e52..e5c3825c 100644 --- a/libs/ui/src/data/mock/rental.ts +++ b/libs/ui/src/data/mock/rental.ts @@ -23,12 +23,14 @@ const mockVariant = { isAvailable: true, availabilityTracking: 'none' as const, isSellable: true, + tags: [], }, createdAt: '2024-03-20T00:00:00.000Z', values: [], pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }; const initialRentals: Rental[] = [ diff --git a/libs/ui/src/data/mock/transaction.ts b/libs/ui/src/data/mock/transaction.ts index 5e70feee..4ccaef62 100644 --- a/libs/ui/src/data/mock/transaction.ts +++ b/libs/ui/src/data/mock/transaction.ts @@ -29,12 +29,14 @@ const mockVariant = { isAvailable: true, availabilityTracking: 'none' as const, isSellable: true, + tags: [], }, createdAt: '2024-03-20T00:00:00.000Z', values: [], pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }; const initialTransactions: Transaction[] = [ diff --git a/libs/ui/src/data/mock/variant.ts b/libs/ui/src/data/mock/variant.ts index 73639a75..0a3bfa05 100644 --- a/libs/ui/src/data/mock/variant.ts +++ b/libs/ui/src/data/mock/variant.ts @@ -19,6 +19,7 @@ const mockProduct = { isAvailable: true, availabilityTracking: 'none' as const, isSellable: true, + tags: [], }; type MenuProductSeed = { @@ -94,6 +95,7 @@ const initialVariants: Variant[] = [ pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }, { id: 2, @@ -106,6 +108,7 @@ const initialVariants: Variant[] = [ pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }, ]; diff --git a/libs/ui/src/domain/entities/Product.ts b/libs/ui/src/domain/entities/Product.ts index 79797109..15b83315 100644 --- a/libs/ui/src/domain/entities/Product.ts +++ b/libs/ui/src/domain/entities/Product.ts @@ -24,7 +24,7 @@ export type Product = { availableQuantity?: number; isSellable: boolean; sellableQuantity?: number; - tags?: ProductTag[]; + tags: ProductTag[]; }; export type Option = { diff --git a/libs/ui/src/domain/entities/Variant.ts b/libs/ui/src/domain/entities/Variant.ts index 26fb765e..b1d0f3ac 100644 --- a/libs/ui/src/domain/entities/Variant.ts +++ b/libs/ui/src/domain/entities/Variant.ts @@ -29,7 +29,7 @@ export type Variant = { availableQuantity?: number; isSellable: boolean; sellableQuantity?: number; - tags?: VariantTag[]; + tags: VariantTag[]; }; export type VariantValue = { diff --git a/libs/ui/src/domain/usecases/menuItemDetail.test.ts b/libs/ui/src/domain/usecases/menuItemDetail.test.ts index d6534f61..dc6fcbfc 100644 --- a/libs/ui/src/domain/usecases/menuItemDetail.test.ts +++ b/libs/ui/src/domain/usecases/menuItemDetail.test.ts @@ -36,6 +36,7 @@ function addSingleOptionProduct(repository: MockMenuRepository): { isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }; const variant: Variant = { id: 4, @@ -55,6 +56,7 @@ function addSingleOptionProduct(repository: MockMenuRepository): { pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }; repository.products = [...repository.products, product]; repository.variants = [...repository.variants, variant]; diff --git a/libs/ui/src/presentation/views/components/cart/CartLineItem.stories.tsx b/libs/ui/src/presentation/views/components/cart/CartLineItem.stories.tsx index 72daeddf..533bcb5f 100644 --- a/libs/ui/src/presentation/views/components/cart/CartLineItem.stories.tsx +++ b/libs/ui/src/presentation/views/components/cart/CartLineItem.stories.tsx @@ -21,6 +21,7 @@ const esKopiSusu = { isAvailable: true, availabilityTracking: 'none' as const, isSellable: true, + tags: [], }; const variant = { @@ -41,6 +42,7 @@ const variant = { pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }; const item = { diff --git a/libs/ui/src/presentation/views/components/menu/MenuProductCard.stories.tsx b/libs/ui/src/presentation/views/components/menu/MenuProductCard.stories.tsx index 2782194c..b454388c 100644 --- a/libs/ui/src/presentation/views/components/menu/MenuProductCard.stories.tsx +++ b/libs/ui/src/presentation/views/components/menu/MenuProductCard.stories.tsx @@ -36,6 +36,7 @@ export const WithDescriptionAndPrice: Story = { isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }, startingPrice: 18000, }, @@ -56,6 +57,7 @@ export const NoDescription: Story = { isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }, startingPrice: 25000, }, @@ -76,6 +78,7 @@ export const NoPrice: Story = { isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }, startingPrice: null, }, @@ -96,6 +99,7 @@ export const WithImage: Story = { isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }, startingPrice: 18000, }, @@ -125,6 +129,7 @@ export const WithMatchedLabels: Story = { isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }, startingPrice: 10000, matchedLabels: ['Earl Grey'], @@ -147,6 +152,7 @@ export const SoldOut: Story = { availabilityTracking: 'product', availableQuantity: 0, isSellable: false, + tags: [], }, startingPrice: 8000, }, diff --git a/libs/ui/src/presentation/views/components/tags/TagBadge.stories.tsx b/libs/ui/src/presentation/views/components/tags/TagBadge.stories.tsx new file mode 100644 index 00000000..f4c7b39b --- /dev/null +++ b/libs/ui/src/presentation/views/components/tags/TagBadge.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Theme, XStack, YStack } from 'tamagui'; +import { TagBadge } from './TagBadge'; +import { TagColor, tagColors } from '../../../../domain'; + +const meta: Meta = { + title: 'Components/Tags/TagBadge', + component: TagBadge, + args: { label: 'Best Seller', color: 'orange' }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const VariantScope: Story = { + args: { label: 'New · Ice Cream', color: 'green' }, +}; + +const ColorRow = ({ themeName }: { themeName: 'light' | 'dark' }) => ( + + + {tagColors.map((color: TagColor) => ( + + ))} + + +); + +export const AllColorsInBothThemes: Story = { + render: () => ( + + + + + ), +}; diff --git a/libs/ui/src/presentation/views/components/tags/TagBadge.tsx b/libs/ui/src/presentation/views/components/tags/TagBadge.tsx new file mode 100644 index 00000000..51c53fa2 --- /dev/null +++ b/libs/ui/src/presentation/views/components/tags/TagBadge.tsx @@ -0,0 +1,22 @@ +import { SizableText, XStack } from 'tamagui'; +import { TagColor } from '../../../../domain'; +import { tagColorBackground, tagColorForeground } from './tagColors'; + +export type TagBadgeProps = { + label: string; + color: TagColor; +}; + +export const TagBadge = ({ label, color }: TagBadgeProps) => ( + + + {label} + + +); diff --git a/libs/ui/src/presentation/views/components/tags/index.ts b/libs/ui/src/presentation/views/components/tags/index.ts index 3efa6e3f..831032d8 100644 --- a/libs/ui/src/presentation/views/components/tags/index.ts +++ b/libs/ui/src/presentation/views/components/tags/index.ts @@ -1,3 +1,4 @@ +export * from './TagBadge'; export * from './TagColorPicker'; export * from './TagColorPill'; export * from './TagDeleteAlert'; diff --git a/libs/ui/src/presentation/views/components/transactions/TransactionVerificationSheet.stories.tsx b/libs/ui/src/presentation/views/components/transactions/TransactionVerificationSheet.stories.tsx index e1ce47d5..83e67780 100644 --- a/libs/ui/src/presentation/views/components/transactions/TransactionVerificationSheet.stories.tsx +++ b/libs/ui/src/presentation/views/components/transactions/TransactionVerificationSheet.stories.tsx @@ -25,12 +25,14 @@ const mockVariant = { isAvailable: true, availabilityTracking: 'none' as const, isSellable: true, + tags: [], }, createdAt: '2024-01-20T10:00:00.000Z', values: [], pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }; const mockTransaction: Transaction = { diff --git a/libs/ui/src/presentation/views/screens/order/CartItemEditScreen.stories.tsx b/libs/ui/src/presentation/views/screens/order/CartItemEditScreen.stories.tsx index 17c28d6c..c88a7f69 100644 --- a/libs/ui/src/presentation/views/screens/order/CartItemEditScreen.stories.tsx +++ b/libs/ui/src/presentation/views/screens/order/CartItemEditScreen.stories.tsx @@ -21,6 +21,7 @@ const esKopiSusu = { isAvailable: true, availabilityTracking: 'none' as const, isSellable: true, + tags: [], }; const variant = { @@ -47,6 +48,7 @@ const variant = { pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }; const item = { diff --git a/libs/ui/src/presentation/views/screens/order/CartScreen.stories.tsx b/libs/ui/src/presentation/views/screens/order/CartScreen.stories.tsx index c94f1a6e..d5ac7065 100644 --- a/libs/ui/src/presentation/views/screens/order/CartScreen.stories.tsx +++ b/libs/ui/src/presentation/views/screens/order/CartScreen.stories.tsx @@ -21,6 +21,7 @@ const esKopiSusu = { isAvailable: true, availabilityTracking: 'none' as const, isSellable: true, + tags: [], }; const variant = { @@ -41,6 +42,7 @@ const variant = { pricingTiers: [], isAvailable: true, isSellable: true, + tags: [], }; const cart = { diff --git a/libs/ui/src/presentation/views/screens/order/MenuItemDetailScreen.stories.tsx b/libs/ui/src/presentation/views/screens/order/MenuItemDetailScreen.stories.tsx index fee834e8..4a48c2c4 100644 --- a/libs/ui/src/presentation/views/screens/order/MenuItemDetailScreen.stories.tsx +++ b/libs/ui/src/presentation/views/screens/order/MenuItemDetailScreen.stories.tsx @@ -37,6 +37,7 @@ const esKopiSusu = { isAvailable: true, availabilityTracking: 'none' as const, isSellable: true, + tags: [], }; const esKopiSusuDenganEs = { @@ -67,6 +68,7 @@ const nasiGoreng = { isAvailable: true, availabilityTracking: 'none' as const, isSellable: true, + tags: [], }; const softCookies = { @@ -91,6 +93,7 @@ const softCookies = { isAvailable: true, availabilityTracking: 'variant' as const, isSellable: true, + tags: [], }; const meta: Meta = { diff --git a/libs/ui/src/presentation/views/screens/order/MenuListScreen.stories.tsx b/libs/ui/src/presentation/views/screens/order/MenuListScreen.stories.tsx index b42c9c0e..d981fcaf 100644 --- a/libs/ui/src/presentation/views/screens/order/MenuListScreen.stories.tsx +++ b/libs/ui/src/presentation/views/screens/order/MenuListScreen.stories.tsx @@ -28,6 +28,7 @@ const esKopiSusu = { isAvailable: true, availabilityTracking: 'none' as const, isSellable: true, + tags: [], }; const nasiGoreng = { @@ -43,6 +44,7 @@ const nasiGoreng = { isAvailable: true, availabilityTracking: 'none' as const, isSellable: true, + tags: [], }; const meta: Meta = { diff --git a/libs/ui/src/utils/buildTagHighlights.test.ts b/libs/ui/src/utils/buildTagHighlights.test.ts new file mode 100644 index 00000000..cdaab0d0 --- /dev/null +++ b/libs/ui/src/utils/buildTagHighlights.test.ts @@ -0,0 +1,242 @@ +import { + Product, + ProductTag, + Tag, + Variant, + VariantTag, +} from '../domain/entities'; +import { buildTagHighlights } from './buildTagHighlights'; + +const category = { + id: 1, + name: 'Menu', + station: 'KITCHEN' as const, + createdAt: '2024-01-01T00:00:00.000Z', +}; + +const newTag: Tag = { + id: 1, + name: 'New', + color: 'green', + isHighlighted: true, + sortOrder: 1, + variantCount: 0, + createdAt: '2024-01-01T00:00:00.000Z', +}; + +const bestSellerTag: Tag = { + id: 2, + name: 'Best Seller', + color: 'orange', + isHighlighted: true, + sortOrder: 2, + variantCount: 0, + createdAt: '2024-01-01T00:00:00.000Z', +}; + +const plainTag: Tag = { + id: 3, + name: 'Spicy', + color: 'red', + isHighlighted: false, + sortOrder: 0, + variantCount: 0, + createdAt: '2024-01-01T00:00:00.000Z', +}; + +const productTag = ( + tag: Tag, + scope: ProductTag['scope'], + variantIds: number[], + taggedAt: string +): ProductTag => ({ tag, scope, variantIds, taggedAt }); + +const variantTag = (tag: Tag, taggedAt: string): VariantTag => ({ + tag, + taggedAt, +}); + +const buildProduct = ( + id: number, + name: string, + tags: ProductTag[], + isSellable = true +): Product => ({ + id, + name, + category, + imageUrl: `https://example.com/${id}.jpg`, + createdAt: '2024-01-01T00:00:00.000Z', + options: [], + saleType: 'purchase', + status: 'published', + isAvailable: isSellable, + availabilityTracking: 'none', + isSellable, + tags, +}); + +const buildVariant = ( + id: number, + name: string, + product: Product, + tags: VariantTag[], + isSellable = true +): Variant => ({ + id, + name, + price: 10000, + materials: [], + product, + createdAt: '2024-01-01T00:00:00.000Z', + values: [], + pricingTiers: [], + isAvailable: isSellable, + isSellable, + tags, +}); + +const iceCreamTaggedAt = '2024-03-01T00:00:00.000Z'; +const macchiatoTaggedAt = '2024-02-01T00:00:00.000Z'; +const latteTaggedAt = '2024-02-15T00:00:00.000Z'; + +const pancong = buildProduct(1, 'Pancong', [ + productTag(newTag, 'variant', [14], iceCreamTaggedAt), +]); +const macchiato = buildProduct(2, 'Salted Caramel Macchiato', [ + productTag(newTag, 'product', [21], macchiatoTaggedAt), +]); +const latte = buildProduct(3, 'Coffee Latte', [ + productTag(bestSellerTag, 'product', [31, 32], latteTaggedAt), +]); + +const pancongVariants = [ + buildVariant(11, 'Choco', pancong, []), + buildVariant(12, 'Matcha', pancong, []), + buildVariant(13, 'Vanilla', pancong, []), + buildVariant(14, 'Ice Cream', pancong, [ + variantTag(newTag, iceCreamTaggedAt), + ]), +]; +const macchiatoVariants = [ + buildVariant(21, 'Original', macchiato, [ + variantTag(newTag, macchiatoTaggedAt), + ]), +]; +const latteVariants = [ + buildVariant(31, 'Hot', latte, [variantTag(bestSellerTag, latteTaggedAt)]), + buildVariant(32, 'Iced', latte, [variantTag(bestSellerTag, latteTaggedAt)]), +]; + +const products = [pancong, macchiato, latte]; +const variants = [...pancongVariants, ...macchiatoVariants, ...latteVariants]; + +describe('buildTagHighlights', () => { + it('builds the New and Best Seller sections for the three cases', () => { + const highlights = buildTagHighlights(products, variants); + + expect(highlights.map(({ tag }) => tag.name)).toEqual([ + 'New', + 'Best Seller', + ]); + expect(highlights[0].entries).toEqual([ + { kind: 'variant', product: pancong, variant: pancongVariants[3] }, + { kind: 'product', product: macchiato }, + ]); + expect(highlights[1].entries).toEqual([ + { kind: 'product', product: latte }, + ]); + }); + + it('does not build a section for a non-highlighted tag', () => { + const spicy = buildProduct(4, 'Spicy Noodle', [ + productTag(plainTag, 'product', [41], '2024-03-01T00:00:00.000Z'), + ]); + const spicyVariant = buildVariant(41, 'Regular', spicy, [ + variantTag(plainTag, '2024-03-01T00:00:00.000Z'), + ]); + + expect(buildTagHighlights([spicy], [spicyVariant])).toEqual([]); + }); + + it('drops a sold-out variant entry', () => { + const soldOutIceCream = buildVariant( + 14, + 'Ice Cream', + pancong, + [variantTag(newTag, iceCreamTaggedAt)], + false + ); + + const highlights = buildTagHighlights( + [pancong, macchiato], + [...pancongVariants.slice(0, 3), soldOutIceCream, ...macchiatoVariants] + ); + + expect(highlights[0].entries).toEqual([ + { kind: 'product', product: macchiato }, + ]); + }); + + it('drops a sold-out product entry', () => { + const soldOutLatte = buildProduct(3, 'Coffee Latte', latte.tags, false); + + const highlights = buildTagHighlights([soldOutLatte], latteVariants); + + expect(highlights).toEqual([]); + }); + + it('drops a section whose entries are all sold out', () => { + const soldOutIceCream = buildVariant( + 14, + 'Ice Cream', + pancong, + [variantTag(newTag, iceCreamTaggedAt)], + false + ); + + expect(buildTagHighlights([pancong], [soldOutIceCream])).toEqual([]); + }); + + it('sorts a newer tagged variant ahead of an older tagged product', () => { + const highlights = buildTagHighlights( + [macchiato, pancong], + [...macchiatoVariants, ...pancongVariants] + ); + + expect(highlights[0].entries).toEqual([ + { kind: 'variant', product: pancong, variant: pancongVariants[3] }, + { kind: 'product', product: macchiato }, + ]); + }); + + it('falls back to menu order when tagged at the same time', () => { + const sameTime = '2024-03-01T00:00:00.000Z'; + const first = buildProduct(5, 'First', [ + productTag(newTag, 'product', [51], sameTime), + ]); + const second = buildProduct(6, 'Second', [ + productTag(newTag, 'product', [61], sameTime), + ]); + + const highlights = buildTagHighlights([second, first], []); + + expect(highlights[0].entries).toEqual([ + { kind: 'product', product: second }, + { kind: 'product', product: first }, + ]); + }); + + it('orders sections by tag sort order', () => { + const highlights = buildTagHighlights( + [latte, macchiato], + [...latteVariants, ...macchiatoVariants] + ); + + expect(highlights.map(({ tag }) => tag.sortOrder)).toEqual([1, 2]); + }); + + it('returns no sections for products without tags', () => { + expect(buildTagHighlights([buildProduct(7, 'Plain', [])], [])).toEqual([]); + }); +}); diff --git a/libs/ui/src/utils/buildTagHighlights.ts b/libs/ui/src/utils/buildTagHighlights.ts new file mode 100644 index 00000000..66bfb111 --- /dev/null +++ b/libs/ui/src/utils/buildTagHighlights.ts @@ -0,0 +1,84 @@ +import { Product, Tag, Variant } from '../domain/entities'; + +export type TagHighlightEntry = + | { kind: 'product'; product: Product } + | { kind: 'variant'; product: Product; variant: Variant }; + +export type TagHighlight = { + tag: Tag; + entries: TagHighlightEntry[]; +}; + +type RankedEntry = { + entry: TagHighlightEntry; + taggedAt: number; + productIndex: number; + variantIndex: number; +}; + +export function buildTagHighlights( + products: Product[], + variants: Variant[] +): TagHighlight[] { + const variantIndexById = new Map( + variants.map((variant, index) => [variant.id, index]) + ); + const rankedEntriesByTagId = new Map(); + const tagsById = new Map(); + + products.forEach((product, productIndex) => { + for (const productTag of product.tags) { + if (!productTag.tag.isHighlighted) continue; + + const rankedEntries: RankedEntry[] = []; + + if (productTag.scope === 'product') { + if (product.isSellable) { + rankedEntries.push({ + entry: { kind: 'product', product }, + taggedAt: Date.parse(productTag.taggedAt), + productIndex, + variantIndex: -1, + }); + } + } else { + for (const variantId of productTag.variantIds) { + const variantIndex = variantIndexById.get(variantId); + if (variantIndex === undefined) continue; + const variant = variants[variantIndex]; + if (!variant.isSellable) continue; + const variantTag = variant.tags.find( + ({ tag }) => tag.id === productTag.tag.id + ); + rankedEntries.push({ + entry: { kind: 'variant', product, variant }, + taggedAt: Date.parse(variantTag?.taggedAt ?? productTag.taggedAt), + productIndex, + variantIndex, + }); + } + } + + if (rankedEntries.length === 0) continue; + tagsById.set(productTag.tag.id, productTag.tag); + rankedEntriesByTagId.set(productTag.tag.id, [ + ...(rankedEntriesByTagId.get(productTag.tag.id) ?? []), + ...rankedEntries, + ]); + } + }); + + return [...rankedEntriesByTagId.entries()] + .map(([tagId, rankedEntries]) => ({ + tag: tagsById.get(tagId) as Tag, + entries: [...rankedEntries] + .sort( + (a, b) => + b.taggedAt - a.taggedAt || + a.productIndex - b.productIndex || + a.variantIndex - b.variantIndex + ) + .map(({ entry }) => entry), + })) + .sort((a, b) => a.tag.sortOrder - b.tag.sortOrder || a.tag.id - b.tag.id); +} diff --git a/libs/ui/src/utils/index.ts b/libs/ui/src/utils/index.ts index 018e1557..46ec9dc3 100644 --- a/libs/ui/src/utils/index.ts +++ b/libs/ui/src/utils/index.ts @@ -17,3 +17,4 @@ export * from './resolveOptionValueAvailability'; export * from './matchMenuSearch'; export * from './buildAvailabilityUpdateForm'; export * from './toSerializableProps'; +export * from './buildTagHighlights'; diff --git a/libs/ui/src/utils/matchMenuSearch.test.ts b/libs/ui/src/utils/matchMenuSearch.test.ts index 932e6397..a8bedd3d 100644 --- a/libs/ui/src/utils/matchMenuSearch.test.ts +++ b/libs/ui/src/utils/matchMenuSearch.test.ts @@ -46,6 +46,7 @@ const teh: Product = { isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }; const nasiGoreng: Product = { @@ -61,6 +62,7 @@ const nasiGoreng: Product = { isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }; const kopi: Product = { @@ -76,6 +78,7 @@ const kopi: Product = { isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }; describe('matchMenuSearch', () => { diff --git a/libs/ui/src/utils/resolveOptionValueAvailability.test.ts b/libs/ui/src/utils/resolveOptionValueAvailability.test.ts index 21962529..2541c55a 100644 --- a/libs/ui/src/utils/resolveOptionValueAvailability.test.ts +++ b/libs/ui/src/utils/resolveOptionValueAvailability.test.ts @@ -30,6 +30,7 @@ const esKopiSusu: Product = { isAvailable: true, availabilityTracking: 'none', isSellable: true, + tags: [], }; function variant( @@ -55,6 +56,7 @@ function variant( pricingTiers: [], isAvailable: isSellable, isSellable, + tags: [], }; } From 88f611d79b0af6365fb74aebc2eea937f538db83 Mon Sep 17 00:00:00 2001 From: M Nindra Zaka Date: Fri, 2 Oct 2026 09:05:34 +0700 Subject: [PATCH 11/15] feat(ui): show tag badges on POS product cards (#675) Claude-Session: https://claude.ai/code/session_01CDrcbE4NkTbQssA1ohgz1D Co-authored-by: Claude --- .../views/components/products/ProductList.tsx | 1 + .../products/ProductListItem.stories.tsx | 59 +++++++++++++ .../products/ProductListItem.test.tsx | 75 ++++++++++++++++ .../components/products/ProductListItem.tsx | 36 ++++++-- .../TransactionItemSelect.stories.tsx | 30 +++++++ .../transactions/TransactionItemSelect.tsx | 1 + libs/ui/src/utils/buildTagBadges.test.ts | 85 +++++++++++++++++++ libs/ui/src/utils/buildTagBadges.ts | 47 ++++++++++ libs/ui/src/utils/index.ts | 1 + 9 files changed, 328 insertions(+), 7 deletions(-) create mode 100644 libs/ui/src/presentation/views/components/products/ProductListItem.test.tsx create mode 100644 libs/ui/src/utils/buildTagBadges.test.ts create mode 100644 libs/ui/src/utils/buildTagBadges.ts diff --git a/libs/ui/src/presentation/views/components/products/ProductList.tsx b/libs/ui/src/presentation/views/components/products/ProductList.tsx index b1bf4329..140243b7 100644 --- a/libs/ui/src/presentation/views/components/products/ProductList.tsx +++ b/libs/ui/src/presentation/views/components/products/ProductList.tsx @@ -221,6 +221,7 @@ export const ProductList = ({ style={{ flex: 1 }} name={item.name} imageUrl={item.imageUrl} + tags={item.tags} onDeleteMenuPress={ onDeleteMenuPress ? () => onDeleteMenuPress(item) diff --git a/libs/ui/src/presentation/views/components/products/ProductListItem.stories.tsx b/libs/ui/src/presentation/views/components/products/ProductListItem.stories.tsx index c2574a67..21e09e1b 100644 --- a/libs/ui/src/presentation/views/components/products/ProductListItem.stories.tsx +++ b/libs/ui/src/presentation/views/components/products/ProductListItem.stories.tsx @@ -1,6 +1,37 @@ import type { Meta, StoryObj } from '@storybook/react'; import { fn } from '@storybook/test'; import { ProductListItem } from './ProductListItem'; +import type { ProductTag, Tag } from '../../../../domain'; + +const buildTag = ( + id: number, + name: string, + color: Tag['color'], + sortOrder: number +): Tag => ({ + id, + name, + color, + isHighlighted: true, + sortOrder, + variantCount: 0, + createdAt: '2024-01-01T00:00:00.000Z', +}); + +const buildProductTag = ( + tag: Tag, + scope: ProductTag['scope'], + variantIds: number[] +): ProductTag => ({ + tag, + scope, + variantIds, + taggedAt: '2024-01-01T00:00:00.000Z', +}); + +const bestSeller = buildTag(1, 'Best Seller', 'orange', 1); +const newTag = buildTag(2, 'New', 'green', 2); +const spicy = buildTag(3, 'Spicy', 'red', 3); const meta: Meta = { title: 'Components/Products/ProductListItem', @@ -70,3 +101,31 @@ export const LowRemainingQuantity: Story = { remainingQuantity: 2, }, }; + +export const WithProductScopeTag: Story = { + args: { + name: 'Coffee Latte', + tags: [buildProductTag(bestSeller, 'product', [1, 2])], + }, +}; + +export const WithVariantScopeTag: Story = { + args: { + name: 'Pancong', + tags: [buildProductTag(newTag, 'variant', [10])], + variantNameById: { 10: 'Ice Cream' }, + }, +}; + +export const WithTagOverflow: Story = { + args: { + name: 'Pancong', + remainingQuantity: 2, + tags: [ + buildProductTag(bestSeller, 'product', [1, 2]), + buildProductTag(newTag, 'variant', [10]), + buildProductTag(spicy, 'product', [1, 2]), + ], + variantNameById: { 10: 'Ice Cream' }, + }, +}; diff --git a/libs/ui/src/presentation/views/components/products/ProductListItem.test.tsx b/libs/ui/src/presentation/views/components/products/ProductListItem.test.tsx new file mode 100644 index 00000000..608f0f49 --- /dev/null +++ b/libs/ui/src/presentation/views/components/products/ProductListItem.test.tsx @@ -0,0 +1,75 @@ +import { render, screen } from '@testing-library/react'; +import { ProductListItem } from './ProductListItem'; +import type { ProductTag, Tag } from '../../../../domain'; + +const buildTag = (id: number, name: string, sortOrder: number): Tag => ({ + id, + name, + color: 'green', + isHighlighted: true, + sortOrder, + variantCount: 0, + createdAt: '2024-01-01T00:00:00.000Z', +}); + +const buildProductTag = ( + tag: Tag, + scope: ProductTag['scope'], + variantIds: number[] +): ProductTag => ({ + tag, + scope, + variantIds, + taggedAt: '2024-01-01T00:00:00.000Z', +}); + +const baseProps = { + name: 'Pancong', + categoryName: 'Snacks', + saleType: 'purchase' as const, + status: 'published' as const, +}; + +describe('ProductListItem tag badges', () => { + it('shows a product-scope tag by name', () => { + render( + + ); + + expect(screen.getByText('Best Seller')).toBeTruthy(); + }); + + it('names the variant on a variant-scope tag', () => { + render( + + ); + + expect(screen.getByText('New · Ice Cream')).toBeTruthy(); + expect(screen.queryByText('New')).toBeNull(); + }); + + it('shows two badges and a +N overflow', () => { + render( + + ); + + expect(screen.getByText('First')).toBeTruthy(); + expect(screen.getByText('Second')).toBeTruthy(); + expect(screen.queryByText('Third')).toBeNull(); + expect(screen.getByText('+1')).toBeTruthy(); + }); +}); diff --git a/libs/ui/src/presentation/views/components/products/ProductListItem.tsx b/libs/ui/src/presentation/views/components/products/ProductListItem.tsx index 2e73a7c6..bdf22fe4 100644 --- a/libs/ui/src/presentation/views/components/products/ProductListItem.tsx +++ b/libs/ui/src/presentation/views/components/products/ProductListItem.tsx @@ -1,7 +1,9 @@ import { FileText, Pencil, Tag, Trash } from '@tamagui/lucide-icons'; import { ListItem } from '../base'; import { Paragraph, XStack, XStackProps, YStack } from 'tamagui'; -import { ProductStatus } from '../../../../domain'; +import { ProductStatus, ProductTag } from '../../../../domain'; +import { buildTagBadges } from '../../../../utils'; +import { TagBadge } from '../tags'; export type ProductListItemProps = { name: string; @@ -11,6 +13,8 @@ export type ProductListItemProps = { imageUrl?: string; isSoldOut?: boolean; remainingQuantity?: number; + tags?: ProductTag[]; + variantNameById?: Record; onEditMenuPress?: () => void; onDeleteMenuPress?: () => void; } & XStackProps; @@ -51,10 +55,14 @@ export const ProductListItem = ({ imageUrl, isSoldOut, remainingQuantity, + tags = [], + variantNameById, onEditMenuPress, onDeleteMenuPress, ...xStackProps }: ProductListItemProps) => { + const { badges, overflowCount } = buildTagBadges(tags, variantNameById); + return ( {categoryName} - {isSoldOut ? ( - - ) : ( - remainingQuantity !== undefined && ( - - ) + {(isSoldOut || + remainingQuantity !== undefined || + badges.length > 0) && ( + + {isSoldOut ? ( + + ) : ( + remainingQuantity !== undefined && ( + + ) + )} + {badges.map(({ key, label, color }) => ( + + ))} + {overflowCount > 0 && ( + + +{overflowCount} + + )} + )} } diff --git a/libs/ui/src/presentation/views/components/transactions/TransactionItemSelect.stories.tsx b/libs/ui/src/presentation/views/components/transactions/TransactionItemSelect.stories.tsx index 11b861c0..e01e2cb2 100644 --- a/libs/ui/src/presentation/views/components/transactions/TransactionItemSelect.stories.tsx +++ b/libs/ui/src/presentation/views/components/transactions/TransactionItemSelect.stories.tsx @@ -228,3 +228,33 @@ export const SelectingOptionsWithCappedStepper: Story = { amount: 3, }, }; + +const taggedProducts: Product[] = [ + { + ...mockProducts[0], + tags: [ + { + tag: { + id: 1, + name: 'Best Seller', + color: 'orange', + isHighlighted: true, + sortOrder: 1, + variantCount: 2, + createdAt: '2024-01-01T00:00:00.000Z', + }, + scope: 'product', + variantIds: [1, 2], + taggedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }, + ...mockProducts.slice(1), +]; + +export const WithTagBadges: Story = { + args: { + variant: { type: 'loaded' }, + products: taggedProducts, + }, +}; diff --git a/libs/ui/src/presentation/views/components/transactions/TransactionItemSelect.tsx b/libs/ui/src/presentation/views/components/transactions/TransactionItemSelect.tsx index 609b725c..3204418b 100644 --- a/libs/ui/src/presentation/views/components/transactions/TransactionItemSelect.tsx +++ b/libs/ui/src/presentation/views/components/transactions/TransactionItemSelect.tsx @@ -356,6 +356,7 @@ export const TransactionItemSelect = ({ style={{ flex: 1 }} name={item.name} imageUrl={item.imageUrl} + tags={item.tags} onPress={ isSoldOut ? undefined diff --git a/libs/ui/src/utils/buildTagBadges.test.ts b/libs/ui/src/utils/buildTagBadges.test.ts new file mode 100644 index 00000000..e35405f7 --- /dev/null +++ b/libs/ui/src/utils/buildTagBadges.test.ts @@ -0,0 +1,85 @@ +import { ProductTag, Tag } from '../domain/entities'; +import { buildTagBadges } from './buildTagBadges'; + +const buildTag = ( + id: number, + name: string, + sortOrder: number, + color: Tag['color'] = 'green' +): Tag => ({ + id, + name, + color, + isHighlighted: true, + sortOrder, + variantCount: 0, + createdAt: '2024-01-01T00:00:00.000Z', +}); + +const buildProductTag = ( + tag: Tag, + scope: ProductTag['scope'], + variantIds: number[] +): ProductTag => ({ + tag, + scope, + variantIds, + taggedAt: '2024-01-01T00:00:00.000Z', +}); + +const newTag = buildTag(1, 'New', 1); +const bestSellerTag = buildTag(2, 'Best Seller', 2, 'orange'); +const spicyTag = buildTag(3, 'Spicy', 3, 'red'); + +describe('buildTagBadges', () => { + it('returns no badges for an untagged product', () => { + expect(buildTagBadges([])).toEqual({ badges: [], overflowCount: 0 }); + }); + + it('labels a product-scope tag with the tag name', () => { + const { badges } = buildTagBadges([ + buildProductTag(bestSellerTag, 'product', [1, 2]), + ]); + + expect(badges).toEqual([ + { key: '2', label: 'Best Seller', color: 'orange' }, + ]); + }); + + it('names the variant for a single-variant scope tag', () => { + const { badges } = buildTagBadges( + [buildProductTag(newTag, 'variant', [10])], + { 10: 'Ice Cream' } + ); + + expect(badges[0].label).toBe('New · Ice Cream'); + }); + + it('counts variants when more than one variant is tagged', () => { + const { badges } = buildTagBadges( + [buildProductTag(newTag, 'variant', [10, 11])], + { 10: 'Ice Cream', 11: 'Chocolate' } + ); + + expect(badges[0].label).toBe('New · 2 varian'); + }); + + it('falls back to the variant count when the variant name is unknown', () => { + const { badges } = buildTagBadges([ + buildProductTag(newTag, 'variant', [10]), + ]); + + expect(badges[0].label).toBe('New · 1 varian'); + }); + + it('shows at most two badges in tag sort order and counts the rest', () => { + const { badges, overflowCount } = buildTagBadges([ + buildProductTag(spicyTag, 'product', [1]), + buildProductTag(newTag, 'product', [1]), + buildProductTag(bestSellerTag, 'product', [1]), + ]); + + expect(badges.map(({ label }) => label)).toEqual(['New', 'Best Seller']); + expect(overflowCount).toBe(1); + }); +}); diff --git a/libs/ui/src/utils/buildTagBadges.ts b/libs/ui/src/utils/buildTagBadges.ts new file mode 100644 index 00000000..0f849b4f --- /dev/null +++ b/libs/ui/src/utils/buildTagBadges.ts @@ -0,0 +1,47 @@ +import { ProductTag, TagColor } from '../domain/entities'; + +export const MAX_TAG_BADGES = 2; + +export type TagBadgeItem = { + key: string; + label: string; + color: TagColor; +}; + +export type TagBadges = { + badges: TagBadgeItem[]; + overflowCount: number; +}; + +const buildLabel = ( + productTag: ProductTag, + variantNameById: Record +) => { + if (productTag.scope === 'product') return productTag.tag.name; + + if (productTag.variantIds.length === 1) { + const variantName = variantNameById[productTag.variantIds[0]]; + if (variantName) return `${productTag.tag.name} · ${variantName}`; + } + + return `${productTag.tag.name} · ${productTag.variantIds.length} varian`; +}; + +export function buildTagBadges( + tags: ProductTag[], + variantNameById: Record = {} +): TagBadges { + const sortedTags = [...tags].sort( + (a, b) => + a.tag.sortOrder - b.tag.sortOrder || a.tag.name.localeCompare(b.tag.name) + ); + + return { + badges: sortedTags.slice(0, MAX_TAG_BADGES).map((productTag) => ({ + key: String(productTag.tag.id), + label: buildLabel(productTag, variantNameById), + color: productTag.tag.color, + })), + overflowCount: Math.max(sortedTags.length - MAX_TAG_BADGES, 0), + }; +} diff --git a/libs/ui/src/utils/index.ts b/libs/ui/src/utils/index.ts index 46ec9dc3..6f03f925 100644 --- a/libs/ui/src/utils/index.ts +++ b/libs/ui/src/utils/index.ts @@ -18,3 +18,4 @@ export * from './matchMenuSearch'; export * from './buildAvailabilityUpdateForm'; export * from './toSerializableProps'; export * from './buildTagHighlights'; +export * from './buildTagBadges'; From 0d227325c9df733749f2ca9f547e02505cd20125 Mon Sep 17 00:00:00 2001 From: M Nindra Zaka Date: Fri, 2 Oct 2026 09:14:03 +0700 Subject: [PATCH 12/15] feat(order): show tag badges on menu cards and item detail (#676) Claude-Session: https://claude.ai/code/session_011NpuEcKaprJtcmS59gihhz Co-authored-by: Claude --- .../handlers/order/MenuListHandler.test.tsx | 33 ++++++ .../handlers/order/MenuListHandler.tsx | 8 ++ .../menu/MenuProductCard.stories.tsx | 101 ++++++++++++++++++ .../views/components/menu/MenuProductCard.tsx | 8 ++ .../components/tags/ProductTagBadges.tsx | 31 ++++++ .../views/components/tags/index.ts | 1 + .../order/MenuItemDetailScreen.stories.tsx | 28 +++++ .../screens/order/MenuItemDetailScreen.tsx | 21 ++++ .../screens/order/MenuListScreen.stories.tsx | 1 + .../views/screens/order/MenuListScreen.tsx | 3 + 10 files changed, 235 insertions(+) create mode 100644 libs/ui/src/presentation/views/components/tags/ProductTagBadges.tsx diff --git a/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx b/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx index 77493081..2f9eef4b 100644 --- a/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx +++ b/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx @@ -193,6 +193,39 @@ describe('MenuListHandler', () => { expect(screen.getByText('Es Kopi Susu')).toBeTruthy(); }); + it('shows tag badges on a tagged product card, naming the variant for variant-scope tags', async () => { + const menuRepository = new MockMenuRepository(); + const tag = { + id: 1, + name: 'New', + color: 'green' as const, + isHighlighted: true, + sortOrder: 1, + variantCount: 1, + createdAt: '2024-03-20T00:00:00.000Z', + }; + menuRepository.products = [ + { + ...menuRepository.products[0], + tags: [ + { + tag, + scope: 'variant', + variantIds: [2], + taggedAt: '2024-03-20T00:00:00.000Z', + }, + ], + }, + menuRepository.products[1], + ]; + renderHandler({ menuRepository }); + + await settle(); + + expect(screen.getByText('New · Es Kopi Susu - Large')).toBeTruthy(); + expect(screen.queryByText('New')).toBeNull(); + }); + it('filters to a single category when its chip is pressed', async () => { const user = userEvent.setup(); renderHandler(); diff --git a/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx b/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx index 55bd9b10..37df4ce6 100644 --- a/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx +++ b/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx @@ -70,6 +70,12 @@ function computeStartingPriceByProductId( }, {}); } +function computeVariantNameById(variants: Variant[]): Record { + return Object.fromEntries( + variants.map((variant) => [variant.id, variant.name]) + ); +} + function computeMatchedLabels( query: string, product: Product, @@ -123,6 +129,7 @@ function toItemDetailScreenVariant( price: state.variant?.price ?? null, variantErrorMessage: state.type === 'error' ? state.errorMessage : null, isVariantSellable: state.variant?.isSellable ?? null, + variantTags: state.variant?.tags ?? [], remainingQuantity: state.variant?.isSellable ? state.variant.sellableQuantity : undefined, @@ -435,6 +442,7 @@ export const MenuListHandler = ({ } startingPriceByProductId={startingPriceByProductId} matchedLabelsByProductId={matchedLabelsByProductId} + variantNameById={computeVariantNameById(menuList.state.variants)} onHistoryPress={() => router.push('/orders')} preparingCount={preparingCount} cartErrorMessage={ diff --git a/libs/ui/src/presentation/views/components/menu/MenuProductCard.stories.tsx b/libs/ui/src/presentation/views/components/menu/MenuProductCard.stories.tsx index b454388c..2ada709c 100644 --- a/libs/ui/src/presentation/views/components/menu/MenuProductCard.stories.tsx +++ b/libs/ui/src/presentation/views/components/menu/MenuProductCard.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; +import { ProductTag, Tag } from '../../../../domain'; import { MenuProductCard } from './MenuProductCard'; const category = { @@ -8,6 +9,45 @@ const category = { createdAt: '2024-03-20T00:00:00.000Z', }; +const makeTag = (overrides: Partial & Pick): Tag => ({ + color: 'blue', + isHighlighted: true, + sortOrder: overrides.id, + variantCount: 0, + createdAt: '2024-03-20T00:00:00.000Z', + ...overrides, +}); + +const makeProductTag = ( + tag: Tag, + scope: ProductTag['scope'], + variantIds: number[] = [] +): ProductTag => ({ + tag, + scope, + variantIds, + taggedAt: '2024-03-20T00:00:00.000Z', +}); + +const bestSeller = makeTag({ id: 1, name: 'Best Seller', color: 'orange' }); +const newTag = makeTag({ id: 2, name: 'New', color: 'green' }); +const recommended = makeTag({ id: 3, name: 'Recommended', color: 'purple' }); + +const pancong = { + id: 6, + name: 'Pancong', + description: 'Kue pancong isi 3 rasa', + category: { ...category, name: 'Makanan', station: 'KITCHEN' as const }, + imageUrl: '', + saleType: 'purchase' as const, + status: 'published' as const, + options: [], + createdAt: '2024-03-20T00:00:00.000Z', + isAvailable: true, + availabilityTracking: 'none' as const, + isSellable: true, +}; + const meta: Meta = { title: 'Components/Menu/MenuProductCard', component: MenuProductCard, @@ -157,3 +197,64 @@ export const SoldOut: Story = { startingPrice: 8000, }, }; + +export const ProductScopeTag: Story = { + args: { + product: { + ...pancong, + tags: [makeProductTag(bestSeller, 'product')], + }, + startingPrice: 8000, + }, +}; + +export const VariantScopeTag: Story = { + args: { + product: { + ...pancong, + tags: [ + makeProductTag(bestSeller, 'product'), + makeProductTag(newTag, 'variant', [11]), + ], + }, + variantNameById: { 11: 'Ice Cream' }, + startingPrice: 8000, + }, +}; + +export const VariantScopeTagOnly: Story = { + args: { + product: { + ...pancong, + tags: [makeProductTag(newTag, 'variant', [11])], + }, + variantNameById: { 11: 'Ice Cream' }, + startingPrice: 8000, + }, +}; + +export const MultipleVariantsTagged: Story = { + args: { + product: { + ...pancong, + tags: [makeProductTag(newTag, 'variant', [11, 12])], + }, + variantNameById: { 11: 'Ice Cream', 12: 'Matcha' }, + startingPrice: 8000, + }, +}; + +export const TagOverflow: Story = { + args: { + product: { + ...pancong, + tags: [ + makeProductTag(bestSeller, 'product'), + makeProductTag(newTag, 'variant', [11]), + makeProductTag(recommended, 'product'), + ], + }, + variantNameById: { 11: 'Ice Cream' }, + startingPrice: 8000, + }, +}; diff --git a/libs/ui/src/presentation/views/components/menu/MenuProductCard.tsx b/libs/ui/src/presentation/views/components/menu/MenuProductCard.tsx index 00afc4e7..bc076fdd 100644 --- a/libs/ui/src/presentation/views/components/menu/MenuProductCard.tsx +++ b/libs/ui/src/presentation/views/components/menu/MenuProductCard.tsx @@ -1,12 +1,14 @@ import { Paragraph, Text, XStack, YStack } from 'tamagui'; import { Product } from '../../../../domain/entities/Product'; import { formatRupiah } from '../../../../utils/currency'; +import { ProductTagBadges } from '../tags/ProductTagBadges'; import { MenuItemThumbnail } from './MenuItemThumbnail'; export type MenuProductCardProps = { product: Product; startingPrice: number | null; matchedLabels?: string[]; + variantNameById?: Record; onPress: () => void; }; @@ -42,6 +44,7 @@ export const MenuProductCard = ({ product, startingPrice, matchedLabels, + variantNameById, onPress, }: MenuProductCardProps) => { const isSellable = product.isSellable; @@ -77,6 +80,11 @@ export const MenuProductCard = ({ {!isSellable && } + + {matchedLabels && matchedLabels.length > 0 && ( {matchedLabels.map((label) => ( diff --git a/libs/ui/src/presentation/views/components/tags/ProductTagBadges.tsx b/libs/ui/src/presentation/views/components/tags/ProductTagBadges.tsx new file mode 100644 index 00000000..b9f38ff9 --- /dev/null +++ b/libs/ui/src/presentation/views/components/tags/ProductTagBadges.tsx @@ -0,0 +1,31 @@ +import { SizableText, XStack } from 'tamagui'; +import { ProductTag } from '../../../../domain'; +import { buildTagBadges } from '../../../../utils/buildTagBadges'; +import { TagBadge } from './TagBadge'; + +export type ProductTagBadgesProps = { + tags: ProductTag[]; + variantNameById?: Record; +}; + +export const ProductTagBadges = ({ + tags, + variantNameById, +}: ProductTagBadgesProps) => { + const { badges, overflowCount } = buildTagBadges(tags, variantNameById); + + if (badges.length === 0) return null; + + return ( + + {badges.map((badge) => ( + + ))} + {overflowCount > 0 && ( + + +{overflowCount} + + )} + + ); +}; diff --git a/libs/ui/src/presentation/views/components/tags/index.ts b/libs/ui/src/presentation/views/components/tags/index.ts index 831032d8..78a8d4b5 100644 --- a/libs/ui/src/presentation/views/components/tags/index.ts +++ b/libs/ui/src/presentation/views/components/tags/index.ts @@ -7,3 +7,4 @@ export * from './TagList'; export * from './TagListItem'; export * from './tagColors'; export * from './TagAssignmentList'; +export * from './ProductTagBadges'; diff --git a/libs/ui/src/presentation/views/screens/order/MenuItemDetailScreen.stories.tsx b/libs/ui/src/presentation/views/screens/order/MenuItemDetailScreen.stories.tsx index 4a48c2c4..0477dfe4 100644 --- a/libs/ui/src/presentation/views/screens/order/MenuItemDetailScreen.stories.tsx +++ b/libs/ui/src/presentation/views/screens/order/MenuItemDetailScreen.stories.tsx @@ -295,3 +295,31 @@ export const LockedByPendingPayment: Story = { }, }, }; + +export const ReadyWithVariantTags: Story = { + args: { + variant: { + type: 'ready', + product: esKopiSusu, + price: 18000, + variantErrorMessage: null, + isVariantSellable: true, + variantTags: [ + { + tag: { + id: 1, + name: 'New', + color: 'green', + isHighlighted: true, + sortOrder: 1, + variantCount: 1, + createdAt: '2024-03-20T00:00:00.000Z', + }, + taggedAt: '2024-03-20T00:00:00.000Z', + }, + ], + }, + ctaState: 'ready', + selectedOptionValueIds: [1], + }, +}; diff --git a/libs/ui/src/presentation/views/screens/order/MenuItemDetailScreen.tsx b/libs/ui/src/presentation/views/screens/order/MenuItemDetailScreen.tsx index 3d513ac5..b57624a7 100644 --- a/libs/ui/src/presentation/views/screens/order/MenuItemDetailScreen.tsx +++ b/libs/ui/src/presentation/views/screens/order/MenuItemDetailScreen.tsx @@ -10,6 +10,7 @@ import { } from 'tamagui'; import { match, P } from 'ts-pattern'; import { Product } from '../../../../domain/entities/Product'; +import { VariantTag } from '../../../../domain/entities/Tag'; import { formatRupiah } from '../../../../utils/currency'; import { ErrorView } from '../../components/base/ErrorView'; import { LoadingView } from '../../components/base/LoadingView'; @@ -21,6 +22,7 @@ import { import { AmountStepper } from '../../components/menu/AmountStepper'; import { MenuItemThumbnail } from '../../components/menu/MenuItemThumbnail'; import { OptionValueChipGroup } from '../../components/menu/OptionValueChipGroup'; +import { TagBadge } from '../../components/tags/TagBadge'; export type MenuItemDetailScreenVariant = | { type: 'loading' } @@ -31,6 +33,7 @@ export type MenuItemDetailScreenVariant = price: number | null; variantErrorMessage: string | null; isVariantSellable: boolean | null; + variantTags?: VariantTag[]; remainingQuantity?: number; }; @@ -104,6 +107,7 @@ export const MenuItemDetailScreen = ({ price, variantErrorMessage, isVariantSellable, + variantTags, remainingQuantity, }) => ( @@ -120,6 +124,23 @@ export const MenuItemDetailScreen = ({ {product.name} + {variantTags && variantTags.length > 0 ? ( + + {[...variantTags] + .sort( + (a, b) => + a.tag.sortOrder - b.tag.sortOrder || + a.tag.id - b.tag.id + ) + .map(({ tag }) => ( + + ))} + + ) : null} {product.description ? ( {product.description} diff --git a/libs/ui/src/presentation/views/screens/order/MenuListScreen.stories.tsx b/libs/ui/src/presentation/views/screens/order/MenuListScreen.stories.tsx index d981fcaf..704aef36 100644 --- a/libs/ui/src/presentation/views/screens/order/MenuListScreen.stories.tsx +++ b/libs/ui/src/presentation/views/screens/order/MenuListScreen.stories.tsx @@ -75,6 +75,7 @@ const meta: Meta = { }, startingPriceByProductId: { 1: 18000, 2: 25000 }, matchedLabelsByProductId: {}, + variantNameById: {}, itemDetail: null, cancelConfirmation: { isOpen: false, diff --git a/libs/ui/src/presentation/views/screens/order/MenuListScreen.tsx b/libs/ui/src/presentation/views/screens/order/MenuListScreen.tsx index 2d22dbf9..321897e9 100644 --- a/libs/ui/src/presentation/views/screens/order/MenuListScreen.tsx +++ b/libs/ui/src/presentation/views/screens/order/MenuListScreen.tsx @@ -47,6 +47,7 @@ export type MenuListScreenProps = { onItemPress: (product: Product) => void; startingPriceByProductId: Record; matchedLabelsByProductId: Record; + variantNameById: Record; itemDetail: (MenuItemDetailScreenProps & { isOpen: true }) | null; cancelConfirmation: PaymentCancelAlertProps; }; @@ -68,6 +69,7 @@ export const MenuListScreen = ({ onItemPress, startingPriceByProductId, matchedLabelsByProductId, + variantNameById, itemDetail, cancelConfirmation, }: MenuListScreenProps) => { @@ -147,6 +149,7 @@ export const MenuListScreen = ({ matchedLabels={ matchedLabelsByProductId[product.id] } + variantNameById={variantNameById} onPress={() => onItemPress(product)} /> From 26e373971625fee0ffc2c94f0224f2b115cc2788 Mon Sep 17 00:00:00 2001 From: M Nindra Zaka Date: Fri, 2 Oct 2026 09:15:06 +0700 Subject: [PATCH 13/15] feat(order): add highlight sections to the menu list (#677) Render one horizontal carousel per highlighted tag above the category groups, only while the search box is empty and no category is selected. SELECT_ITEM now carries an optional variantId so tapping a variant card opens the item sheet with that variant's option values preselected. Claude-Session: https://claude.ai/code/session_01GWVBoUtiTrYsEwcM4T6hgo Co-authored-by: Claude --- libs/ui/src/domain/usecases/menuList.test.ts | 28 +++ libs/ui/src/domain/usecases/menuList.ts | 8 +- .../handlers/order/MenuListHandler.test.tsx | 222 ++++++++++++++++++ .../handlers/order/MenuListHandler.tsx | 34 ++- .../menu/MenuHighlightCard.stories.tsx | 48 ++++ .../components/menu/MenuHighlightCard.tsx | 60 +++++ .../menu/MenuHighlightSection.stories.tsx | 88 +++++++ .../components/menu/MenuHighlightSection.tsx | 50 ++++ .../views/components/menu/index.ts | 2 + .../screens/order/MenuListScreen.stories.tsx | 65 +++++ .../views/screens/order/MenuListScreen.tsx | 18 ++ 11 files changed, 617 insertions(+), 6 deletions(-) create mode 100644 libs/ui/src/presentation/views/components/menu/MenuHighlightCard.stories.tsx create mode 100644 libs/ui/src/presentation/views/components/menu/MenuHighlightCard.tsx create mode 100644 libs/ui/src/presentation/views/components/menu/MenuHighlightSection.stories.tsx create mode 100644 libs/ui/src/presentation/views/components/menu/MenuHighlightSection.tsx diff --git a/libs/ui/src/domain/usecases/menuList.test.ts b/libs/ui/src/domain/usecases/menuList.test.ts index 3f496bf6..77cf7b0e 100644 --- a/libs/ui/src/domain/usecases/menuList.test.ts +++ b/libs/ui/src/domain/usecases/menuList.test.ts @@ -33,6 +33,7 @@ describe('MenuListUsecase', () => { query: '', selectedCategoryId: null, selectedProductId: null, + selectedVariantId: null, errorMessage: null, fetchDebounceDelay: 0, }); @@ -46,6 +47,7 @@ describe('MenuListUsecase', () => { query: '', selectedCategoryId: null, selectedProductId: null, + selectedVariantId: null, errorMessage: null, fetchDebounceDelay: 0, }); @@ -62,6 +64,7 @@ describe('MenuListUsecase', () => { query: '', selectedCategoryId: null, selectedProductId: null, + selectedVariantId: null, errorMessage: null, fetchDebounceDelay: 0, }); @@ -75,6 +78,7 @@ describe('MenuListUsecase', () => { query: '', selectedCategoryId: 2, selectedProductId: null, + selectedVariantId: null, errorMessage: null, fetchDebounceDelay: 0, }); @@ -118,6 +122,7 @@ describe('MenuListUsecase', () => { query: '', selectedCategoryId: null, selectedProductId: null, + selectedVariantId: null, errorMessage: 'Failed to fetch menu', fetchDebounceDelay: 0, }); @@ -173,6 +178,28 @@ describe('MenuListUsecase', () => { expect(menuList.state.selectedProductId).toBe(1); }); + it('keeps the variant id alongside the selected product', async () => { + const repository = new MockMenuRepository(); + const menuList = createTester(repository); + await flushPromises(); + + menuList.dispatch({ type: 'SELECT_ITEM', productId: 1, variantId: 2 }); + + expect(menuList.state.selectedProductId).toBe(1); + expect(menuList.state.selectedVariantId).toBe(2); + }); + + it('selects a product without a variant when none is given, dropping a previous one', async () => { + const repository = new MockMenuRepository(); + const menuList = createTester(repository); + await flushPromises(); + + menuList.dispatch({ type: 'SELECT_ITEM', productId: 1, variantId: 2 }); + menuList.dispatch({ type: 'SELECT_ITEM', productId: 2 }); + + expect(menuList.state.selectedVariantId).toBeNull(); + }); + it('clears the selected product id', async () => { const repository = new MockMenuRepository(); const menuList = createTester(repository); @@ -182,6 +209,7 @@ describe('MenuListUsecase', () => { menuList.dispatch({ type: 'CLEAR_ITEM' }); expect(menuList.state.selectedProductId).toBeNull(); + expect(menuList.state.selectedVariantId).toBeNull(); }); it('reads the initial selection from the query repository when the params do not seed one', () => { diff --git a/libs/ui/src/domain/usecases/menuList.ts b/libs/ui/src/domain/usecases/menuList.ts index 6b143917..b3074d02 100644 --- a/libs/ui/src/domain/usecases/menuList.ts +++ b/libs/ui/src/domain/usecases/menuList.ts @@ -11,6 +11,7 @@ type Context = { query: string; selectedCategoryId: number | null; selectedProductId: number | null; + selectedVariantId: number | null; errorMessage: string | null; fetchDebounceDelay: number; }; @@ -46,7 +47,7 @@ export type MenuListAction = categories: Category[]; variants: Variant[]; } - | { type: 'SELECT_ITEM'; productId: number } + | { type: 'SELECT_ITEM'; productId: number; variantId?: number } | { type: 'CLEAR_ITEM' }; export type MenuListParams = { @@ -89,6 +90,7 @@ export class MenuListUsecase extends Usecase< selectedProductId: this.params.selectedProductId ?? this.menuListQueryRepository.getSelectedProductId(), + selectedVariantId: null, errorMessage: null, fetchDebounceDelay: 0, }; @@ -160,14 +162,16 @@ export class MenuListUsecase extends Usecase< // it. .with( [P._, { type: 'SELECT_ITEM' }], - ([state, { productId }]) => ({ + ([state, { productId, variantId }]) => ({ ...state, selectedProductId: productId, + selectedVariantId: variantId ?? null, }) ) .with([P._, { type: 'CLEAR_ITEM' }], ([state]) => ({ ...state, selectedProductId: null, + selectedVariantId: null, })) .otherwise(() => state); } diff --git a/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx b/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx index 2f9eef4b..a3d3d32e 100644 --- a/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx +++ b/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx @@ -12,6 +12,7 @@ import { MockSessionRepository, } from '../../../data/mock'; import { + Tag, CartUsecase, MenuItemDetailUsecase, MenuListParams, @@ -41,6 +42,83 @@ const pendingQrisPayment: PendingPayment = { canCancel: true, }; +const tagNew: Tag = { + id: 1, + name: 'New', + color: 'green', + isHighlighted: true, + sortOrder: 1, + variantCount: 1, + createdAt: '2024-03-20T00:00:00.000Z', +}; + +const tagBestSeller: Tag = { + id: 2, + name: 'Best Seller', + color: 'orange', + isHighlighted: true, + sortOrder: 2, + variantCount: 3, + createdAt: '2024-03-20T00:00:00.000Z', +}; + +const tagRegular: Tag = { + id: 3, + name: 'Regular Pick', + color: 'gray', + isHighlighted: false, + sortOrder: 3, + variantCount: 1, + createdAt: '2024-03-20T00:00:00.000Z', +}; + +const createTaggedMenuRepository = () => { + const menuRepository = new MockMenuRepository(); + menuRepository.products = menuRepository.products.map((product) => ({ + ...product, + })); + menuRepository.variants = menuRepository.variants.map((variant) => ({ + ...variant, + product: + menuRepository.products.find(({ id }) => id === variant.product.id) ?? + variant.product, + })); + const [esKopiSusu, nasiGoreng] = menuRepository.products; + const [regular, large, friedRice] = menuRepository.variants; + + esKopiSusu.tags = [ + { + tag: tagNew, + scope: 'variant', + variantIds: [large.id], + taggedAt: '2024-06-01T00:00:00.000Z', + }, + { + tag: tagBestSeller, + scope: 'product', + variantIds: [regular.id, large.id], + taggedAt: '2024-06-01T00:00:00.000Z', + }, + { + tag: tagRegular, + scope: 'product', + variantIds: [regular.id, large.id], + taggedAt: '2024-06-01T00:00:00.000Z', + }, + ]; + large.tags = [{ tag: tagNew, taggedAt: '2024-06-01T00:00:00.000Z' }]; + nasiGoreng.tags = [ + { + tag: tagBestSeller, + scope: 'product', + variantIds: [friedRice.id], + taggedAt: '2024-07-01T00:00:00.000Z', + }, + ]; + + return menuRepository; +}; + const renderHandler = ({ menuRepository = new MockMenuRepository(), tableRepository = new MockPublicTableRepository(), @@ -368,6 +446,150 @@ describe('MenuListHandler', () => { expect(screen.queryByText('0')).toBeNull(); }); + describe('highlight sections', () => { + const sectionTitle = (name: string) => screen.getByText(name); + + it('renders one section per highlighted tag above the category groups, in tag sortOrder', async () => { + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + const newTitle = sectionTitle('New'); + const bestSellerTitle = sectionTitle('Best Seller'); + const firstCategoryTitle = screen.getAllByText('Minuman')[1]; + + expect( + newTitle.compareDocumentPosition(bestSellerTitle) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect( + bestSellerTitle.compareDocumentPosition(firstCategoryTitle) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + }); + + it('does not render a section for a tag that is not highlighted', async () => { + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + expect(screen.queryByText('Regular Pick')).toBeNull(); + }); + + it('renders no sections when nothing is tagged', async () => { + renderHandler(); + await settle(); + + expect(screen.queryByText('New')).toBeNull(); + expect(screen.queryByText('Best Seller')).toBeNull(); + }); + + it('lists the most recently tagged entry first within a section', async () => { + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + const nasiGorengCard = screen.getAllByRole('button', { + name: 'Nasi Goreng', + })[0]; + const esKopiSusuCard = screen.getAllByRole('button', { + name: 'Es Kopi Susu', + })[0]; + + expect( + nasiGorengCard.compareDocumentPosition(esKopiSusuCard) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + }); + + it('shows a variant entry with the product and variant name and its own price', async () => { + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + expect( + screen.getByRole('button', { name: 'Es Kopi Susu · Es Kopi Susu - Large' }) + ).toBeTruthy(); + expect(screen.getAllByText('Rp 22.000').length).toBeGreaterThan(0); + }); + + it('hides the sections while a category chip is selected', async () => { + const user = userEvent.setup(); + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + await user.click(screen.getByRole('button', { name: 'Makanan' })); + await settle(); + + expect(screen.queryByText('New')).toBeNull(); + expect(screen.queryByText('Best Seller')).toBeNull(); + }); + + it('hides the sections while the search box has text', async () => { + const user = userEvent.setup(); + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + await user.type( + screen.getByPlaceholderText('Cari menu atau varian'), + 'kopi' + ); + + expect(screen.queryByText('New')).toBeNull(); + expect(screen.queryByText('Best Seller')).toBeNull(); + }); + + it('brings the sections back once the category filter is cleared', async () => { + const user = userEvent.setup(); + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + await user.click(screen.getByRole('button', { name: 'Makanan' })); + await settle(); + await user.click(screen.getByRole('button', { name: 'Semua' })); + await settle(); + + expect(screen.getByText('New')).toBeTruthy(); + }); + + it('omits entries that are sold out, and the whole section when none remain', async () => { + const menuRepository = createTaggedMenuRepository(); + menuRepository.variants[1].isSellable = false; + renderHandler({ menuRepository }); + await settle(); + + expect(screen.queryByText('New')).toBeNull(); + }); + + it('opens the item sheet for a product entry with nothing preselected', async () => { + const user = userEvent.setup(); + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + await user.click( + screen.getAllByRole('button', { name: 'Es Kopi Susu' })[0] + ); + await settle(); + + expect( + screen.getByRole('button', { name: 'Tambah ke Keranjang' }) + ).toBeTruthy(); + }); + + it('opens the item sheet with the tagged variant preselected, skipping straight to its price', async () => { + const user = userEvent.setup(); + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + await user.click( + screen.getByRole('button', { name: 'Es Kopi Susu · Es Kopi Susu - Large' }) + ); + await settle(); + + expect( + screen.getByRole('button', { + name: 'Tambah ke Keranjang · Rp 22.000', + }) + ).toBeTruthy(); + }); + }); + describe('the item sheet', () => { it('opens with no network request when a product card is pressed, and does not remount the menu', async () => { const user = userEvent.setup(); diff --git a/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx b/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx index 37df4ce6..10e54c95 100644 --- a/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx +++ b/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx @@ -13,8 +13,10 @@ import { MenuListUsecase } from '../../../domain/usecases/menuList'; import { PaymentCancelUsecase } from '../../../domain/usecases/paymentCancel'; import { TableResolveUsecase } from '../../../domain/usecases/tableResolve'; import { + buildTagHighlights, matchMenuSearch, resolveOptionValueAvailability, + TagHighlightEntry, } from '../../../utils'; import { CartBar } from '../../views/components/cart/CartBar'; import { PendingPaymentBar } from '../../views/components/cart/PendingPaymentBar'; @@ -51,8 +53,10 @@ function groupByCategory(products: Product[], categories: Category[]) { function computePreselectedOptionValueIds( query: string, - product: Product + product: Product, + variant: Variant | undefined ): number[] { + if (variant) return variant.values.map((value) => value.optionValueId); return matchMenuSearch(query, product).matchedOptionValues.map( (value) => value.id ); @@ -249,17 +253,20 @@ export const MenuListHandler = ({ }, [cart.state.type, cart.dispatch, menuItemDetail.state, menuList.dispatch]); useEffect(() => { - const { selectedProductId, query } = menuList.state; + const { selectedProductId, selectedVariantId, query } = menuList.state; if (selectedProductId !== null) { const product = menuList.state.products.find( (candidate) => candidate.id === selectedProductId ); + const variant = menuList.state.variants.find( + (candidate) => candidate.id === selectedVariantId + ); menuItemDetail.dispatch({ type: 'SELECT_PRODUCT', productId: selectedProductId, product, preselectedOptionValueIds: product - ? computePreselectedOptionValueIds(query, product) + ? computePreselectedOptionValueIds(query, product, variant) : [], }); } @@ -269,7 +276,11 @@ export const MenuListHandler = ({ // (and reset the draft's amount/note) whenever a background // revalidation resolves while the sheet is open. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [menuList.state.selectedProductId, menuItemDetail.dispatch]); + }, [ + menuList.state.selectedProductId, + menuList.state.selectedVariantId, + menuItemDetail.dispatch, + ]); const groups = groupByCategory( menuList.state.products, @@ -283,6 +294,13 @@ export const MenuListHandler = ({ (group) => group.category.id === menuList.state.selectedCategoryId ); + const shouldShowHighlightSections = + menuList.state.query === '' && menuList.state.selectedCategoryId === null; + + const highlightSections = shouldShowHighlightSections + ? buildTagHighlights(menuList.state.products, menuList.state.variants) + : []; + const startingPriceByProductId = computeStartingPriceByProductId( menuList.state.variants ); @@ -440,6 +458,14 @@ export const MenuListHandler = ({ onItemPress={(product: Product) => menuList.dispatch({ type: 'SELECT_ITEM', productId: product.id }) } + highlightSections={highlightSections} + onHighlightEntryPress={(entry: TagHighlightEntry) => + menuList.dispatch({ + type: 'SELECT_ITEM', + productId: entry.product.id, + variantId: entry.kind === 'variant' ? entry.variant.id : undefined, + }) + } startingPriceByProductId={startingPriceByProductId} matchedLabelsByProductId={matchedLabelsByProductId} variantNameById={computeVariantNameById(menuList.state.variants)} diff --git a/libs/ui/src/presentation/views/components/menu/MenuHighlightCard.stories.tsx b/libs/ui/src/presentation/views/components/menu/MenuHighlightCard.stories.tsx new file mode 100644 index 00000000..8305b10d --- /dev/null +++ b/libs/ui/src/presentation/views/components/menu/MenuHighlightCard.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { MenuHighlightCard } from './MenuHighlightCard'; + +const meta: Meta = { + title: 'Components/Menu/MenuHighlightCard', + component: MenuHighlightCard, + args: { + station: 'KITCHEN', + onPress: () => { + // Storybook action stand-in + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const ProductEntry: Story = { + args: { + name: 'Es Kopi Susu', + imageUrl: 'https://picsum.photos/200/200', + station: 'BAR', + price: 18000, + }, +}; + +export const VariantEntry: Story = { + args: { + name: 'Pancong', + variantName: 'Ice Cream', + imageUrl: 'https://picsum.photos/200/201', + price: 15000, + }, +}; + +export const WithoutImage: Story = { + args: { + name: 'Nasi Goreng', + price: 25000, + }, +}; + +export const FullWidth: Story = { + args: { + ...VariantEntry.args, + fullWidth: true, + }, +}; diff --git a/libs/ui/src/presentation/views/components/menu/MenuHighlightCard.tsx b/libs/ui/src/presentation/views/components/menu/MenuHighlightCard.tsx new file mode 100644 index 00000000..c3b6430a --- /dev/null +++ b/libs/ui/src/presentation/views/components/menu/MenuHighlightCard.tsx @@ -0,0 +1,60 @@ +import { Paragraph, Text, YStack } from 'tamagui'; +import { CategoryStation } from '../../../../domain/entities/Category'; +import { formatRupiah } from '../../../../utils/currency'; +import { MenuItemThumbnail } from './MenuItemThumbnail'; + +export type MenuHighlightCardProps = { + name: string; + variantName?: string; + imageUrl?: string; + station: CategoryStation; + price: number | null; + fullWidth?: boolean; + onPress: () => void; +}; + +export const MenuHighlightCard = ({ + name, + variantName, + imageUrl, + station, + price, + fullWidth = false, + onPress, +}: MenuHighlightCardProps) => { + const accessibilityLabel = variantName ? `${name} · ${variantName}` : name; + + return ( + + + + + {name} + + {variantName ? ( + + {variantName} + + ) : null} + {price !== null && {formatRupiah(price)}} + + + ); +}; diff --git a/libs/ui/src/presentation/views/components/menu/MenuHighlightSection.stories.tsx b/libs/ui/src/presentation/views/components/menu/MenuHighlightSection.stories.tsx new file mode 100644 index 00000000..bfe36234 --- /dev/null +++ b/libs/ui/src/presentation/views/components/menu/MenuHighlightSection.stories.tsx @@ -0,0 +1,88 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Product } from '../../../../domain/entities/Product'; +import { Variant } from '../../../../domain/entities/Variant'; +import { MenuHighlightSection } from './MenuHighlightSection'; + +const kitchen = { + id: 2, + name: 'Makanan', + station: 'KITCHEN' as const, + createdAt: '2024-03-20T00:00:00.000Z', +}; + +const tag = { + id: 1, + name: 'New', + color: 'green' as const, + isHighlighted: true, + sortOrder: 1, + variantCount: 2, + createdAt: '2024-03-20T00:00:00.000Z', +}; + +const pancong: Product = { + id: 1, + name: 'Pancong', + description: '', + category: kitchen, + imageUrl: 'https://picsum.photos/200/200', + saleType: 'purchase', + status: 'published', + options: [], + createdAt: '2024-03-20T00:00:00.000Z', + isAvailable: true, + availabilityTracking: 'none', + isSellable: true, + tags: [], +}; + +const iceCream: Variant = { + id: 1, + name: 'Ice Cream', + price: 15000, + imageUrl: 'https://picsum.photos/200/201', + materials: [], + product: pancong, + createdAt: '2024-03-20T00:00:00.000Z', + values: [], + pricingTiers: [], + isAvailable: true, + isSellable: true, + tags: [], +}; + +const nasiGoreng: Product = { + ...pancong, + id: 2, + name: 'Nasi Goreng', + imageUrl: '', +}; + +const meta: Meta = { + title: 'Components/Menu/MenuHighlightSection', + component: MenuHighlightSection, + args: { + tag, + startingPriceByProductId: { 1: 8000, 2: 25000 }, + onEntryPress: () => { + // Storybook action stand-in + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const MixedEntries: Story = { + args: { + entries: [ + { kind: 'variant', product: pancong, variant: iceCream }, + { kind: 'product', product: nasiGoreng }, + { + kind: 'variant', + product: pancong, + variant: { ...iceCream, id: 2, name: 'Chocolate', imageUrl: undefined }, + }, + ], + }, +}; diff --git a/libs/ui/src/presentation/views/components/menu/MenuHighlightSection.tsx b/libs/ui/src/presentation/views/components/menu/MenuHighlightSection.tsx new file mode 100644 index 00000000..82bf6586 --- /dev/null +++ b/libs/ui/src/presentation/views/components/menu/MenuHighlightSection.tsx @@ -0,0 +1,50 @@ +import { ScrollView, Text, XStack, YStack } from 'tamagui'; +import { Tag } from '../../../../domain/entities/Tag'; +import { TagHighlightEntry } from '../../../../utils/buildTagHighlights'; +import { MenuHighlightCard } from './MenuHighlightCard'; + +export type MenuHighlightSectionProps = { + tag: Tag; + entries: TagHighlightEntry[]; + startingPriceByProductId: Record; + onEntryPress: (entry: TagHighlightEntry) => void; +}; + +export const MenuHighlightSection = ({ + tag, + entries, + startingPriceByProductId, + onEntryPress, +}: MenuHighlightSectionProps) => ( + + + {tag.name} + + + + {entries.map((entry) => + entry.kind === 'variant' ? ( + onEntryPress(entry)} + /> + ) : ( + onEntryPress(entry)} + /> + ) + )} + + + +); diff --git a/libs/ui/src/presentation/views/components/menu/index.ts b/libs/ui/src/presentation/views/components/menu/index.ts index 0304958a..2574f6fc 100644 --- a/libs/ui/src/presentation/views/components/menu/index.ts +++ b/libs/ui/src/presentation/views/components/menu/index.ts @@ -1,3 +1,5 @@ export * from './MenuItemThumbnail'; export * from './OptionValueChipGroup'; export * from './AmountStepper'; +export * from './MenuHighlightCard'; +export * from './MenuHighlightSection'; diff --git a/libs/ui/src/presentation/views/screens/order/MenuListScreen.stories.tsx b/libs/ui/src/presentation/views/screens/order/MenuListScreen.stories.tsx index 704aef36..34d2bb52 100644 --- a/libs/ui/src/presentation/views/screens/order/MenuListScreen.stories.tsx +++ b/libs/ui/src/presentation/views/screens/order/MenuListScreen.stories.tsx @@ -73,6 +73,10 @@ const meta: Meta = { onItemPress: () => { // Storybook action stand-in }, + highlightSections: [], + onHighlightEntryPress: () => { + // Storybook action stand-in + }, startingPriceByProductId: { 1: 18000, 2: 25000 }, matchedLabelsByProductId: {}, variantNameById: {}, @@ -110,6 +114,67 @@ export const Loaded: Story = { }, }; +const newTag = { + id: 1, + name: 'New', + color: 'green' as const, + isHighlighted: true, + sortOrder: 1, + variantCount: 1, + createdAt: '2024-03-20T00:00:00.000Z', +}; + +const bestSellerTag = { + id: 2, + name: 'Best Seller', + color: 'orange' as const, + isHighlighted: true, + sortOrder: 2, + variantCount: 2, + createdAt: '2024-03-20T00:00:00.000Z', +}; + +const pancong = { + ...nasiGoreng, + id: 3, + name: 'Pancong', + imageUrl: 'https://picsum.photos/200/200', +}; + +const pancongIceCream = { + id: 4, + name: 'Ice Cream', + price: 15000, + imageUrl: 'https://picsum.photos/200/201', + materials: [], + product: pancong, + createdAt: '2024-03-20T00:00:00.000Z', + values: [], + pricingTiers: [], + isAvailable: true, + isSellable: true, + tags: [], +}; + +export const WithHighlightSections: Story = { + args: { + ...Loaded.args, + highlightSections: [ + { + tag: newTag, + entries: [{ kind: 'variant', product: pancong, variant: pancongIceCream }], + }, + { + tag: bestSellerTag, + entries: [ + { kind: 'product', product: esKopiSusu }, + { kind: 'product', product: nasiGoreng }, + ], + }, + ], + }, +}; + export const Searching: Story = { args: { ...Loaded.args, diff --git a/libs/ui/src/presentation/views/screens/order/MenuListScreen.tsx b/libs/ui/src/presentation/views/screens/order/MenuListScreen.tsx index 321897e9..a59fb923 100644 --- a/libs/ui/src/presentation/views/screens/order/MenuListScreen.tsx +++ b/libs/ui/src/presentation/views/screens/order/MenuListScreen.tsx @@ -3,6 +3,10 @@ import { Input, ScrollView, Spinner, Text, XStack, YStack } from 'tamagui'; import { match } from 'ts-pattern'; import { Category } from '../../../../domain/entities/Category'; import { Product } from '../../../../domain/entities/Product'; +import { + TagHighlight, + TagHighlightEntry, +} from '../../../../utils/buildTagHighlights'; import { EmptyView } from '../../components/base/EmptyView'; import { ErrorView } from '../../components/base/ErrorView'; import { Focusable } from '../../components/base/Focusable'; @@ -12,6 +16,7 @@ import { PaymentCancelAlertProps, } from '../../components/checkout/PaymentCancelAlert'; import { CategoryChipList } from '../../components/menu/CategoryChipList'; +import { MenuHighlightSection } from '../../components/menu/MenuHighlightSection'; import { MenuProductCard } from '../../components/menu/MenuProductCard'; import { MenuItemDetailScreen, @@ -45,6 +50,8 @@ export type MenuListScreenProps = { variant: MenuListScreenVariant; onRetryButtonPress: () => void; onItemPress: (product: Product) => void; + highlightSections: TagHighlight[]; + onHighlightEntryPress: (entry: TagHighlightEntry) => void; startingPriceByProductId: Record; matchedLabelsByProductId: Record; variantNameById: Record; @@ -67,6 +74,8 @@ export const MenuListScreen = ({ variant, onRetryButtonPress, onItemPress, + highlightSections, + onHighlightEntryPress, startingPriceByProductId, matchedLabelsByProductId, variantNameById, @@ -130,6 +139,15 @@ export const MenuListScreen = ({ .with({ type: 'loaded' }, ({ groups }) => ( + {highlightSections.map(({ tag, entries }) => ( + + ))} {groups.map(({ category, products }) => ( From 1f4af29790337b5fb874b8cb508a3f9603c4c23c Mon Sep 17 00:00:00 2001 From: M Nindra Zaka Date: Fri, 2 Oct 2026 10:30:25 +0700 Subject: [PATCH 14/15] feat(order): add highlighted-tag chips to the menu list (#678) Render one chip per highlighted tag with entries between Semua and the categories, in the tag's colour. Selecting a tag shows that tag's entries as a vertical list of full-width cards, newest-tagged first, replacing the category groups. MenuListUsecase gains selectedTagId, mutually exclusive with selectedCategoryId. Claude-Session: https://claude.ai/code/session_01TLnh5DyR2sQw11gzZEMbFW Co-authored-by: Claude --- libs/ui/src/domain/usecases/menuList.test.ts | 59 +++++- libs/ui/src/domain/usecases/menuList.ts | 9 + .../handlers/order/MenuListHandler.test.tsx | 183 ++++++++++++++++-- .../handlers/order/MenuListHandler.tsx | 54 ++++-- .../menu/CategoryChipList.stories.tsx | 37 ++++ .../components/menu/CategoryChipList.tsx | 32 ++- .../menu/MenuHighlightEntryCard.tsx | 36 ++++ .../components/menu/MenuHighlightSection.tsx | 36 ++-- .../views/components/menu/index.ts | 1 + .../screens/order/MenuListScreen.stories.tsx | 33 +++- .../views/screens/order/MenuListScreen.tsx | 39 +++- 11 files changed, 459 insertions(+), 60 deletions(-) create mode 100644 libs/ui/src/presentation/views/components/menu/MenuHighlightEntryCard.tsx diff --git a/libs/ui/src/domain/usecases/menuList.test.ts b/libs/ui/src/domain/usecases/menuList.test.ts index 77cf7b0e..181389c8 100644 --- a/libs/ui/src/domain/usecases/menuList.test.ts +++ b/libs/ui/src/domain/usecases/menuList.test.ts @@ -4,7 +4,10 @@ import { MenuListState, MenuListParams, } from './menuList'; -import { MockMenuListQueryRepository, MockMenuRepository } from '../../data/mock'; +import { + MockMenuListQueryRepository, + MockMenuRepository, +} from '../../data/mock'; import { UsecaseTester, flushPromises } from '../../utils/usecase'; const createTester = ( @@ -32,6 +35,7 @@ describe('MenuListUsecase', () => { variants: [], query: '', selectedCategoryId: null, + selectedTagId: null, selectedProductId: null, selectedVariantId: null, errorMessage: null, @@ -46,6 +50,7 @@ describe('MenuListUsecase', () => { variants: repository.variants, query: '', selectedCategoryId: null, + selectedTagId: null, selectedProductId: null, selectedVariantId: null, errorMessage: null, @@ -63,6 +68,7 @@ describe('MenuListUsecase', () => { variants: repository.variants, query: '', selectedCategoryId: null, + selectedTagId: null, selectedProductId: null, selectedVariantId: null, errorMessage: null, @@ -77,6 +83,7 @@ describe('MenuListUsecase', () => { variants: repository.variants, query: '', selectedCategoryId: 2, + selectedTagId: null, selectedProductId: null, selectedVariantId: null, errorMessage: null, @@ -121,6 +128,7 @@ describe('MenuListUsecase', () => { variants: [], query: '', selectedCategoryId: null, + selectedTagId: null, selectedProductId: null, selectedVariantId: null, errorMessage: 'Failed to fetch menu', @@ -166,6 +174,55 @@ describe('MenuListUsecase', () => { expect(menuList.state.categories).toEqual(categories); }); + describe('tag selection', () => { + it('clears the selected category when a tag is selected', async () => { + const menuList = createTester(new MockMenuRepository()); + await flushPromises(); + + menuList.dispatch({ type: 'CHANGE_PARAMS', selectedCategoryId: 2 }); + menuList.dispatch({ type: 'CHANGE_PARAMS', selectedTagId: 1 }); + + expect(menuList.state.selectedTagId).toBe(1); + expect(menuList.state.selectedCategoryId).toBeNull(); + }); + + it('clears the selected tag when a category is selected', async () => { + const menuList = createTester(new MockMenuRepository()); + await flushPromises(); + + menuList.dispatch({ type: 'CHANGE_PARAMS', selectedTagId: 1 }); + menuList.dispatch({ type: 'CHANGE_PARAMS', selectedCategoryId: 2 }); + + expect(menuList.state.selectedCategoryId).toBe(2); + expect(menuList.state.selectedTagId).toBeNull(); + }); + + it('clears both when null is given for both', async () => { + const menuList = createTester(new MockMenuRepository()); + await flushPromises(); + + menuList.dispatch({ type: 'CHANGE_PARAMS', selectedTagId: 1 }); + menuList.dispatch({ + type: 'CHANGE_PARAMS', + selectedCategoryId: null, + selectedTagId: null, + }); + + expect(menuList.state.selectedCategoryId).toBeNull(); + expect(menuList.state.selectedTagId).toBeNull(); + }); + + it('keeps the selected tag when only the query changes', async () => { + const menuList = createTester(new MockMenuRepository()); + await flushPromises(); + + menuList.dispatch({ type: 'CHANGE_PARAMS', selectedTagId: 1 }); + menuList.dispatch({ type: 'CHANGE_PARAMS', query: 'kopi' }); + + expect(menuList.state.selectedTagId).toBe(1); + }); + }); + describe('item selection', () => { it('holds the selected product id, from any fetch state, without changing it', async () => { const repository = new MockMenuRepository(); diff --git a/libs/ui/src/domain/usecases/menuList.ts b/libs/ui/src/domain/usecases/menuList.ts index b3074d02..79ec177d 100644 --- a/libs/ui/src/domain/usecases/menuList.ts +++ b/libs/ui/src/domain/usecases/menuList.ts @@ -10,6 +10,7 @@ type Context = { variants: Variant[]; query: string; selectedCategoryId: number | null; + selectedTagId: number | null; selectedProductId: number | null; selectedVariantId: number | null; errorMessage: string | null; @@ -39,6 +40,7 @@ export type MenuListAction = type: 'CHANGE_PARAMS'; query?: string; selectedCategoryId?: number | null; + selectedTagId?: number | null; fetchDebounceDelay?: number; } | { @@ -87,6 +89,7 @@ export class MenuListUsecase extends Usecase< variants: this.params.variants ?? [], query: '', selectedCategoryId: null, + selectedTagId: null, selectedProductId: this.params.selectedProductId ?? this.menuListQueryRepository.getSelectedProductId(), @@ -141,6 +144,12 @@ export class MenuListUsecase extends Usecase< ([state, { type: _type, fetchDebounceDelay = 0, ...params }]) => ({ ...state, ...params, + ...(typeof params.selectedTagId === 'number' && { + selectedCategoryId: null, + }), + ...(typeof params.selectedCategoryId === 'number' && { + selectedTagId: null, + }), fetchDebounceDelay, type: 'changingParams', }) diff --git a/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx b/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx index a3d3d32e..288c1b96 100644 --- a/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx +++ b/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx @@ -301,7 +301,6 @@ describe('MenuListHandler', () => { await settle(); expect(screen.getByText('New · Es Kopi Susu - Large')).toBeTruthy(); - expect(screen.queryByText('New')).toBeNull(); }); it('filters to a single category when its chip is pressed', async () => { @@ -447,7 +446,11 @@ describe('MenuListHandler', () => { }); describe('highlight sections', () => { - const sectionTitle = (name: string) => screen.getByText(name); + const sectionTitle = (name: string) => screen.getAllByText(name)[1]; + const largeVariantCard = () => + screen.queryByRole('button', { + name: 'Es Kopi Susu · Es Kopi Susu - Large', + }); it('renders one section per highlighted tag above the category groups, in tag sortOrder', async () => { renderHandler({ menuRepository: createTaggedMenuRepository() }); @@ -504,7 +507,9 @@ describe('MenuListHandler', () => { await settle(); expect( - screen.getByRole('button', { name: 'Es Kopi Susu · Es Kopi Susu - Large' }) + screen.getByRole('button', { + name: 'Es Kopi Susu · Es Kopi Susu - Large', + }) ).toBeTruthy(); expect(screen.getAllByText('Rp 22.000').length).toBeGreaterThan(0); }); @@ -517,8 +522,7 @@ describe('MenuListHandler', () => { await user.click(screen.getByRole('button', { name: 'Makanan' })); await settle(); - expect(screen.queryByText('New')).toBeNull(); - expect(screen.queryByText('Best Seller')).toBeNull(); + expect(largeVariantCard()).toBeNull(); }); it('hides the sections while the search box has text', async () => { @@ -531,8 +535,7 @@ describe('MenuListHandler', () => { 'kopi' ); - expect(screen.queryByText('New')).toBeNull(); - expect(screen.queryByText('Best Seller')).toBeNull(); + expect(largeVariantCard()).toBeNull(); }); it('brings the sections back once the category filter is cleared', async () => { @@ -545,7 +548,7 @@ describe('MenuListHandler', () => { await user.click(screen.getByRole('button', { name: 'Semua' })); await settle(); - expect(screen.getByText('New')).toBeTruthy(); + expect(largeVariantCard()).toBeTruthy(); }); it('omits entries that are sold out, and the whole section when none remain', async () => { @@ -578,7 +581,110 @@ describe('MenuListHandler', () => { await settle(); await user.click( - screen.getByRole('button', { name: 'Es Kopi Susu · Es Kopi Susu - Large' }) + screen.getByRole('button', { + name: 'Es Kopi Susu · Es Kopi Susu - Large', + }) + ); + await settle(); + + expect( + screen.getByRole('button', { + name: 'Tambah ke Keranjang · Rp 22.000', + }) + ).toBeTruthy(); + }); + }); + + describe('highlighted-tag chips', () => { + const chipNames = () => + screen + .getAllByRole('button') + .map((button) => button.textContent) + .filter((text) => + [ + 'Semua', + 'New', + 'Best Seller', + 'Regular Pick', + 'Minuman', + 'Makanan', + ].includes(text ?? '') + ); + + it('renders a chip per non-empty highlighted tag between Semua and the categories', async () => { + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + expect(chipNames().slice(0, 5)).toEqual([ + 'Semua', + 'New', + 'Best Seller', + 'Minuman', + 'Makanan', + ]); + }); + + it('renders no tag chip for a tag that is not highlighted', async () => { + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + expect(screen.queryByRole('button', { name: 'Regular Pick' })).toBeNull(); + }); + + it('renders no tag chip for a tag whose entries are all sold out', async () => { + const menuRepository = createTaggedMenuRepository(); + menuRepository.variants[1].isSellable = false; + renderHandler({ menuRepository }); + await settle(); + + expect(screen.queryByRole('button', { name: 'New' })).toBeNull(); + expect(screen.getByRole('button', { name: 'Best Seller' })).toBeTruthy(); + }); + + it('renders no tag chip when nothing is tagged', async () => { + renderHandler(); + await settle(); + + expect(screen.queryByRole('button', { name: 'New' })).toBeNull(); + }); + + it('shows only that tag’s entries as a list, newest first, replacing sections and category groups', async () => { + const user = userEvent.setup(); + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + await user.click(screen.getByRole('button', { name: 'Best Seller' })); + await settle(); + + const nasiGorengCard = screen.getByRole('button', { + name: 'Nasi Goreng', + }); + const esKopiSusuCard = screen.getByRole('button', { + name: 'Es Kopi Susu', + }); + expect( + nasiGorengCard.compareDocumentPosition(esKopiSusuCard) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect( + screen.queryByRole('button', { + name: 'Es Kopi Susu · Es Kopi Susu - Large', + }) + ).toBeNull(); + expect(screen.getAllByText('Minuman')).toHaveLength(1); + }); + + it('opens the item sheet with the variant preselected from a tag list entry', async () => { + const user = userEvent.setup(); + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + await user.click(screen.getByRole('button', { name: 'New' })); + await settle(); + await user.click( + screen.getByRole('button', { + name: 'Es Kopi Susu · Es Kopi Susu - Large', + }) ); await settle(); @@ -588,6 +694,57 @@ describe('MenuListHandler', () => { }) ).toBeTruthy(); }); + + it('replaces the tag list with a category when a category chip is pressed', async () => { + const user = userEvent.setup(); + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + await user.click(screen.getByRole('button', { name: 'New' })); + await settle(); + await user.click(screen.getByRole('button', { name: 'Makanan' })); + await settle(); + + expect(screen.getAllByText('Makanan')).toHaveLength(2); + expect(screen.getByText('Nasi Goreng')).toBeTruthy(); + expect(screen.queryByText('Es Kopi Susu')).toBeNull(); + }); + + it('replaces the category with the tag list when a tag chip is pressed', async () => { + const user = userEvent.setup(); + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + await user.click(screen.getByRole('button', { name: 'Makanan' })); + await settle(); + await user.click(screen.getByRole('button', { name: 'New' })); + await settle(); + + expect(screen.queryByText('Nasi Goreng')).toBeNull(); + expect( + screen.getByRole('button', { + name: 'Es Kopi Susu · Es Kopi Susu - Large', + }) + ).toBeTruthy(); + }); + + it('brings back the sections and category groups when Semua is pressed', async () => { + const user = userEvent.setup(); + renderHandler({ menuRepository: createTaggedMenuRepository() }); + await settle(); + + await user.click(screen.getByRole('button', { name: 'New' })); + await settle(); + await user.click(screen.getByRole('button', { name: 'Semua' })); + await settle(); + + expect( + screen.getByRole('button', { + name: 'Es Kopi Susu · Es Kopi Susu - Large', + }) + ).toBeTruthy(); + expect(screen.getAllByText('Makanan')).toHaveLength(2); + }); }); describe('the item sheet', () => { @@ -826,9 +983,7 @@ describe('MenuListHandler', () => { await user.click(screen.getByText('Es Kopi Susu')); await settle(); - expect( - screen.getByText(/pembayaran yang belum selesai/) - ).toBeTruthy(); + expect(screen.getByText(/pembayaran yang belum selesai/)).toBeTruthy(); expect( screen.queryByRole('button', { name: /Tambah ke Keranjang/ }) ).toBeNull(); @@ -972,9 +1127,7 @@ describe('MenuListHandler', () => { expect(cancelSpy).not.toHaveBeenCalled(); expect(screen.getByLabelText('Tutup')).toBeTruthy(); - expect( - screen.getByText(/pembayaran yang belum selesai/) - ).toBeTruthy(); + expect(screen.getByText(/pembayaran yang belum selesai/)).toBeTruthy(); }); }); }); diff --git a/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx b/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx index 10e54c95..dd7e2b93 100644 --- a/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx +++ b/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx @@ -216,9 +216,8 @@ export const MenuListHandler = ({ method: pendingPayment.method, } ); - const handledCancelResultRef = useRef< - typeof paymentCancel.state.result - >(null); + const handledCancelResultRef = + useRef(null); const isAddAfterCancelPendingRef = useRef(false); useEffect(() => { @@ -294,12 +293,22 @@ export const MenuListHandler = ({ (group) => group.category.id === menuList.state.selectedCategoryId ); + const allHighlights = buildTagHighlights( + menuList.state.products, + menuList.state.variants + ); + + const selectedTagHighlight = + allHighlights.find( + (highlight) => highlight.tag.id === menuList.state.selectedTagId + ) ?? null; + const shouldShowHighlightSections = - menuList.state.query === '' && menuList.state.selectedCategoryId === null; + menuList.state.query === '' && + menuList.state.selectedCategoryId === null && + menuList.state.selectedTagId === null; - const highlightSections = shouldShowHighlightSections - ? buildTagHighlights(menuList.state.products, menuList.state.variants) - : []; + const highlightSections = shouldShowHighlightSections ? allHighlights : []; const startingPriceByProductId = computeStartingPriceByProductId( menuList.state.variants @@ -324,9 +333,7 @@ export const MenuListHandler = ({ ) : currentCart && currentCart.itemCount > 0 ? ( - router.push(`/t/${tableResolveUsecase.params.code}/cart`) - } + onPress={() => router.push(`/t/${tableResolveUsecase.params.code}/cart`)} /> ) : null; @@ -451,6 +458,16 @@ export const MenuListHandler = ({ menuList.dispatch({ type: 'CHANGE_PARAMS', selectedCategoryId: categoryId, + selectedTagId: null, + fetchDebounceDelay: 0, + }) + } + chipTags={allHighlights.map((highlight) => highlight.tag)} + selectedTagId={menuList.state.selectedTagId} + onSelectTag={(tagId: number) => + menuList.dispatch({ + type: 'CHANGE_PARAMS', + selectedTagId: tagId, fetchDebounceDelay: 0, }) } @@ -483,10 +500,19 @@ export const MenuListHandler = ({ })) .with( { type: P.union('changingParams', 'loaded', 'revalidating') }, - () => ({ - type: visibleGroups.length > 0 ? 'loaded' : 'empty', - groups: visibleGroups, - }) + () => + menuList.state.selectedTagId !== null + ? selectedTagHighlight + ? { + type: 'tagLoaded', + tag: selectedTagHighlight.tag, + entries: selectedTagHighlight.entries, + } + : { type: 'empty' } + : { + type: visibleGroups.length > 0 ? 'loaded' : 'empty', + groups: visibleGroups, + } ) .with({ type: 'error' }, () => ({ type: 'error' })) .exhaustive()} diff --git a/libs/ui/src/presentation/views/components/menu/CategoryChipList.stories.tsx b/libs/ui/src/presentation/views/components/menu/CategoryChipList.stories.tsx index c0ee7aa7..27547f9e 100644 --- a/libs/ui/src/presentation/views/components/menu/CategoryChipList.stories.tsx +++ b/libs/ui/src/presentation/views/components/menu/CategoryChipList.stories.tsx @@ -1,6 +1,27 @@ import type { Meta, StoryObj } from '@storybook/react'; import { CategoryChipList } from './CategoryChipList'; +const tags = [ + { + id: 1, + name: 'New', + color: 'green' as const, + isHighlighted: true, + sortOrder: 1, + variantCount: 2, + createdAt: '', + }, + { + id: 2, + name: 'Best Seller', + color: 'orange' as const, + isHighlighted: true, + sortOrder: 2, + variantCount: 3, + createdAt: '', + }, +]; + const meta: Meta = { title: 'Components/Menu/CategoryChipList', component: CategoryChipList, @@ -27,3 +48,19 @@ export const CategorySelected: Story = { selectedCategoryId: 2, }, }; + +export const WithTagChips: Story = { + args: { + selectedCategoryId: null, + tags, + selectedTagId: null, + }, +}; + +export const TagSelected: Story = { + args: { + selectedCategoryId: null, + tags, + selectedTagId: 2, + }, +}; diff --git a/libs/ui/src/presentation/views/components/menu/CategoryChipList.tsx b/libs/ui/src/presentation/views/components/menu/CategoryChipList.tsx index 565e8a18..860137b9 100644 --- a/libs/ui/src/presentation/views/components/menu/CategoryChipList.tsx +++ b/libs/ui/src/presentation/views/components/menu/CategoryChipList.tsx @@ -1,28 +1,58 @@ import { Button, ScrollView, XStack } from 'tamagui'; import { Category } from '../../../../domain/entities/Category'; +import { Tag } from '../../../../domain/entities/Tag'; +import { tagColorBackground, tagColorForeground } from '../tags/tagColors'; export type CategoryChipListProps = { categories: Category[]; selectedCategoryId: number | null; onSelectCategory: (categoryId: number | null) => void; + tags?: Tag[]; + selectedTagId?: number | null; + onSelectTag?: (tagId: number) => void; }; export const CategoryChipList = ({ categories, selectedCategoryId, onSelectCategory, + tags = [], + selectedTagId = null, + onSelectTag, }: CategoryChipListProps) => { + const isAllSelected = selectedCategoryId === null && selectedTagId === null; + return ( + {tags.map((tag) => { + const isSelected = selectedTagId === tag.id; + return ( + + ); + })} {categories.map((category) => (