Build an Order Detail Form
In this lesson you will build a realistic Order Detail module — a form that loads an existing order, lets the dispatcher edit fields, links to a contact via select-async, manages commodity lines via field-collection, and shows a read-only charges grid below the form. You will follow the same workflow used in production: scaffold, read, customize, validate.
1. Scaffold with the Form Template
Start by generating the initial file. The --template form gives you a schema-valid skeleton with a query, form, and mutation already wired up.
bash1npx cxtms create module "OrderDetail" --template form --options '[2 {"name": "orderNumber", "type": "text", "label": "Order Number", "required": true},3 {"name": "status", "type": "select", "label": "Status", "required": true},4 {"name": "contactId", "type": "select-async", "label": "Contact"},5 {"name": "pickupDate", "type": "date", "label": "Pickup Date"},6 {"name": "deliveryDate", "type": "date", "label": "Delivery Date"},7 {"name": "notes", "type": "textarea", "label": "Notes"}8]'
This creates modules/order-detail-module.yaml. Read the generated file immediately before making any edits — confirm the scaffolded query, mutation, validationSchema, and field children match what you expect.
2. Configure the Initial Values Query
The scaffold generates a generic query. Replace it with the actual Order query that fetches the full record including the commodity array and contactId:
yaml1queries:2 - name: getOrder3 query:4 command: |5 query GetOrder($id: Int!) {6 order(id: $id) {7 id8 orderNumber9 status10 contactId11 pickupDate12 deliveryDate13 notes14 commodities {15 description16 quantity17 weight18 unitPrice19 }20 }21 }22 variables:23 id: "{{ number id }}"24initialValues:25 fromQuery:26 name: getOrder27 path: order28 append:29 status: "Draft"
The append.status: "Draft" default only applies in create mode when the query returns nothing. In edit mode the query result overwrites it.
3. Add the validationSchema
Replace the generated schema with rules that match your fields. For the date fields, use type: date so Yup coerces string values from the date picker correctly:
yaml1validationSchema:2 orderNumber:3 type: string4 required: true5 status:6 type: string7 required: true8 pickupDate:9 type: date10 deliveryDate:11 type: date12 commodities:13 type: array
Arrays are validated at the schema level. If you want each commodity item to be required, add a nested of rule — but for now the top-level type: array is sufficient to pass the schema check.
4. Replace the Status Select with Static Items
The scaffolded select field has no items yet. Add the order status options:
yaml1- component: field2 name: status3 props:4 type: select5 label: { en-US: "Status" }6 required: true7 items:8 - { label: "Draft", value: "Draft" }9 - { label: "Confirmed", value: "Confirmed" }10 - { label: "In Transit", value: "InTransit" }11 - { label: "Delivered", value: "Delivered" }12 - { label: "Cancelled", value: "Cancelled" }
5. Wire Up the Contact select-async
The scaffold generates a placeholder for the select-async field. Replace it with the real contact lookup. Two named queries are needed — one for paginated search, one for single-value load:
yaml1- component: field2 name: contactId3 props:4 type: select-async5 label: { en-US: "Contact" }6 options:7 valueFieldName: contactId8 itemLabelTemplate: "{{contactName}}"9 itemValueTemplate: "{{contactId}}"10 allowSearch: true11 allowClear: true12 searchQuery:13 name: getContacts14 path: contacts.items15 params:16 search: "{{ string search }}"17 take: "{{ number pageSize }}"18 skip: "{{ number skip }}"19 valueQuery:20 name: getContact21 path: contact22 params:23 contactId: "{{ contactId }}"24 queries:25 - name: getContacts26 query:27 command: >-28 query($search: String!, $take: Int!, $skip: Int!) {29 contacts(search: $search, take: $take, skip: $skip) {30 items { contactId contactName } totalCount31 }32 }33 - name: getContact34 query:35 command: >-36 query($contactId: Int!) {37 contact(contactId: $contactId) { contactId contactName }38 }39 variables:40 contactId: "{{ number contactId }}"
Note that itemLabelTemplate and itemValueTemplate use Handlebars {{field}} (single curly braces), not the CXTMS {{ field }} template syntax.
6. Add the field-collection for Commodities
Below the date fields, add a field-collection component bound to the commodities array:
yaml1- component: field-collection2 name: commodities3 props:4 fieldName: commodities5 options:6 allowAdd: true7 allowRemove: true8 allowReorder: true9 minItems: 110 maxItems: 10011 addButton:12 label: { en-US: "Add Commodity" }13 icon: plus14 position: bottom15 defaultItem:16 description: ""17 quantity: 118 weight: 019 unitPrice: 020 layout: list21 cols: 422 itemTemplate:23 component: row24 name: commodityRow25 props: { spacing: 2 }26 children:27 - component: field28 name: description29 props:30 type: text31 label: { en-US: "Description" }32 required: true33 - component: field34 name: quantity35 props:36 type: number37 label: { en-US: "Qty" }38 - component: field39 name: weight40 props:41 type: number42 label: { en-US: "Weight (kg)" }43 - component: field44 name: unitPrice45 props:46 type: number47 label: { en-US: "Unit Price" }
minItems: 1 auto-creates a blank row when the form opens for a new order. cols: 4 lays out the four fields in a single row per commodity.
7. Configure the onSubmit Mutation
Replace the scaffolded mutation with the real one. Use createMode to branch between create and update:
yaml1onSubmit:2 - validateForm: {}3 - if: "{{ eval createMode }}"4 then:5 - mutation:6 command: |7 mutation CreateOrder($input: OrderInput!) {8 createOrder(input: $input) { id }9 }10 variables:11 input: "{{ form }}"12 onSuccess:13 - notification: { message: { en-US: "Order created" }, type: success }14 - navigate: "orders/{{ createOrder.id }}"15 onError:16 - notification: { message: { en-US: "Failed to create order" }, type: error }17 else:18 - mutation:19 command: |20 mutation UpdateOrder($id: Int!, $input: OrderInput!) {21 updateOrder(id: $id, input: $input) { id }22 }23 variables:24 id: "{{ number id }}"25 input: "{{ form }}"26 onSuccess:27 - notification: { message: { en-US: "Order saved" }, type: success }28 - resetDirtyState: {}29 onError:30 - notification: { message: { en-US: "Failed to save order" }, type: error }
{{ form }} serializes all current form values (including the commodities array) into the input object.
8. Add a Charges DataGrid Below the Form
Below the form component, add a dataGrid for the order's charge lines. Because the grid lives outside the form, it uses datasource to load its own data independently:
yaml1- component: dataGrid2 name: chargesGrid3 props:4 refreshHandler: orderCharges5 views:6 - name: all7 displayName: { en-US: "Charges" }8 columns:9 - name: id10 isHidden: true11 - name: description12 label: { en-US: "Description" }13 - name: chargeType14 label: { en-US: "Type" }15 - name: amount16 label: { en-US: "Amount" }17 showAs:18 component: text19 props:20 value: "${{ amount }}"21 - name: currency22 label: { en-US: "Currency" }23 options:24 query: orderCharges25 rootEntityName: OrderCharge26 entityKeys: [id]27 navigationType: dialog28 enableDynamicGrid: false29 enableViews: true30 enableSearch: false31 enablePagination: true32 enableColumns: false33 enableFilter: false34 defaultView: all35 onRowClick:36 - dialog:37 component: Orders/EditCharge38 onClose:39 - refresh: orderCharges
Notice enableDynamicGrid: false — this grid is embedded within a detail page and does not need the full dynamic column management. Setting enableSearch: false and enableColumns: false keeps the UI clean in this context.
9. Enable dirtyGuard
Add the dirty guard to the form props so dispatchers are warned before navigating away unsaved:
yaml1dirtyGuard:2 enabled: true3 title: { en-US: "Unsaved Changes" }4 message: { en-US: "You have unsaved changes. Leave without saving?" }5 confirmLabel: { en-US: "Leave" }6 cancelLabel: { en-US: "Stay" }
10. Validate the Module
After every batch of changes, run the CLI validator:
bash1npx cxtms modules/order-detail-module.yaml
Fix any schema errors before moving on. Common mistakes to watch for:
- Missing
entityKeyson thedataGridoptions. select-asyncwithout bothsearchQueryandvalueQuerydefined.field-collectionwithoutfieldNameset.validationSchemamissing fields that haverequired: truein their field props.
Summary
In this lesson you:
- Scaffolded an OrderDetail module with
--template form --options. - Replaced the generic query with an Order query that includes the
commoditiesarray. - Added static items to the
statusselect field. - Configured a
select-asynccontact picker with bothsearchQueryandvalueQuery. - Added a
field-collectionfor commodity lines with drag-and-drop and a default row. - Wrote a branched
onSubmitchain that creates or updates based oncreateMode. - Embedded a read-only
dataGridfor charge lines below the form. - Enabled
dirtyGuardto protect unsaved changes.