Skip to main content
Version: 2.4

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​

LocationExampleMeaning
Layout referencerows!.sourceField in the repeated row template
Legacy one-level listener / expression$value.rows[$row].sourceValue in the concrete row
Legacy one-level action field, from, or torows.$row.targetLiteral control path
Scoped nested listener$value.groups!.lines!.source$value path with bound array indexes
Scoped nested action pathgroups!.lines!.targetLiteral 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].

Do not repeat legacy $row for multiple levels

A 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.