20 minlesson

Build an Order Detail Form

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.

bash
1npx 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:

yaml
1queries:
2 - name: getOrder
3 query:
4 command: |
5 query GetOrder($id: Int!) {
6 order(id: $id) {
7 id
8 orderNumber
9 status
10 contactId
11 pickupDate
12 deliveryDate
13 notes
14 commodities {
15 description
16 quantity
17 weight
18 unitPrice
19 }
20 }
21 }
22 variables:
23 id: "{{ number id }}"
24initialValues:
25 fromQuery:
26 name: getOrder
27 path: order
28 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:

yaml
1validationSchema:
2 orderNumber:
3 type: string
4 required: true
5 status:
6 type: string
7 required: true
8 pickupDate:
9 type: date
10 deliveryDate:
11 type: date
12 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:

yaml
1- component: field
2 name: status
3 props:
4 type: select
5 label: { en-US: "Status" }
6 required: true
7 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:

yaml
1- component: field
2 name: contactId
3 props:
4 type: select-async
5 label: { en-US: "Contact" }
6 options:
7 valueFieldName: contactId
8 itemLabelTemplate: "{{contactName}}"
9 itemValueTemplate: "{{contactId}}"
10 allowSearch: true
11 allowClear: true
12 searchQuery:
13 name: getContacts
14 path: contacts.items
15 params:
16 search: "{{ string search }}"
17 take: "{{ number pageSize }}"
18 skip: "{{ number skip }}"
19 valueQuery:
20 name: getContact
21 path: contact
22 params:
23 contactId: "{{ contactId }}"
24 queries:
25 - name: getContacts
26 query:
27 command: >-
28 query($search: String!, $take: Int!, $skip: Int!) {
29 contacts(search: $search, take: $take, skip: $skip) {
30 items { contactId contactName } totalCount
31 }
32 }
33 - name: getContact
34 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:

yaml
1- component: field-collection
2 name: commodities
3 props:
4 fieldName: commodities
5 options:
6 allowAdd: true
7 allowRemove: true
8 allowReorder: true
9 minItems: 1
10 maxItems: 100
11 addButton:
12 label: { en-US: "Add Commodity" }
13 icon: plus
14 position: bottom
15 defaultItem:
16 description: ""
17 quantity: 1
18 weight: 0
19 unitPrice: 0
20 layout: list
21 cols: 4
22 itemTemplate:
23 component: row
24 name: commodityRow
25 props: { spacing: 2 }
26 children:
27 - component: field
28 name: description
29 props:
30 type: text
31 label: { en-US: "Description" }
32 required: true
33 - component: field
34 name: quantity
35 props:
36 type: number
37 label: { en-US: "Qty" }
38 - component: field
39 name: weight
40 props:
41 type: number
42 label: { en-US: "Weight (kg)" }
43 - component: field
44 name: unitPrice
45 props:
46 type: number
47 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:

yaml
1onSubmit:
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:

yaml
1- component: dataGrid
2 name: chargesGrid
3 props:
4 refreshHandler: orderCharges
5 views:
6 - name: all
7 displayName: { en-US: "Charges" }
8 columns:
9 - name: id
10 isHidden: true
11 - name: description
12 label: { en-US: "Description" }
13 - name: chargeType
14 label: { en-US: "Type" }
15 - name: amount
16 label: { en-US: "Amount" }
17 showAs:
18 component: text
19 props:
20 value: "${{ amount }}"
21 - name: currency
22 label: { en-US: "Currency" }
23 options:
24 query: orderCharges
25 rootEntityName: OrderCharge
26 entityKeys: [id]
27 navigationType: dialog
28 enableDynamicGrid: false
29 enableViews: true
30 enableSearch: false
31 enablePagination: true
32 enableColumns: false
33 enableFilter: false
34 defaultView: all
35 onRowClick:
36 - dialog:
37 component: Orders/EditCharge
38 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:

yaml
1dirtyGuard:
2 enabled: true
3 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:

bash
1npx cxtms modules/order-detail-module.yaml

Fix any schema errors before moving on. Common mistakes to watch for:

  • Missing entityKeys on the dataGrid options.
  • select-async without both searchQuery and valueQuery defined.
  • field-collection without fieldName set.
  • validationSchema missing fields that have required: true in their field props.

Summary

In this lesson you:

  1. Scaffolded an OrderDetail module with --template form --options.
  2. Replaced the generic query with an Order query that includes the commodities array.
  3. Added static items to the status select field.
  4. Configured a select-async contact picker with both searchQuery and valueQuery.
  5. Added a field-collection for commodity lines with drag-and-drop and a default row.
  6. Wrote a branched onSubmit chain that creates or updates based on createMode.
  7. Embedded a read-only dataGrid for charge lines below the form.
  8. Enabled dirtyGuard to protect unsaved changes.
Build an Order Detail Form - Anko Academy