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

LocationExampleMeaning
Layout referencerows!.sourceField in the repeated row template
Listener or value expression$value.rows[$row].sourceValue in the concrete row
Action field, from, or torows.$row.targetLiteral 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].

One $row represents one array level

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