B2B Product Catalog App with Power Apps

B2B Product Catalog App — Saeid Kajkolah
Power Platform Canvas App Dataverse Claude Code + MCP

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.

2
Dataverse tables, relational model
3
Screens — catalog, detail, order
5
Operations executed on order submit
0
Delegation errors — all filtering in-memory
Stack Power Apps Dataverse Power Fx Claude Code canvas-apps MCP
Architecture

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.

Screen 1
HomeScreen
Searchable, filterable product catalog gallery with category pills
Screen 2
DetailScreen
Full product specs, stock status, and per-product order history
Screen 3
OrderScreen
Order form with live total, stock warning, and atomic Dataverse write
Data flow
On app start, the entire Products and Orders tables are loaded into local Power Fx collections (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.
Step 1

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.

Why Dataverse over SharePoint
SharePoint — not chosen
Lookup columns but no enforced foreign key relationships. Delegation stops at 2,000 rows for most filters. Permissions are list-level only. OptionSet columns don’t exist — just text strings.
Dataverse — chosen
Native table relationships with referential integrity. Full server-side delegation for supported column types. Row-level security per record. Choice columns carry typed enum values for type-safe Power Fx comparisons.
Step 2

The data model

Products table (saeid_product)
Display NameLogical NameTypeNotes
Product Namesaeid_nameTextPrimary name column
SKUsaeid_skuTextUnique product code
Pricesaeid_priceCurrencyTracks base currency automatically
Stocksaeid_stockWhole NumberMust use Int() when writing via Patch
Categorysaeid_categoryChoiceElectronics, Tools, Machinery, Safety Equipment, Consumables
Descriptionsaeid_descriptionText (multiline)
Is Activesaeid_isactiveChoiceFilter inactive products
Orders table (saeid_order)
Display NameLogical NameTypeNotes
Order Namesaeid_nameTextAuto-generated: ORD- + timestamp
Customer Namesaeid_customernameText
Customer Emailsaeid_customeremailText
Quantitysaeid_quantityWhole Number
Total Pricesaeid_totalpriceCurrencyCalculated on submit: qty × price
Order Statussaeid_orderstatusChoicePending, Confirmed, Shipped, Delivered
Productsaeid_ProductIdLookup → ProductsThe key relationship — enables per-product order history
Why the Product lookup column matters
The 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.
Step 3

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.

App.OnStart — the ClearCollect pattern
App.OnStart
Set(varCategory, “All”); Set(varSearch, “”); ClearCollect(colProducts, Products); ClearCollect(colOrders, Orders); Set(varSelectedProduct, First(colProducts))
Why ClearCollect instead of querying Dataverse directly
When you filter a data source directly in a gallery, the filter must be expressible as a server-side query. Many filter operations — including multi-condition OptionSet comparisons — are not delegatable. When the compiler detects a non-delegatable filter on a Dataverse table, it fails compilation with an error, not a warning. By collecting the entire table into 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.
Named formulas in App.Formulas

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.

App.Formulas — design tokens
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)
Gallery filter formula

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.

galProducts — Items formula
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) ) ) )
Why compare against the enum, not Text(saeid_category)
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.
Step 4

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 — no on-screen trigger or data load required.

Stock colour coding

The stock level uses a three-tier colour system to give immediate visual status without any additional UI element:

lblStock — Color formula
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 ) )
Recent orders gallery — GUID-based filtering

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:

galOrders — Items formula
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 product
Step 5

OrderScreen — 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.

Real-time total calculation
Estimated total — live as quantity is typed
Text: =If( IsBlank(txtQuantity.Text) || Value(txtQuantity.Text) <= 0, “—”, “£” & Text(Value(txtQuantity.Text) * varSelectedProduct.saeid_price, “#,##0.00”) )
Stock warning — automatic visibility
Stock warning label
Text: =“⚠ Quantity exceeds available stock (“ & Text(varSelectedProduct.saeid_stock) & ” units)” Visible: =Value(txtQuantity.Text) > varSelectedProduct.saeid_stock
The submit sequence — 5 operations in order

The 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.

btnSubmit — OnSelect (full sequence)
=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) )
  • 1
    Validate — if any required field is blank or quantity is zero, show a warning and stop. No Dataverse writes happen.
  • 2
    Create the order — Patch(Orders, Defaults(Orders), {...}) writes a new record. The product lookup field receives a live Dataverse reference from LookUp(Products, ...), not a collection variable.
  • 3
    Reduce stock — Int() ensures an integer value is written to the Whole Number field. Wrapped in IfError so any Dataverse rejection surfaces as a visible notification rather than failing silently.
  • 4
    Refresh collections — pulls updated data from Dataverse so the new stock level is immediately in memory. Re-sets varSelectedProduct from the refreshed collection so DetailScreen shows the correct stock if the user navigates back.
  • 5
    Navigate — success notification, then back to HomeScreen.
Reference

Key Power Fx patterns

OptionSet comparison — always use the enum literal
Text() returns the numeric code from a collected record, not the label
// 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”
Patch with a live record reference, not a collection variable
LookUp(DataSource) returns a live entity reference; a collection variable is a copy
// 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 around every update Patch
Dataverse can reject a Patch silently — IfError makes failures visible
IfError( Patch(Products, LookUp(Products, …), {saeid_stock: newValue}), Notify(“Update failed: “ & FirstError.Message, NotificationType.Error) )
Gallery template controls must use explicit RGBA()
Named formulas from App.Formulas do not resolve inside Gallery Children blocks
// Correct inside a Gallery Children block Color: =RGBA(255,255,255,1) // Will not resolve correctly inside gallery template Color: =clrText
Tooling

Claude 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.

Setup — three steps
1. Open the app in Power Apps Studio and enable coauthoring — Settings → Updates → Coauthoring

2. In the terminal: claude --plugin-dir /path/to/plugins/canvas-apps

3. Run /configure-canvas-mcp and paste the Studio URL from the browser address bar

Changes appear in Studio without a manual import or publish step.
What the MCP server makes possible
compile_canvas
Validates YAML files against the live authoring session and commits them to Studio. Fails on delegation warnings and schema mismatches — a reliable correctness gate, not a silent acceptor.
sync_canvas
Pulls the current server state back to disk. Normalises YAML properties that Studio manages (default values, Z-order) and ensures local files reflect exactly what Studio holds.
get_data_source_schema
Returns every column name and Power Fx type for a connected Dataverse table. Enables formula authoring against verified column names before a single compile — no guessing logical names.
list_controls + describe_control
Provide control property schemas so the correct property names are used from the start — for example BasePaletteColor for buttons, not Fill.
One critical YAML syntax rule

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:

YAML — block scalar for colon-space strings
# Correct — YAML block scalar avoids parse error Text: |- =“SKU: “ & varSelectedProduct.saeid_sku # Will cause a YAML parse error Text: =“SKU: “ & varSelectedProduct.saeid_sku
Result

What 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
Natural extensions
Order management screen
A fourth screen showing all orders across all products, filterable by status, with the ability to update from Pending to Confirmed or Shipped.
Power Automate notifications
A cloud flow triggered on new order creation that sends a confirmation email to the customer and an internal alert to the fulfilment team.
Stock alerts
A flow that monitors the Products table and notifies the purchasing team when stock falls below a configurable threshold.
Barcode scanning
Power Apps has a built-in barcode scanner control that could pre-fill the SKU search on HomeScreen for warehouse or showroom use.
Role-based views
Dataverse security roles can restrict which products a rep sees based on territory or product line assignment — no formula changes in the app.
Power BI reporting
The Orders and Products tables in Dataverse connect directly to Power BI — sales dashboards, stock trend reports, and order velocity charts with no additional data pipeline.
Scroll to Top