Skip to content

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.

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 when View is 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:

OrderLines.view.xml
<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).

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.

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>

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.

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

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>

A cart maps to no real column, so to make it work in edit the same way as create:

  1. 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 via Columns:
    <Sql …> SELECT s.Id, …,
    (SELECT si.ProductId, si.Quantity, si.UnitPrice FROM Sales.SaleItems si
    WHERE si.SaleId = s.Id AND si.IsDeleted = 0 FOR JSON PATH) AS Lines
    FROM Sales.Sales s … </Sql>
    <Columns> <Column Name="Lines" Hide="true" Export="false" /> </Columns>
  2. Persist — mirror the OPENJSON expansion in <EditSql>: soft-delete the current child rows for @Id, then re-insert from @Lines (same block as InsertSql, keyed on @Id).
  3. 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). Without SubView, 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.