Five patterns that come up in almost every form program. Each one shows the field setup as JSON — the same structure the builder produces — plus the one or two lines of logic to paste into the Formula or Visible when box. Every configuration on this page has been validated against the platform's form rules, and every formula has been executed with sample data to confirm its output.
If the fields / return notation is new to you, read
form logic first — it is short.
Pattern 1: a dropdown with "Other" and a follow-up box
Goal: a Reason dropdown ends with "Other"; choosing it reveals a text box that is required only when shown.
| Field | Type | Logic |
|---|---|---|
reason | Dropdown | none |
other_reason | Text, required | Visible when: return fields.reason === 'Other'; |
[
{
"id": "reason",
"label": "Reason",
"type": "list",
"interface": "dropdown",
"required": true,
"attributes": {
"options": [
{ "label": "Damaged" },
{ "label": "Expired" },
{ "label": "Other" }
]
}
},
{
"id": "other_reason",
"label": "Other reason",
"type": "text",
"interface": "single_line",
"required": true,
"visible_when": "return fields.reason === 'Other';",
"attributes": { "max_length": 200 }
}
]How it behaves. The text box appears the moment "Other" is chosen and disappears on any other choice. While hidden, its required rule is skipped, so it never blocks submission invisibly.
Gotchas. A dropdown stores the option's label exactly, so the
comparison is case-sensitive: 'Other', not 'other'. And if a user
types a reason, then switches the dropdown away from "Other", the
typed text is hidden but kept — it still arrives with the record.
For a multi-select list the value is a list, not a single label, so the condition becomes:
return Array.isArray(fields.reason) && fields.reason.includes('Other');Pattern 2: a full name from two boxes
Goal: combine first and last name into one clean, read-only field.
{
"id": "full_name",
"label": "Full name",
"type": "text",
"interface": "single_line",
"read_only": true,
"formula": "return ((fields.first_name || '') + ' ' + (fields.last_name || '')).trim();",
"attributes": {}
}How it behaves. The || '' guards mean a half-filled form shows
"Ada" rather than "Ada undefined", and .trim() removes the stray
space when only one box is filled. The field fills in when the user
leaves first_name or last_name.
Pattern 3: a grand total from a list of expenses
Goal: an Expenses table where users add rows, and a read-only grand total on the main form that always matches.
From the main form's point of view, the subform's value is the list
of its rows, so the total walks the list and adds up each row's
amount:
[
{
"id": "expenses",
"label": "Expenses",
"type": "subform",
"interface": "multiple",
"attributes": {
"fields": [
{ "id": "description", "label": "Description", "type": "text", "interface": "single_line", "attributes": {} },
{ "id": "amount", "label": "Amount", "type": "number", "interface": "keyboard", "attributes": { "min": 0, "digits": { "before": 8, "after": 2 } } }
],
"display_fields": ["description", "amount"]
}
},
{
"id": "grand_total",
"label": "Grand total",
"type": "number",
"interface": "keyboard",
"read_only": true,
"formula": "return (fields.expenses || []).reduce(function (sum, row) { return sum + (Number(row.amount) || 0); }, 0);",
"attributes": { "digits": { "before": 10, "after": 2 } }
}
]Reading the formula in plain words: take the list of expense rows (or an empty list if there are none yet), and starting from zero, add each row's amount — treating a blank amount as zero.
Gotcha. The total refreshes when the user finishes editing a row, not on every keystroke inside it.
Pattern 4: a line total inside each row
Goal: inside an Items table, each row multiplies quantity by unit price into its own read-only line total.
Inside a row, fields. means that row's values — so the formula
reads its sibling fields directly:
{
"id": "items",
"label": "Items",
"type": "subform",
"interface": "multiple",
"attributes": {
"fields": [
{ "id": "item_name", "label": "Item", "type": "text", "interface": "single_line", "attributes": {} },
{ "id": "quantity", "label": "Quantity", "type": "number", "interface": "keyboard", "attributes": { "min": 0 } },
{ "id": "unit_price", "label": "Unit price", "type": "number", "interface": "keyboard", "attributes": { "min": 0, "digits": { "before": 8, "after": 2 } } },
{
"id": "line_total",
"label": "Line total",
"type": "number",
"interface": "keyboard",
"read_only": true,
"formula": "return (fields.quantity || 0) * (fields.unit_price || 0);",
"attributes": { "digits": { "before": 8, "after": 2 } }
}
],
"display_fields": ["item_name", "line_total"]
}
}How it behaves. Row formulas update as the user types, so the line
total tracks keystroke by keystroke. Putting line_total in
display_fields makes it show in each row's collapsed summary.
Patterns 3 and 4 combine naturally: line totals inside the rows, and a
grand total on the main form summing row.line_total the same way
pattern 3 sums row.amount. A row can also read the main form when it
needs context — parent.site_name from inside a row reads the form's
site_name.
Pattern 5: an automatic follow-up date
Goal: a follow-up date that is always 30 days after the inspection date, with no mental math in the field.
[
{
"id": "inspection_date",
"label": "Inspection date",
"type": "date_time",
"interface": "date",
"required": true,
"attributes": {}
},
{
"id": "follow_up_date",
"label": "Follow-up date",
"type": "date_time",
"interface": "date",
"read_only": true,
"formula": "if (!fields.inspection_date) return null; var d = new Date(fields.inspection_date); d.setDate(d.getDate() + 30); return d.toISOString();",
"attributes": {}
}
]Reading the formula in plain words: if no inspection date is set yet, stay empty; otherwise take the inspection date, move it forward 30 days, and hand it back in the date format the platform stores.
Gotchas. Return null (not empty text) when the source date is
blank — a date field stores either a real date or nothing. Change the
30 to any offset you need.
Pattern 6: number each row in a list
Goal: inside a Samples table, each row labels itself "Sample 1", "Sample 2", and so on — and the numbers stay right when a row is removed.
Inside a row, meta holds the row's position. meta.index counts from
zero, so add one for a label people read:
{
"id": "samples",
"label": "Samples",
"type": "subform",
"interface": "multiple",
"attributes": {
"fields": [
{
"id": "sample_label",
"label": "Sample",
"type": "text",
"interface": "single_line",
"read_only": true,
"formula": "return `Sample ${meta.index + 1}`;",
"attributes": {}
},
{ "id": "reading", "label": "Reading", "type": "number", "interface": "keyboard", "attributes": {} }
],
"display_fields": ["sample_label", "reading"]
}
}How it behaves. Add or remove a row and every label renumbers, so
there are no gaps. meta is only in scope inside a row — see
Rule 4 in Form logic
for meta.count, meta.isFirst, and meta.isLast.
Pattern 7: a list inside each row that depends on a choice above
The problem. The user picks a species once at the top of the form. Every row of a repeating section then picks a variety, and should only see the varieties of that species.
The setup.
- A lookup field at the top of the form,
species, pointing at a species data source. - A repeating section,
plantings, holding a lookup fieldvarietypointing at a varieties data source. - On
variety, a filter that matches the varieties source'sspecies_idcolumn against the species chosen above.
{
"id": "variety",
"label": "Variety",
"type": "lookup",
"interface": "dropdown",
"attributes": {
"lookup_source_id": "…",
"display_fields": ["name"],
"search_fields": ["name"],
"filters": [
{
"type": "dynamic",
"field": "species_id",
"operator": "eq",
"source_field_id": "species.id",
"scope": "top"
}
]
}
}How it behaves. Each row's variety list holds only that species' varieties. Change the species at the top and every row's list changes with it.
Two parts do the work, and both are easy to miss:
scope says where to read the answer from. A filter inside a repeating
section reads the row's own values by default, and the species is not there
— it is at the top of the form. "scope": "top" reads from the form root;
"parent" reads one level up, which is the same thing at one level deep but
differs inside a section nested in another section. Without it the filter
finds nothing and the list shows every variety, with no error to explain
why.
source_field_id can read one property of a lookup value. A lookup
field stores the whole selected row, so species is an object, not an id.
species.id reads that row's id column. Writing just species would
compare a whole row against an id column and match nothing.
On older forms. You may find an extra hidden field carrying a formula
like return fields.species.id;, existing only so a filter had something to
point at. That is no longer necessary, and those fields were never free —
they stored a value in every record and showed up in exports and webhook
payloads. A filter can read species.id directly.
More ideas
- Make a section conditional: give several related fields the same Visible when condition, and they appear and disappear together.
- Compute a dropdown's value: a formula on a list field works, but what it returns must match one of the options' labels exactly.
- Flag a problem automatically: a Yes/No list field with a formula
like
return (fields.expenses || []).some(function (row) { return row.amount > 500; }) ? 'Yes' : 'No';turns a judgment call into a rule.
Whatever you build, open it in test mode from the builder's Preview button to watch the logic run, then publish to a test form and run it on a device for a full check — the form logic page covers what test mode does and does not cover.