Example: Sales Order
Summary
A complete worked example of a Sales Order — the most complex screen type in 2Wee. This walks through the full JSON for every part: the header fields, the editable grid, item lookups with autofill, calculated fields, and saving.
By the end, you will understand how all the pieces (HeaderLines, lookups, autofill, Option columns, calculated columns, and the save changeset) work together in a real document.
What the user sees
A Sales Order has two parts:
- Header — customer info, dates, references (card fields)
- Lines — the items being sold (editable grid)
┌─ Sell-to Customer ────────────────┐ ┌─ General ──────────────────────┐
│ Customer No...... [10000 ]│ │ No............. [SO-1001 ]│
│ Name............. [Cannon Group ]│ │ Order Date..... [10-03-2026 ]│
│ Address.......... [123 Main St ]│ │ Posting Date... [10-03-2026 ]│
│ Post Code........ [100 ]│ │ Your Reference. [PO-4455 ]│
│ City............. [Tórshavn ]│ │ Payment Terms.. [30D ]│
│ Phone............ [+45 70102030 ]│ │ Shipment Method [DHL ]│
│ E-Mail........... [ap@cannon.tst ]│ └────────────────────────────────┘
└───────────────────────────────────┘
┌─ Lines ──────────────────────────────────────────────────────────────────┐
│ Type No. Description UoM Quantity Price Disc% Amt │
│ ─────── ───────── ──────────────────── ──── ──────── ─────── ───── ──────│
│ Item 1200 Tripod PCS 5 250.00 0.00 1250 │
│ Item 1000 Bicycle PCS 2 1495.00 0.00 2990 │
│ Item 1928-S Conference Lamp PCS 10 45.00 0.00 450 │
│ │
└──────────────────────────────────────────────────────────────────────────┘Step 1: The ScreenContract
Your server returns a HeaderLines layout with sections (header) and lines (grid):
json
{
"layout": "HeaderLines",
"title": "Sales Order - SO-1001",
"lines_overlay_pct": 65,
"sections": [
{
"id": "sell_to",
"label": "Sell-to Customer",
"column": 0,
"row_group": 0,
"fields": [
{
"id": "sell_to_no",
"label": "Customer No.",
"type": "Text",
"value": "10000",
"focus": true,
"lookup": {
"endpoint": "/lookup/sell_to_no",
"display_field": "no",
"validate": "/validate/sell_to_no"
}
},
{
"id": "sell_to_name",
"label": "Name",
"type": "Text",
"value": "The Cannon Group",
"quick_entry": false
},
{
"id": "sell_to_address",
"label": "Address",
"type": "Text",
"value": "123 Main St",
"quick_entry": false
},
{
"id": "sell_to_post_code",
"label": "Post Code",
"type": "Text",
"value": "100",
"quick_entry": false
},
{
"id": "sell_to_city",
"label": "City",
"type": "Text",
"value": "Tórshavn",
"quick_entry": false
},
{
"id": "sell_to_phone",
"label": "Phone",
"type": "Phone",
"value": "+45 70 10 20 30",
"quick_entry": false
},
{
"id": "sell_to_email",
"label": "E-Mail",
"type": "Email",
"value": "ap@cannongroup.example",
"quick_entry": false
}
]
},
{
"id": "general",
"label": "General",
"column": 1,
"row_group": 0,
"fields": [
{
"id": "no",
"label": "No.",
"type": "Text",
"value": "SO-1001",
"editable": false
},
{
"id": "order_date",
"label": "Order Date",
"type": "Date",
"value": "10-03-2026"
},
{
"id": "posting_date",
"label": "Posting Date",
"type": "Date",
"value": "10-03-2026"
},
{
"id": "your_reference",
"label": "Your Reference",
"type": "Text",
"value": "PO-4455"
},
{
"id": "payment_terms_code",
"label": "Payment Terms",
"type": "Text",
"value": "30D",
"lookup": {
"endpoint": "/lookup/payment_terms",
"display_field": "code",
"validate": "/validate/payment_terms",
"display": "modal"
}
},
{
"id": "shipment_method_code",
"label": "Shipment Method",
"type": "Text",
"value": "DHL"
}
]
}
],
"lines": {
"columns": [
{
"id": "type",
"label": "Type",
"type": "Option",
"width": 9,
"editable": true,
"quick_entry": false,
"options": [
"",
"Item",
"Resource",
"Text"
]
},
{
"id": "no",
"label": "No.",
"type": "Text",
"width": 12,
"editable": true,
"lookup": {
"endpoint": "/lookup/no",
"display_field": "no",
"validate": "/validate/no",
"display": "modal",
"context": [{ "field": "type" }]
}
},
{
"id": "description",
"label": "Description",
"type": "Text",
"width": "fill",
"editable": true,
"quick_entry": false
},
{
"id": "unit_of_measure",
"label": "UoM",
"type": "Text",
"width": 6,
"editable": true,
"quick_entry": false,
"lookup": {
"endpoint": "/lookup/unit_of_measure",
"display_field": "code",
"display": "modal",
"context": [{ "field": "type" }]
}
},
{
"id": "quantity",
"label": "Quantity",
"type": "Decimal",
"width": 10,
"align": "right",
"editable": true
},
{
"id": "unit_price",
"label": "Unit Price",
"type": "Decimal",
"width": 12,
"align": "right",
"editable": true,
"quick_entry": false
},
{
"id": "line_discount_pct",
"label": "Disc. %",
"type": "Decimal",
"width": 9,
"align": "right",
"editable": true,
"quick_entry": false,
"validation": {
"min": 0.0,
"max": 100.0,
"decimals": 2
}
},
{
"id": "line_amount",
"label": "Amount",
"type": "Decimal",
"width": 14,
"align": "right",
"editable": false,
"formula": "quantity * unit_price * (1 - line_discount_pct / 100)",
"validation": { "decimals": 2 }
}
],
"rows": [
{
"index": 0,
"values": [
"Item",
"1200",
"Tripod",
"PCS",
"5",
"250.00",
"0.00",
"1,250.00"
]
},
{
"index": 1,
"values": [
"Item",
"1000",
"Bicycle",
"PCS",
"2",
"1,495.00",
"0.00",
"2,990.00"
]
},
{
"index": 2,
"values": [
"Item",
"1928-S",
"Conference Lamp",
"PCS",
"10",
"45.00",
"0.00",
"450.00"
]
},
{
"index": 3,
"values": [
"",
"",
"",
"",
"",
"",
"",
""
]
}
],
"editable": true,
"row_count": 4
},
"actions": {
"save": "/screen/sales_order/SO-1001/save",
"delete": "/screen/sales_order/SO-1001/delete",
"create": "/screen/sales_order/new"
},
"screen_actions": [
{
"id": "send_email",
"label": "Send as Email",
"kind": "modal",
"endpoint": "/action/sales_order/SO-1001/send_email",
"fields": [
{
"id": "to",
"label": "To",
"type": "Email",
"value": "ap@cannongroup.example",
"required": true
},
{
"id": "subject",
"label": "Subject",
"type": "Text",
"value": "Sales Order SO-1001",
"required": true
},
{
"id": "message",
"label": "Message",
"type": "Text",
"value": ""
}
]
}
],
"locale": {
"date_format": "DD-MM-YYYY",
"decimal_separator": ".",
"thousand_separator": ","
},
"work_date": "10-03-2026"
}Key things to notice
lines_overlay_pct: 65— the grid covers 65% of the screen when opened with Ctrl+Lline_amounthasformula— calculated live by the client:quantity * unit_price * (1 - line_discount_pct / 100). The user cannot type in it (editable: false).- Row 3 is empty — this is the "new line" row where the user starts adding items
- The
typecolumn is anOption— the user cycles through"","Item","Resource","Text" - The
nocolumn has a lookup withvalidate— the item number is validated against the server quick_entry: falseon autofill columns (Description, UoM, Unit Price, Disc%) and Type — Enter skips them. The fast Enter path is: No. → Quantity → next rowfocus: trueon Customer No. — cursor starts there, not on No. (read-only)quick_entry: falseon header autofill fields (Name, Address, City, Phone, Email) — Enter skips from Customer No. to Order Date
Step 2: The user adds a new line
The user navigates to the empty row (row 3) and starts entering a new order line. Here is the complete workflow:
2a. Select the Type
The user moves to the Type cell and presses Space to cycle to "Item" (or opens the modal and selects it).
No server call. The client handles Option cycling locally.
2b. Look up an Item
The user moves to the No. column and presses Ctrl+Enter (or any alternate: Shift+Enter, F6, Ctrl+O).
The client reads the type column value from the current row ("Item") and sends:
GET /lookup/no?type=ItemYour server returns the Items lookup list:
json
{
"layout": "List",
"title": "Items",
"lines": {
"columns": [
{
"id": "no",
"label": "No.",
"width": 12
},
{
"id": "description",
"label": "Description",
"width": "fill"
},
{
"id": "unit_of_measure",
"label": "UoM",
"width": 6
},
{
"id": "unit_price",
"label": "Unit Price",
"width": 12,
"align": "right"
},
{
"id": "line_discount_pct",
"label": "Disc. %",
"width": 8,
"align": "right"
}
],
"rows": [
{
"index": 0,
"values": [
"1000",
"Bicycle",
"PCS",
"1,495.00",
"0.00"
]
},
{
"index": 1,
"values": [
"1100",
"Chain",
"PCS",
"45.00",
"0.00"
]
},
{
"index": 2,
"values": [
"1200",
"Tripod",
"PCS",
"250.00",
"0.00"
]
},
{
"index": 3,
"values": [
"1928-S",
"Conference Lamp",
"PCS",
"45.00",
"0.00"
]
}
],
"selectable": true,
"value_column": "no",
"autofill": {
"description": "description",
"unit_of_measure": "unit_of_measure",
"unit_price": "unit_price",
"line_discount_pct": "line_discount_pct"
}
}
}The autofill map says: when the user selects a row, copy these column values into the corresponding grid cells on the current row:
| Lookup column | → | Grid column |
|---|---|---|
description | → | description |
unit_of_measure | → | unit_of_measure |
unit_price | → | unit_price |
line_discount_pct | → | line_discount_pct |
2c. User selects "1100 - Chain"
The user presses Enter on the Chain row. The client:
- Writes
"1100"to thenocell (fromvalue_column) - Writes
"Chain"to thedescriptioncell (from autofill) - Writes
"PCS"to theunit_of_measurecell (from autofill) - Writes
"45.00"to theunit_pricecell (from autofill) - Writes
"0.00"to theline_discount_pctcell (from autofill)
The row now looks like:
Item 1100 Chain PCS 45.00 0.00All of this happens in one action — one selection fills five cells.
2d. Alternative: type the item number directly
Instead of opening the lookup, the user could type 1100 directly and press Tab to leave the cell.
The client sends a blur validation request:
GET /validate/no/1100?type=ItemYour server returns:
json
{
"valid": true,
"autofill": {
"description": "Chain",
"unit_of_measure": "PCS",
"unit_price": "45.00",
"line_discount_pct": "0.00"
}
}Same result — the description, UoM, price, and discount fill in automatically. The lookup and validation paths produce identical results.
If the user types an invalid number:
GET /validate/no/9999?type=Itemjson
{
"valid": false,
"error": "'9999' is not a valid Item No."
}The client locks the user on the no cell until they fix the value or press Esc to revert.
2e. Enter the quantity
The user tabs to the Quantity cell and types 4. No server call — the client accepts the input locally.
The row now shows:
Item 1100 Chain PCS 4 45.00 0.00The line_amount cell (Amount) has a formula: "quantity * unit_price * (1 - discount_pct / 100)" — the client calculates it live as the user edits. After entering quantity 4, the Amount shows 180.00 (4 × 45.00). The server also recalculates on save as the authoritative source of truth.
2f. Polymorphic lookups — Resources
If the user sets Type to "Resource" instead of "Item", the same lookup request includes the context:
GET /lookup/no?type=ResourceYour server returns the Resources table instead:
json
{
"layout": "List",
"title": "Resources",
"lines": {
"columns": [
{
"id": "no",
"label": "No.",
"width": 12
},
{
"id": "description",
"label": "Description",
"width": "fill"
},
{
"id": "unit_of_measure",
"label": "UoM",
"width": 6
},
{
"id": "unit_price",
"label": "Unit Price",
"width": 12,
"align": "right"
}
],
"rows": [
{
"index": 0,
"values": [
"8100",
"Consulting",
"HOURS",
"150.00"
]
},
{
"index": 1,
"values": [
"8200",
"Installation",
"HOURS",
"95.00"
]
},
{
"index": 2,
"values": [
"8210",
"Freight",
"PCS",
"0.00"
]
}
],
"selectable": true,
"value_column": "no",
"autofill": {
"description": "description",
"unit_of_measure": "unit_of_measure",
"unit_price": "unit_price"
}
}
}Note: Resources have no discount column, so the autofill map only includes three fields. The line_discount_pct cell stays at its current value.
One lookup endpoint (/lookup/no) serves different data based on the ?type= parameter. Your server just checks the query param and queries the right table.
Step 3: Saving
The user presses Ctrl+S. The client collects all dirty header fields and the full grid data:
json
{
"screen_id": "sales_order",
"record_id": "SO-1001",
"changes": {
"sell_to_name": "The Cannon Group"
},
"lines": [
[
"Item",
"1200",
"Tripod",
"PCS",
"5",
"250.00",
"0.00",
"1,250.00"
],
[
"Item",
"1000",
"Bicycle",
"PCS",
"2",
"1,495.00",
"0.00",
"2,990.00"
],
[
"Item",
"1928-S",
"Conference Lamp",
"PCS",
"10",
"45.00",
"0.00",
"450.00"
],
[
"Item",
"1100",
"Chain",
"PCS",
"4",
"45.00",
"0.00",
""
],
[
"",
"",
"",
"",
"",
"",
"",
""
]
]
}What your server does with this
- Parse header changes — update the
sell_to_namefield - Parse grid lines — each inner array maps to columns by position:
- Position 0 →
type - Position 1 →
no - Position 2 →
description - Position 3 →
unit_of_measure - Position 4 →
quantity - Position 5 →
unit_price - Position 6 →
line_discount_pct - Position 7 →
line_amount
- Position 0 →
- Skip empty rows — row 4 is all empty strings, ignore it
- Calculate line amounts — for each line:
quantity × unit_price × (1 - discount/100)- Line 3 (new): 4 × 45.00 × (1 - 0/100) = 180.00
- Persist — save to database
- Return fresh ScreenContract — with all calculated values filled in
The save response
json
{
"layout": "HeaderLines",
"title": "Sales Order - SO-1001",
"status": "Saved.",
"lines_overlay_pct": 65,
"sections": [],
"lines": {
"columns": [],
"rows": [
{
"index": 0,
"values": [
"Item",
"1200",
"Tripod",
"PCS",
"5",
"250.00",
"0.00",
"1,250.00"
]
},
{
"index": 1,
"values": [
"Item",
"1000",
"Bicycle",
"PCS",
"2",
"1,495.00",
"0.00",
"2,990.00"
]
},
{
"index": 2,
"values": [
"Item",
"1928-S",
"Conference Lamp",
"PCS",
"10",
"45.00",
"0.00",
"450.00"
]
},
{
"index": 3,
"values": [
"Item",
"1100",
"Chain",
"PCS",
"4",
"45.00",
"0.00",
"180.00"
]
},
{
"index": 4,
"values": [
"",
"",
"",
"",
"",
"",
"",
""
]
}
],
"editable": true
},
"actions": {
"save": "/screen/sales_order/SO-1001/save"
}
}The client replaces the current screen. The line_amount column now shows 180.00 for the new line — calculated by the server.
Step 4: Customer lookup with header autofill
The same autofill pattern works for header fields. When the user changes the Customer No.:
GET /lookup/sell_to_nojson
{
"layout": "List",
"title": "Customers",
"lines": {
"columns": [
{
"id": "no",
"label": "No.",
"width": 10
},
{
"id": "name",
"label": "Name",
"width": "fill"
},
{
"id": "city",
"label": "City",
"width": 15
},
{
"id": "phone",
"label": "Phone",
"width": 18
}
],
"rows": [
{
"index": 0,
"values": [
"10000",
"The Cannon Group",
"Tórshavn",
"+45 70 10 20 30"
]
},
{
"index": 1,
"values": [
"20000",
"Selangorian Ltd.",
"London",
"+44 20 1234 5678"
]
}
],
"selectable": true,
"value_column": "no",
"autofill": {
"name": "sell_to_name",
"city": "sell_to_city",
"phone": "sell_to_phone"
}
}
}Selecting customer 20000 fills in the name, city, and phone fields on the header — all at once.
The blur validation endpoint does the same for direct typing:
GET /validate/sell_to_no/20000json
{
"valid": true,
"autofill": {
"sell_to_name": "Selangorian Ltd.",
"sell_to_address": "456 High Street",
"sell_to_city": "London",
"sell_to_post_code": "WC2R 1LA",
"sell_to_phone": "+44 20 1234 5678",
"sell_to_email": "orders@selangorian.example"
}
}The validate endpoint can return more autofill fields than the lookup list (the validate response is not limited by which columns the list shows).
Autofill means: you pick one thing, and the related things fill in automatically. Select a customer number, and the name, address, city, phone, and email fill in. Select an item number, and the description, unit of measure, and price fill in.
Calculated fields
The line_amount column has a formula — the client evaluates it live after every cell edit:
json
{
"id": "line_amount",
"editable": false,
"formula": "quantity * unit_price * (1 - line_discount_pct / 100)"
}When the user changes quantity, unit price, or discount, the Amount updates immediately — no server call needed. The formula references column IDs by name and supports +, -, *, /, parentheses, and numeric literals. Empty or missing values are treated as 0.
The server also recalculates on save (as the source of truth), but the user sees live results as they type.
Use validation.decimals to control the display precision (default: 2).
Step 5: Sending the order as email
The sales order has a screen_actions entry for "Send as Email". The user presses Ctrl+A (or F8) from anywhere on the order — the card header or the lines overlay — and selects "Send as Email".
The action form opens with the customer's email pre-filled from the card data. The user can edit the address, change the subject, add a message, then press Enter to submit.
The client POSTs
POST /action/sales_order/SO-1001/send_emailjson
{
"action_id": "send_email",
"screen_title": "Sales Order - SO-1001",
"record_id": "SO-1001",
"fields": {
"to": "ap@cannongroup.example",
"subject": "Sales Order SO-1001",
"message": "Please review and confirm."
}
}Your server processes it
- Look up the order by the URL path parameter
- Validate the email address
- Send the email (or queue it)
- Return the result
Success response
json
{
"success": true,
"message": "Email sent to ap@cannongroup.example."
}The client shows this message in a result modal. The user presses Enter to dismiss and returns to the order.
Error response
If the email address is invalid:
json
{
"success": false,
"error": "A valid email address is required."
}The client shows the error. The user can retry by pressing Ctrl+A again.
See Screen Actions for the full reference on action kinds, fields, and wire format.
Summary of all server endpoints for this example
| Endpoint | Method | Purpose |
|---|---|---|
/screen/sales_order/{no} | GET | Load the order (HeaderLines ScreenContract) |
/screen/sales_order/{no}/save | POST | Save changes + recalculate |
/screen/sales_order/{no}/delete | POST | Delete the order |
/screen/sales_order/new | GET | Empty order form |
/screen/sales_order/new | POST | Create new order |
/lookup/sell_to_no | GET | Customer lookup list |
/validate/sell_to_no/{value} | GET | Validate customer + autofill header |
/lookup/no?type=Item | GET | Item lookup list |
/lookup/no?type=Resource | GET | Resource lookup list |
/validate/no/{value}?type=Item | GET | Validate item + autofill line |
/validate/no/{value}?type=Resource | GET | Validate resource + autofill line |
/lookup/payment_terms | GET | Payment terms lookup (modal) |
/validate/payment_terms/{value} | GET | Validate payment terms |
/lookup/unit_of_measure?type=Item | GET | Unit of measure lookup (modal) |
/action/sales_order/{no}/send_email | POST | Send order as email (screen action) |