Arrays and Nested Forms
Start with one row scope and one visible result. The short example below
shows the $row contract; open the complete schemas only when you need a form
to paste into the schema editor.
Row-scoped effects
Control paths can cross nested objects, characteristics, and inner forms:
customer.address.street
chars.installation.code
Although effects are stored in the root effects[] array, one definition can
run independently for each row of a fluent-array. Put $row at the array
index in listen and JEXL expressions, and use it as a dotted segment in action
paths:
{
"id": "calculate-line-total",
"listen": [
"$value.items[$row].quantity",
"$value.items[$row].unitPrice"
],
"do": [
{
"type": "set",
"field": "items.$row.total",
"value": "${($value.items[$row].quantity || 0) * ($value.items[$row].unitPrice || 0)}"
}
]
}
The path before $row identifies the array scope ($value.items in this
example). The runtime compares every concrete row and runs the effect only for
rows whose listened values changed. During that run, $row is the concrete row
index in when, action values, script data, and invoked-effect inputs. The same
index replaces $row in literal field, from, and to paths.
All $row listeners in one effect must have the same array scope. Listening to
the whole array, such as $value.items, is still useful when an effect should
update one control outside it. Setting, copying, clearing, or resetting a whole
array also updates the rendered fluent-array rows to match the new value.
Copy a value within each row
First create object rows using Array → Object → Text fields, as described in Fluent Array. A Text dropped directly into an empty Array creates a list of strings, which cannot contain separate source and target fields.
You can configure this effect in the Designer's effect editor without editing the whole JSON: add a value-change effect, enter $value.rows[$row].source as its listener, add a set action with field rows.$row.target, and enter the value expression shown below. Paths and expressions are typed into the editor fields.
This complete schema is also ready to paste into the schema editor:
Complete copy-per-row schema
{
"type": "object",
"properties": {
"rows": {
"type": "array",
"items": {
"type": "object",
"properties": {
"source": {
"type": "string",
"title": "Source",
"widget": {
"type": "text"
}
},
"target": {
"type": "string",
"title": "Target",
"widget": {
"type": "text",
"readonly": true
}
}
}
}
}
},
"layout": [
{
"propertyKey": "rows",
"type": "layout",
"widget": {
"type": "dtl-fluent-array"
},
"items": [
"rows!.source",
"rows!.target"
]
}
],
"effects": [
{
"id": "copy-current-row",
"listen": [
"$value.rows[$row].source"
],
"do": [
{
"type": "set",
"field": "rows.$row.target",
"value": "${$value.rows[$row].source + ' / row ' + $row}"
}
]
}
]
}
In the preview, add two rows and enter A in the first Source and B in the second. The targets become A / row 0 and B / row 1. Editing one Source updates the Target in that row. The example reacts to changes; add "runOnInitialization": true to the effect if existing values should also be calculated when the form opens.
Update one inner row in any group
The effect still lives in the root effects[]. Set scope to the inner array's
schema propertyKey (groups!.lines). The runtime binds it separately to
each concrete inner row in every group. ! stands for an array row in the
listener and literal action paths; JEXL expressions use $item, $row, and
$parents for row context:
Complete nested-array schema
{
"type": "object",
"properties": {
"groups": {
"type": "array",
"items": {
"type": "object",
"properties": {
"lines": {
"type": "array",
"items": {
"type": "object",
"properties": {
"source": {
"type": "string",
"title": "Source",
"widget": {
"type": "text"
}
},
"target": {
"type": "string",
"title": "Target",
"widget": {
"type": "text",
"readonly": true
}
}
}
}
}
}
}
}
},
"layout": [
{
"propertyKey": "groups",
"type": "layout",
"widget": {
"type": "dtl-fluent-array"
},
"items": [
{
"propertyKey": "groups!.lines",
"type": "layout",
"widget": {
"type": "dtl-fluent-array"
},
"items": [
"groups!.lines!.source",
"groups!.lines!.target"
]
}
]
}
],
"effects": [
{
"id": "copy-inner-rows",
"scope": "groups!.lines",
"listen": [
"$value.groups!.lines!.source"
],
"do": [
{
"type": "set",
"field": "groups!.lines!.target",
"value": "${'group ' + $parents[0].row + ', row ' + $row + ': ' + $item.source}"
}
]
}
]
}
listen follows a $value path. Use ! for array row positions in literal
listener and action paths of a scoped effect, including paths to other arrays.
In value (and when), $item is the current inner row's value, $row its
index, and $parents[0] the nearest outer {row, item} — the group in this
example.
$parents[1] is the next ancestor if there is a third array level. Ancestors
are numbered from the current row outward, so adding another outer array does
not change what $parents[0] means. For example, the value expression could
also read $parents[0].item.name if each group had a name field.
Group 0 with sources A, B gets targets group 0, row 0: A and
group 0, row 1: B; group 1 with source C gets group 1, row 0: C.
Editing a Source writes only its own Target control. The effect does not listen
to Target, so this source-to-target edge is not a cycle and needs no guard.
To calculate loaded values on opening, add "runOnInitialization": true.
For three array levels such as regions!.groups!.lines, choose
"scope": "regions!.groups!.lines", listen to
$value.regions!.groups!.lines!.source, and set
regions!.groups!.lines!.target. $row is the line index,
$parents[0].row is the group index, and $parents[1].row is the region
index. An anonymous inner array can similarly use scope array!, listener
$value.array!!.col0, and target array!!.col1.
Use the right path syntax
| Location | Example | Meaning |
|---|---|---|
| Layout reference | rows!.source | Field in the repeated row template |
| Legacy one-level listener / expression | $value.rows[$row].source | Value in the concrete row |
Legacy one-level action field, from, or to | rows.$row.target | Literal control path |
| Scoped nested listener | $value.groups!.lines!.source | $value path with bound array indexes |
| Scoped nested action path | groups!.lines!.target | Literal target path with the same bound indexes |
| Scoped JEXL expression | ${$item.source + ' / ' + $parents[0].row} | Relative row and ancestor values in JEXL |
scope binds the ! row markers in literal listener and action paths. JEXL
expressions read the same row context through $item, $row, and $parents.
For legacy one-level action paths, write rows.$row.target. Value expressions
use the ${...} wrapper; listeners use a $value path.
Widget events in nested arrays
A widget event identifies both its schema source and the concrete rendered
occurrence. For a widget nested in orders[] → lines[], an event from outer
row 2 and inner row 1 provides:
$event.objectPointer // schema source, for example /layout/0/items/1/items/0
$event.dataPointer // concrete occurrence inside orders/2/lines/1
$event.indexes // [2, 1], outermost to innermost
$row // 1, the event-owning (innermost) row
The effect remains at the root and matches the stable schema
objectPointer. The runtime obtains the actual rows from dataPointer; it
does not guess the deepest row from the root effect's location. For example:
{
"id": "remember-saved-line",
"trigger": {
"type": "widgetEvent",
"objectPointer": "/layout/0/items/1/items/0",
"event": "saveSuccess"
},
"do": [
{
"type": "set",
"field": "lastProcessedLine",
"value": "${$value.orders[$event.indexes[0]].lines[$row].description}"
}
]
}
Use $event.indexes[0] for the outer row and $row for the innermost row in
expressions. Action paths are literal paths, so field, from, and to
cannot contain an expression such as $event.indexes[0].
$row for multiple levelsA path such as $value.orders[$row].lines[$row].description is ambiguous and
is rejected as E_ROW_DEPTH_UNSUPPORTED. For a value-change effect, use
scope and ! as shown above. A widget-event effect can use
$event.indexes in expressions, as in this example. Do not put an expression
inside a literal action path.
When an asynchronous script or widget result returns, the runtime follows the
original row control. If rows were inserted or reordered, $row resolves to
that row's current index. If the row was removed, its continuation is skipped.