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 inner rows in every group
$value.groups[0].lines[$row].source limits an effect to the first group. Replacing groups[0] with groups[$row] would reuse the same placeholder for two independent array levels and is not supported.
Instead, let $row select a group and use map to calculate the targets in its lines array. The callback index i identifies the inner row. This complete example preserves the other properties of each line:
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",
"listen": [
"$value.groups[$row].lines"
],
"when": "${($value.groups[$row].lines || []).some((x, i) => x.target != x.source + ' / inner row ' + i)}",
"do": [
{
"type": "set",
"field": "groups.$row.lines",
"value": "${($value.groups[$row].lines || []).map((x, i) => ({...x, target: x.source + ' / inner row ' + i}))}"
}
]
}
]
}
The listener watches all of lines in each group. When a line changes, the action recalculates all targets in that group and writes the whole inner array. It does not perform an individual leaf write for only the edited line. This is suitable for read-only, derived targets.
The when condition checks whether a target needs updating. Because the listener also observes target changes, this condition prevents another write once the derived values are correct. Missing or empty lines produce no work. Source values in this example are strings; adapt the expression if empty or missing sources need special output.
For example, group 0 with sources A, B gets targets A / inner row 0, B / inner row 1; group 1 with source C gets C / inner row 0. Editing group 1 leaves group 0 unchanged. To calculate loaded values on opening, add "runOnInitialization": true to the effect.
Use the right path syntax
| Location | Example | Meaning |
|---|---|---|
| Layout reference | rows!.source | Field in the repeated row template |
| Listener or value expression | $value.rows[$row].source | Value in the concrete row |
Action field, from, or to | rows.$row.target | Literal control path with the supported row placeholder |
Do not copy the layout marker ! into an effect path. Do not escape the dot after $row: rows.$row.target is correct; a backslash before the dot is not valid JSON escaping. Value expressions use the ${...} wrapper, whereas the listener examples above use a path directly.
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 represents one array levelA path such as $value.orders[$row].lines[$row].description is ambiguous and
is rejected as E_ROW_DEPTH_UNSUPPORTED. A root effect cannot use one $row
to address two nested array levels. Use the event's $event.indexes when only
an expression needs all nested indexes, as in the example above; otherwise
split or reshape the effect so each row-scoped effect addresses one array
level.
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.