CartTable
A <CartTable> is an inline, editable grid of line items inside a form — an order’s lines, an
invoice’s items. It is an editor field whose value submits as a
JSON array, and it renders per-mode (editable in Create/Edit, read-only in View).
<CartTable Name="Lines" SubView="SaleItem" RowStyle="Grid" AllowAdd="true" AllowEdit="true" AllowRemove="true" ShowRowNumbers="true" ShowHeader="true" AddButtonText="Add" RemoveButtonText="Remove" MaxRows="50"> <Columns> <Select Name="ProductId" Label="Product" Width="5"><DataSet Type="sql">SELECT Id AS Value, Name AS Label FROM Sales.Products</DataSet></Select> <Number Name="Quantity" Label="Qty" Width="3" /> </Columns></CartTable>RowStyle is Grid or Stacked. Each <Columns> child is itself a full editor field — including a
<Select> with ControlType="ModalSelector", its own <DataSet>, <Columns>, and <ExtraMappings>
(picking a row autofills sibling cells in the same row, e.g. Product → Unit Price).
There are two flavours:
- Local cart (the example above): the rows live in the form as a JSON array and you persist
them yourself in the parent’s
<InsertSql>/<EditSql>(see Persistence). - View-backed cart (
View/ParentKey): the rows belong to a child view that owns the columns and the persistence — the recommended shape for real line items.
View-backed cart (View / ParentKey)
Section titled “View-backed cart (View / ParentKey)”Point a cart at a child view instead of declaring its columns and persistence inline:
<CartTable Name="Lines" View="OrderLines" ParentKey="OrderId" AllowAdd="true" AllowEdit="true" AllowRemove="true" MaxRows="50" />View— the child view that owns the rows. Its<EditorFields>become the cart’s columns (so you don’t redeclare Product/Qty/Price), and its own<InsertSql>/<EditSql>do the persistence. Add inline<Columns>only to override the inherited set.ParentKey— the child’s foreign-key column that receives the parent’s primary key. Required whenViewis set.
How it loads and saves:
- Load — the cart fetches its rows from the child view’s own table endpoint, scoped by
@Parent__Id(the parent record’s primary key) — exactly like a sub-view. In Create mode it starts empty. - Save — on parent submit the engine hands the whole edited row array to the child view as
@Rows(a JSON array) together with@Parent__Id(and the parent key under its own column name), and runs the child’s<InsertSql>(new parent) or<EditSql>(existing parent). This all happens inside one transaction with the parent write, so the header and its lines commit or roll back together. A form may have more than one cart — each is written through its own child view in the same transaction.
The child view authors its write SQL set-based with OPENJSON(@Rows) (fast — one statement, not
row-by-row) and transaction-free (the engine owns the transaction; a BEGIN TRAN in child cart
SQL is rejected). A typical child <EditSql> full-replaces the set:
<EditorFields> <Select Name="ProductId" Label="Product" ControlType="ModalSelector"><DataSet Type="sql">…</DataSet> <ExtraMappings><Map Column="UnitCost" Field="UnitPrice" /></ExtraMappings></Select> <Number Name="Quantity" Label="Qty" Required="true" MinValue="1" /> <Number Name="UnitPrice" Label="Unit Price" Required="true" DecimalPlaces="2" /> <Number Name="LineTotal" Label="Line Total" Disabled="true" ValueExpression="Quantity * UnitPrice" /></EditorFields>
<InsertSql> IF (@Rows IS NOT NULL AND ISJSON(@Rows) = 1) INSERT INTO Sales.OrderLines (OrderId, ProductId, Quantity, UnitPrice, LineTotal, …) SELECT TRY_CAST(@Parent__Id AS BIGINT), TRY_CAST(j.ProductId AS BIGINT), TRY_CAST(j.Quantity AS INT), TRY_CAST(j.UnitPrice AS DECIMAL(18,2)), TRY_CAST(j.Quantity AS INT) * TRY_CAST(j.UnitPrice AS DECIMAL(18,2)), … FROM OPENJSON(@Rows) WITH (ProductId NVARCHAR(50) '$.ProductId', Quantity NVARCHAR(50) '$.Quantity', UnitPrice NVARCHAR(50) '$.UnitPrice') AS j WHERE TRY_CAST(j.ProductId AS BIGINT) IS NOT NULL;</InsertSql><EditSql> UPDATE Sales.OrderLines SET IsDeleted = 1, … WHERE OrderId = TRY_CAST(@Parent__Id AS BIGINT) AND IsDeleted = 0; IF (@Rows IS NOT NULL AND ISJSON(@Rows) = 1) INSERT INTO Sales.OrderLines (…) SELECT … FROM OPENJSON(@Rows) WITH (…) AS j …;</EditSql>The OPENJSON … WITH (…) column list is the mass-assignment allowlist — only its declared paths are
read. RBAC is re-validated on the child view (parent-create needs the child’s Create verb,
parent-edit needs Update). The parent <InsertSql> must SELECT the new primary key as its first
result cell (e.g. SELECT CAST(SCOPE_IDENTITY() AS BIGINT);) so the engine can attach the lines.
Header total. Because line persistence lives in the child view, recompute a parent total (e.g.
TotalAmount) with an AFTER INSERT, UPDATE, DELETE trigger on the child table — the single
source of truth no matter who writes the lines. Keep a sumLines(...) ValueExpression on the header
field for the live in-form estimate while editing.
View mode is automatic: SubView defaults to the child view, so View renders that grid read-only in
the cart’s place (see Per-mode rendering).
Computed columns
Section titled “Computed columns”A cart column can carry a ValueExpression
that derives its value from the other cells in the same row. Give the column a Disabled="true"
so it reads as a computed field — the cart makes any column with a ValueExpression read-only and
recalculates it live as the row’s inputs change (including a value autofilled via <ExtraMappings>):
<Number Name="LineTotal" Label="Line Total" Disabled="true" DecimalPlaces="2" ValueExpression="Quantity * UnitPrice" />The expression scope is the row’s own cells (referenced by column name); a numeric result is
formatted to the column’s DecimalPlaces. An existing record’s computed cells are filled on load, so
they never open blank. To total the lines in a header field, use the sumLines(...) helper in a
ValueExpression on a top-level field.
Dependent option filtering (FilterBy)
Section titled “Dependent option filtering (FilterBy)”A dataset-backed cart Select can filter its options by a sibling cell in the same row: set
FilterBy="{SiblingColumn}" and expose an extra column with the same name on the Select’s dataset.
Each row keeps only the options whose extra column equals that row’s sibling value (an empty sibling
shows all options; the row’s current selection is never filtered away, so stored labels survive).
Filtering is client-side over the already-loaded dataset — no extra queries per row.
<Select Name="CategoryId" Label="Category" Width="3">…</Select><Select Name="ItemId" Label="Item" Width="4" FilterBy="CategoryId" ControlType="ModalSelector"> <DataSet Type="sql">SELECT i.Id AS Value, i.Name AS Label, CAST(i.CategoryId AS NVARCHAR(20)) AS CategoryId, … FROM …</DataSet></Select>Per-mode rendering (SubView)
Section titled “Per-mode rendering (SubView)”SubView links the cart to the <SubConfig> sub-view that holds the
same rows. It drives per-mode rendering: Create/Edit show the editable cart; View renders that
sub-view read-only (permission-gated) in the cart’s place — so you never need a separate <Sub> for
the line items, and the view never shows raw JSON. When SubView is omitted the form falls back to a
sub-view named after the cart field (and, for a TableName cart, to that table); if none resolves,
View renders a plain read-only cart table. A referenced (TableName) cart always renders as the
sub-view table — editable in Create/Edit, read-only in View.
Selected-value labels
Section titled “Selected-value labels”A dataset-backed column shows the label of each stored selection in edit (and the read-only
fallback), even when the option list isn’t fully loaded. The cart sends the stored keys to the
field-dataset endpoint as CurrentValues and the backend returns just those rows (works for SQL /
Options / CSV / Model datasets). For very large or search-only SQL datasets you can narrow this
server-side with WHERE Value IN (SELECT value FROM STRING_SPLIT(@CurrentValues, ',')); otherwise no
SQL change is needed.
Large pickers — server-side search & scroll pagination
Section titled “Large pickers — server-side search & scroll pagination”A ControlType="ModalSelector" column doesn’t preload its dataset. Its picker searches server-side
(the typed term goes to the field-dataset endpoint as @SearchText, debounced) and loads one page
at a time, appending the next page as you scroll — so a catalog of thousands never loads (or renders)
at once. The easiest way to page a large dataset is the
Type="search" shorthand — write just the
core query and the engine adds the search/resolve/pagination for you:
<Select Name="ProductId" Label="Product" Width="5" Required="true" ControlType="ModalSelector"> <DataSet Type="search" Search="Label,SKU"><![CDATA[ SELECT p.Id AS Value, p.Name AS Label, p.Sku AS SKU, p.UnitCost AS UnitCost FROM Sales.Products p WHERE p.IsDeleted = 0 AND p.CompanyId = @SessionCompanyId ]]></DataSet> <ExtraMappings><Map Column="UnitCost" Field="UnitPrice" /></ExtraMappings></Select>(For full control you can still hand-write the plumbing with Type="sql" using @SearchText /
@CurrentValues / @PageSize/@PageOffset — see the editor-fields reference.) A plain (non-modal)
dropdown column still preloads its options, so keep those datasets small (or use a ModalSelector).
Persistence
Section titled “Persistence”A local cart is an in-memory field whose value submits as a JSON array under its own name
(@{Name}, e.g. @Lines). Persist it in <InsertSql> by expanding that JSON into child rows (SQL
Server OPENJSON); capture the new parent id first and end by returning it:
<InsertSql> INSERT INTO Sales.Sales (InvoiceNumber, CustomerId, TotalAmount, …) VALUES (@InvoiceNumber, @CustomerId, @TotalAmount, …); DECLARE @NewId INT = CAST(SCOPE_IDENTITY() AS INT); IF (@Lines IS NOT NULL AND ISJSON(@Lines) = 1) INSERT INTO Sales.SaleItems (SaleId, ProductId, Quantity, UnitPrice, LineTotal, …) SELECT @NewId, TRY_CAST(j.ProductId AS INT), TRY_CAST(j.Quantity AS INT), TRY_CAST(j.UnitPrice AS DECIMAL(18,2)), TRY_CAST(j.Quantity AS INT) * TRY_CAST(j.UnitPrice AS DECIMAL(18,2)), … FROM OPENJSON(@Lines) WITH (ProductId NVARCHAR(50) '$.ProductId', Quantity NVARCHAR(50) '$.Quantity', UnitPrice NVARCHAR(50) '$.UnitPrice') AS j WHERE TRY_CAST(j.ProductId AS INT) IS NOT NULL; SELECT @NewId;</InsertSql>Edit mode (pre-fill + persist)
Section titled “Edit mode (pre-fill + persist)”A cart maps to no real column, so to make it work in edit the same way as create:
- Pre-fill — add a JSON-array column to the main
<Sql>, aliased to the cart field name, with keys matching the cart column names. The form-values load wraps<Sql>(… WHERE Id=@Id), so the cart parses this JSON to seed its rows. Hide it in the grid viaColumns:<Sql …> SELECT s.Id, …,(SELECT si.ProductId, si.Quantity, si.UnitPrice FROM Sales.SaleItems siWHERE si.SaleId = s.Id AND si.IsDeleted = 0 FOR JSON PATH) AS LinesFROM Sales.Sales s … </Sql><Columns> <Column Name="Lines" Hide="true" Export="false" /> </Columns> - Persist — mirror the
OPENJSONexpansion in<EditSql>: soft-delete the current child rows for@Id, then re-insert from@Lines(same block asInsertSql, keyed on@Id). - View mode — set
SubView="…"on the<CartTable>(pointing at the<SubConfig>sub-view for the same rows). The cart then drives all three modes itself: editable cart in Create/Edit, the read-only sub-view in View. Place a single<Item Name="{cart}" />in the layout — no separate<Sub>is needed (a standalone<Sub>here would duplicate the cart in edit). WithoutSubView, View falls back to a read-only cart table. Use a standalone<Sub>only for child grids that aren’t backed by a cart.
The local-cart pre-fill/persist steps above are the hand-written pattern; a view-backed cart does all three (load, persist, View mode) for you with no parent SQL.
Worked example: sample/Inventory/models/views/PurchaseOrders.view.xml — a view-backed
<CartTable View="PurchaseOrderLines" ParentKey="PurchaseOrderId">. The PurchaseOrderLines child
view supplies the columns (a ModalSelector product column that autofills the line price via
<ExtraMappings>, a per-line LineTotal ValueExpression) and the set-based OPENJSON
Insert/Edit SQL; the header TotalAmount uses sumLines(...) for the live estimate and the
tr_PurchaseOrderLines_Total trigger for the stored value.