B2B Product
Catalog App
Field sales reps need instant access to live product data, stock levels, and order history, on a tablet, between meetings, without the patience for a slow enterprise portal. This project builds that in three screens using Power Apps Canvas, Dataverse, and AI-assisted authoring with Claude Code and the canvas-apps MCP plugin.
How it’s structured
The app is built on two custom Dataverse tables inside a dedicated ProductCatalog solution. All front-end logic lives in a three-screen Canvas App. State between screens is carried by a single global variable. All filtering runs against in-memory collections loaded at startup, never against Dataverse directly, which eliminates delegation errors entirely.
colProducts, colOrders). All gallery filtering happens against these in-memory collections, never directly against Dataverse. When an order is submitted, Dataverse is written to via Patch and the local collections are immediately refreshed. Navigation state is carried by a single global variable, varSelectedProduct, read by both DetailScreen and OrderScreen.Environment setup
All tables, the app, and any future flows live inside a single unmanaged ProductCatalog solution. Solutions matter for two reasons: publisher prefix isolation: every custom column and table gets the saeid_ prefix rather than the generic cr_ prefix, keeping the schema readable, and portability, so the solution can be exported and deployed to staging or production as a managed solution.
The data model
saeid_product)| Display Name | Logical Name | Type | Notes |
|---|---|---|---|
| Product Name | saeid_name | Text | Primary name column |
| SKU | saeid_sku | Text | Unique product code |
| Price | saeid_price | Currency | Tracks base currency automatically |
| Stock | saeid_stock | Whole Number | Must use Int() when writing via Patch |
| Category | saeid_category | Choice | Electronics, Tools, Machinery, Safety Equipment, Consumables |
| Description | saeid_description | Text (multiline) | |
| Is Active | saeid_isactive | Choice | Filter inactive products |
saeid_order)| Display Name | Logical Name | Type | Notes |
|---|---|---|---|
| Order Name | saeid_name | Text | Auto-generated: ORD- + timestamp |
| Customer Name | saeid_customername | Text | |
| Customer Email | saeid_customeremail | Text | |
| Quantity | saeid_quantity | Whole Number | |
| Total Price | saeid_totalprice | Currency | Calculated on submit: qty × price |
| Order Status | saeid_orderstatus | Choice | Pending, Confirmed, Shipped, Delivered |
| Product | saeid_ProductId | Lookup → Products | The key relationship: enables per-product order history |
saeid_ProductId lookup creates a many-to-one relationship between Orders and Products. This is what makes the DetailScreen order history gallery possible: filtering orders by saeid_ProductId.saeid_productid = varSelectedProduct.saeid_productid traverses the relationship using GUIDs. Without this relationship, you’d be doing string matching, which is fragile and slow.HomeScreen: the catalog
HomeScreen combines a category filter bar, a search field, and a scrollable product gallery. The key architectural decision is made here in App.OnStart: collecting both tables into local collections before the gallery ever renders.
Set(varCategory, "All");
Set(varSearch, "");
ClearCollect(colProducts, Products);
ClearCollect(colOrders, Orders);
Set(varSelectedProduct, First(colProducts))colProducts at startup, all subsequent filtering runs entirely in-memory. No delegation limits apply and the compiler never raises a delegation error against a local collection. varSelectedProduct is initialised to First(colProducts) so DetailScreen and OrderScreen always have a valid record, even before the user makes a selection.The design system is defined once as named formulas, allowing the same color reference across all screen-level controls. One critical rule: named formulas must never be referenced inside gallery template children: controls nested inside a Gallery’s Children block do not resolve named formulas at runtime. All gallery template controls use explicit RGBA() values instead.
clrBg = RGBA(10,12,20,1);
clrCard = RGBA(20,24,38,1);
clrBorder = RGBA(40,48,72,1);
clrAccent = RGBA(79,140,255,1);
clrSuccess = RGBA(34,197,94,1);
clrWarning = RGBA(234,179,8,1);
clrDanger = RGBA(239,68,68,1);
clrText = RGBA(255,255,255,1);
clrTextSec = RGBA(148,163,184,1)The gallery Items formula handles three cases: All products, Safety Equipment (which requires special treatment), and any named category. The Safety Equipment case uses exclusion rather than a direct match because choice option values that contain spaces don’t have a resolvable identifier in Power Fx: 'Category (Products)'.'Safety Equipment' fails compilation. Excluding all other categories captures it correctly by elimination.
If(varCategory = "All",
Filter(colProducts, varSearch = "" || StartsWith(saeid_name, varSearch)),
If(varCategory = "Safety Equipment",
Filter(colProducts,
Not(saeid_category = 'Category (Products)'.Electronics ||
saeid_category = 'Category (Products)'.Tools ||
saeid_category = 'Category (Products)'.Machinery ||
saeid_category = 'Category (Products)'.Consumables),
varSearch = "" || StartsWith(saeid_name, varSearch)
),
Filter(colProducts,
saeid_category = Switch(varCategory,
"Electronics", 'Category (Products)'.Electronics,
"Tools", 'Category (Products)'.Tools,
"Machinery", 'Category (Products)'.Machinery,
"Consumables", 'Category (Products)'.Consumables
),
varSearch = "" || StartsWith(saeid_name, varSearch)
)
)
)Text() applied to a choice column value from a collected record returns the internal numeric code, for example "2", not the display label. Always compare the field directly against the Power Fx enum literal: saeid_category = 'Category (Products)'.Electronics. This works correctly on both live Dataverse records and collected records.DetailScreen: product detail and order history
DetailScreen presents the complete product record alongside a live list of recent orders for that product. It is divided into two panels: left for product details (608px), right for the orders gallery (694px). varSelectedProduct is set in HomeScreen before navigation, so it’s ready the moment the screen loads, with no on-screen trigger or data load required.
The stock level uses a three-tier colour system to give immediate visual status without any additional UI element:
Color: =If(
varSelectedProduct.saeid_stock > 20, RGBA(34,197,94,1), // green: healthy stock
If(
varSelectedProduct.saeid_stock > 0, RGBA(234,179,8,1), // amber: low stock
RGBA(239,68,68,1) // red: out of stock
)
)The orders gallery filters colOrders using the product’s primary key. This is the correct pattern for filtering through a lookup relationship. Never filter by name or text string, always by GUID:
Items: =Filter(colOrders,
saeid_ProductId.saeid_productid = varSelectedProduct.saeid_productid
)
// saeid_ProductId: the lookup column on the Orders table
// .saeid_productid: traverses the relationship to the related product's PK
// varSelectedProduct.saeid_productid: the PK of the currently selected productOrderScreen: order submission
OrderScreen is split into two panels. The left panel shows read-only product context from varSelectedProduct (name, price per unit, available stock, SKU), so the rep can confirm they’re ordering the right item. The estimated total updates in real time as they type a quantity. The right panel contains the editable order form.
Text: =If(
IsBlank(txtQuantity.Text) || Value(txtQuantity.Text) <= 0,
"—",
"£" & Text(Value(txtQuantity.Text) * varSelectedProduct.saeid_price, "#,##0.00")
)Text: ="⚠ Quantity exceeds available stock (" & Text(varSelectedProduct.saeid_stock) & " units)"
Visible: =Value(txtQuantity.Text) > varSelectedProduct.saeid_stockThe submit button executes five operations in a single formula. Each step must succeed before the next runs. Order matters: validate first, write the order second, reduce stock third, refresh collections fourth, then navigate.
=If(
IsBlank(txtCustomerName.Text) || IsBlank(txtCustomerEmail.Text) ||
IsBlank(txtQuantity.Text) || Value(txtQuantity.Text) <= 0,
Notify("Please fill in all required fields", NotificationType.Warning),
// Step 1: Create the order record
Patch(Orders, Defaults(Orders), {
saeid_name: "ORD-" & Text(Now(), "yyyyMMddHHmmss"),
saeid_customername: txtCustomerName.Text,
saeid_customeremail: txtCustomerEmail.Text,
saeid_quantity: Value(txtQuantity.Text),
saeid_totalprice: Value(txtQuantity.Text) * varSelectedProduct.saeid_price,
saeid_ProductId: LookUp(Products, saeid_productid = varSelectedProduct.saeid_productid)
});
// Step 2: Reduce stock on the product record
IfError(
Patch(
Products,
LookUp(Products, saeid_productid = varSelectedProduct.saeid_productid),
{saeid_stock: varSelectedProduct.saeid_stock - Int(Value(txtQuantity.Text))}
),
Notify("Stock update failed: " & FirstError.Message, NotificationType.Error)
);
// Step 3: Refresh both collections from Dataverse
ClearCollect(colProducts, Products);
Set(varSelectedProduct, LookUp(colProducts, saeid_productid = varSelectedProduct.saeid_productid));
ClearCollect(colOrders, Orders);
// Step 4: Confirm and navigate home
Notify("Order placed successfully!", NotificationType.Success);
Navigate(HomeScreen, ScreenTransition.Fade)
)- 1Validate: if any required field is blank or quantity is zero, show a warning and stop. No Dataverse writes happen.
- 2Create the order:
Patch(Orders, Defaults(Orders), {...})writes a new record. The product lookup field receives a live Dataverse reference fromLookUp(Products, ...), not a collection variable. - 3Reduce stock:
Int()ensures an integer value is written to the Whole Number field. Wrapped inIfErrorso any Dataverse rejection surfaces as a visible notification rather than failing silently. - 4Refresh collections: pulls updated data from Dataverse so the new stock level is immediately in memory. Re-sets
varSelectedProductfrom the refreshed collection so DetailScreen shows the correct stock if the user navigates back. - 5Navigate: success notification, then back to HomeScreen.
Key Power Fx patterns
// Correct: compare directly against the enum literal
saeid_category = 'Category (Products)'.Electronics
// Incorrect: Text() returns the numeric code ("2") not the label
Text(saeid_category) = "Electronics"// Correct: LookUp returns a live Dataverse entity reference
Patch(
Products,
LookUp(Products, saeid_productid = varSelectedProduct.saeid_productid),
{saeid_stock: varSelectedProduct.saeid_stock - Int(Value(txtQuantity.Text))}
)
// Incorrect: varSelectedProduct is a copy from a collection, not a live reference
Patch(Products, varSelectedProduct, {saeid_stock: newValue})IfError(
Patch(Products, LookUp(Products, ...), {saeid_stock: newValue}),
Notify("Update failed: " & FirstError.Message, NotificationType.Error)
)// Correct inside a Gallery Children block
Color: =RGBA(255,255,255,1)
// Will not resolve correctly inside gallery template
Color: =clrTextClaude Code + canvas-apps MCP plugin
The canvas-apps MCP plugin connects Claude Code directly to a live Power Apps Studio coauthoring session. Instead of writing formulas in the Studio formula bar, which gives no autocomplete for Dataverse logical names and silently accepts delegation errors, Claude Code writes .pa.yaml files locally and compiles them directly into the live session.
2. In the terminal:
claude --plugin-dir /path/to/plugins/canvas-apps3. Run
/configure-canvas-mcp and paste the Studio URL from the browser address barChanges appear in Studio without a manual import or publish step.
BasePaletteColor for buttons, not Fill.Any formula string containing a colon followed by a space, such as "SKU: ", must use a YAML block scalar rather than an inline value to avoid a parse error:
# Correct: YAML block scalar avoids parse error
Text: |-
="SKU: " & varSelectedProduct.saeid_sku
# Will cause a YAML parse error
Text: ="SKU: " & varSelectedProduct.saeid_skuWhat the finished app delivers
The ProductCatalogApp gives B2B sales reps a complete product management and order capture flow across three screens, all within the Microsoft 365 ecosystem, with no additional infrastructure, no external APIs, and security inherited from the existing Azure Active Directory tenant.
- →HomeScreen: instant product search by name, one-tap category filtering across five categories, real-time product count, card gallery loading on startup without any manual refresh
- →DetailScreen: full product specification with colour-coded stock status (green/amber/red) and a live feed of all orders placed against that product
- →OrderScreen: guided order form that pre-fills product context, calculates total in real time, warns when quantity exceeds stock, and writes both an order record and a stock reduction to Dataverse atomically on submission