Fluent Forms Effects Engine
Overview
The effects engine lets you define reactive side-effects at the form schema root. An effect can respond to a form value change, a semantic event emitted by a specific widget, or an explicit invocation from another effect.
Main use cases:
- Automatically clear/set field values when another field changes
- Copy values between fields
- Execute server-side scripts on value changes
- React to widget events such as a button result, an opened accordion panel, or a changed wizard step
- Reuse and chain named effects with explicit inputs
- Run value-change logic once after form initialization when required
- Design and inspect form logic visually, including dynamic JEXL dependencies
- Complex form logic without writing JavaScript
Current Capabilities at a Glance
| Area | Available behavior |
|---|---|
| Triggers | Form value change, semantic widget event, or explicit invocation from another effect |
| Initialization | A value-change effect can optionally run once after defaults, initial values, and data sources settle |
| Field actions | Set, clear/reset, and deep-copy form values |
| Server workflows | Run a server-side script, optionally await it, and continue through success or error branches |
| Reuse | Invoke a named effect with recursively evaluated $input values |
| Expressions | Use JEXL in listen, when, action values, script data, and dynamic widget properties |
| Design tools | Edit the same schema in the original Effects panel or in the Form Logic diagram |
| Diagnostics | Inspect dependencies and highlight field cycles or recursive runEffect chains before saving |
Editing Effects in the Form Designer
The Form Designer provides two synchronized ways to work with the same root-level effects[] array:
| View | Best suited for |
|---|---|
| Form designer → Effects (lightning icon) | Editing one effect as a linear form, reviewing all effect properties, and viewing read-only configuration effects |
| Form Logic (top-level tab) | Understanding and editing relationships between fields, effects, widget events, actions, script branches, and dynamic properties |
The diagram is not a second workflow format. Both editors read and update the same FluentSchema JSON, and the original Form Designer remains fully supported. You can switch between the views at any time without migrating an existing form.
Form Logic Diagram
Open a form and select the Form Logic tab. The screen consists of three resizable areas: a palette on the left, the diagram canvas in the center, and the logic inspector on the right.
Palette
The palette contains the building blocks that are valid for the current form:
- Triggers — Value change, Widget event, and Invoked effect
- Actions — Set, Clear, Copy, Script, and Run effect
- Form fields — Fields discovered from the current schema
- Widget events — Semantic events declared by widgets present in the current layout
Drag an item to an exact position on the canvas, or activate it by click or keyboard. A trigger creates a new effect. A field, widget event, or action is initially a standalone node.
Adding a node from the palette does not automatically change an existing effect. Create the relationship deliberately by dragging from a node port to a highlighted compatible target.
The most important editable connections are:
| Connection | Result in the schema |
|---|---|
| Form field → value-change effect | Adds the field path to listen |
| Widget event → effect | Creates a widgetEvent trigger with objectPointer and event |
| Effect ↔ standalone action | Adds the action to the effect's do array |
| Set/Clear/Copy action → form field | Selects the action target (field or to) |
| Run effect action → invoked effect | Sets effectId |
| Effect → invoked effect | Inserts and connects a new runEffect action |
The diagram accepts either drawing direction for trigger connections between a field/widget event and an effect. The stored relationship still follows the logical direction shown above.
Canvas and Inspector
Select a node to edit it in the right inspector. The inspector reuses the existing Effects and Expressions editors, so advanced properties remain available without leaving the diagram. Selecting a field, action, event, expression, or effect shows only the relevant context instead of opening every effect at once.
Use the scope switch at the top of the canvas:
- All logic shows the complete graph.
- Selection context shows the selected node and its related subgraph. For example, selecting a field shows effects that listen to or write that field, as well as JEXL expressions that depend on it.
Without a selection, Selection context is disabled. Selecting another node recalculates the context immediately.
The toolbar provides Auto layout and Fit diagram. Dragged node positions are stored separately in settings.formLogicDiagram.nodePositions; they do not alter the form's runtime logic. Switching tabs therefore preserves the layout while schema compatibility remains unchanged.
Direct and Derived Relationships
Not every displayed edge is an independently stored relationship:
- Trigger and
runEffectedges represent directly editable schema properties. - Write edges are derived from action targets such as
set.field,clear.field, orcopy.to. - JEXL dependency edges are derived from expressions in conditions, action values, defaults, and dynamic widget properties.
- Script success and error branches are derived from
successDoanderrorDo.
When a relationship is derived, edit the expression or action in the inspector. For a directly editable edge, selecting the edge shows its source, target, and the available delete action. Deleting an edge removes only that relationship; deleting a node removes the corresponding effect, action, or dynamic property. The Delete key is also supported for a selected deletable item.
Dynamic Widget Properties
JEXL entered in an individual widget attribute is also form logic. The diagram projects these dynamic properties as expression nodes and connects every referenced form field with a visually distinct JEXL dependency edge. Use the inspector's Expressions tab to search all expressions, see their schema location, and open the corresponding editor.
Cycle Diagnostics
The diagram analyzes executable dependencies whenever the schema changes:
- A field dependency cycle is shown as a warning. It may be intentional and can still be saved, but the highlighted path should be reviewed.
- A direct or indirect
runEffectrecursion is shown as an error and blocks saving until the recursive chain is removed.
The cycle counter and inspector panel list each detected path. Select a cycle to focus all involved nodes and edges. The original Effects panel displays the same diagnostics, so switching back to the linear editor does not hide a problem.
Read-Only Configuration Effects
Effects supplied by a base form or plugin configuration remain read-only. They are visible in the Effects editor inside the Form Logic inspector and in the original Form Designer. The editable canvas works with the form's own root effects[]; configuration effects are not copied into the schema. A form may only deactivate them as described in Configuration Effects (Read-Only).
Basic Structure
Effects are defined in the JSON schema at the root level using the effects property. This first example uses the traditional value-change trigger:
{
"type": "object",
"properties": {
"checkbox": {"type": "boolean"},
"text1": {"type": "string"},
"text2": {"type": "string"}
},
"layout": ["checkbox", "text1", "text2"],
"effects": [
{
"id": "my-effect",
"listen": ["${$value.text1}"],
"when": "${$value.checkbox == true}",
"do": [{"type": "set", "field": "text2", "value": "${$value.text1}"}]
}
]
}
Effect Object Structure
| Property | Required | Type | Description |
|---|---|---|---|
id | Conditional | string | Identifier for debugging and references. Required for an invoked effect and must be unique within the root schema. |
listen | Conditional | string[] | Expressions whose output is watched. Used by value-change effects, which do not have trigger. |
trigger | Conditional | EffectTrigger | A widgetEvent or invoked trigger. It is mutually exclusive with listen. |
when | No | string | Condition evaluated in the trigger-specific expression context. If omitted, the effect always executes. |
do | Yes | EffectAction[] | Array of actions to execute when the condition is met. |
disabled | No | boolean | When true, the effect remains in the schema but is not executed. |
runOnInitialization | No | boolean | For a value-change effect, run once after defaults, initial values, and data sources settle. The default is false. |
Trigger Types
Value change
A value-change effect has listen and no trigger. It runs whenever the evaluated result of at least one listen expression changes:
{
"id": "recalculate-total",
"listen": ["${$value.quantity}", "${$value.price}"],
"do": [
{
"type": "set",
"field": "total",
"value": "${($value.quantity || 0) * ($value.price || 0)}"
}
]
}
By default, initialization only establishes the initial listen values and does not execute the effect. Set runOnInitialization to true when the effect must also run once against the fully initialized form state:
{
"id": "initialize-total",
"listen": ["${$value.quantity}", "${$value.price}"],
"runOnInitialization": true,
"do": [
{
"type": "set",
"field": "total",
"value": "${($value.quantity || 0) * ($value.price || 0)}"
}
]
}
This option applies only to value-change effects. The designer removes it when the trigger is changed to widgetEvent or invoked.
Widget event
A widget-event effect listens to one semantic event emitted by one concrete widget or layout node:
{
"id": "remember-opened-panel",
"trigger": {
"type": "widgetEvent",
"objectPointer": "/layout/0",
"event": "panelOpened"
},
"do": [
{
"type": "set",
"field": "lastOpenedPanel",
"value": "${$event.payload.index}"
}
]
}
| Trigger property | Description |
|---|---|
type | Always widgetEvent. |
objectPointer | Canonical JSON Pointer of the emitting schema node. This distinguishes multiple instances of the same widget. |
event | Stable event name declared by that widget's plugin definition. |
The Form Designer provides a widget-source dropdown with a breadcrumb, widget selector, and pointer, for example Order › Actions › Calculate · dtl-button · /layout/0/items/4. The event dropdown then shows only the human-readable events implemented by that widget. Locate widget selects the source in the designer and scrolls it into view.
Do not hand-maintain objectPointer after structural edits. The designer remaps pointers when nodes are moved, inserted, renamed, duplicated, or deleted. Because the pointer identifies a schema position rather than a persistent ID, effects should be edited together with their source widget in the Form Designer.
The event expression context adds $event:
{
name: string;
payload?: unknown;
objectPointer: string;
selector?: string;
}
Known payload members are shown by JEXL autocomplete for the selected event. For example, dtl-fluent-steps exposes $event.payload.previousIndex and $event.payload.activeIndex for activeStepChanged.
Explicit invocation
An invoked effect is a reusable named block that does not run from a value change or widget event. It can only be called by a runEffect action:
{
"id": "processCustomer",
"trigger": {"type": "invoked"},
"when": "${$input.customer != null}",
"do": [
{
"type": "set",
"field": "customerName",
"value": "${$input.customer.name}"
}
]
}
Named values passed by runEffect.inputs are available as $input. An invoked effect must have a unique non-empty id; the target must be in the same root schema. The designer updates runEffect.effectId references when an effect ID is renamed.
Action Types (EffectAction)
1. CLEAR - Clear/Reset Field Value
Clears a field value. The mode controls what happens after the value is removed. If mode is not specified, empty is used.
Mode empty (default) — Simply wipes the value (sets it to null/[]/{}). The field is marked as "dirty", meaning any schema default will not be applied. Use this when you just want to blank out the field with no further behavior:
{
"type": "clear",
"field": "text1",
"mode": "empty"
}
Mode unset — Removes the value and marks the field as "pristine" (as if the user never touched it). The field is then open to receiving its default value again — but only if the default expression produces a new value in the future (e.g. when another field it depends on changes). Use this when you want to "undo" a user's input and let the form's default logic take over again passively:
{
"type": "clear",
"field": "text1",
"mode": "unset"
}
Mode reset — Same as unset, but also immediately re-applies the schema default right away, without waiting for anything to change. If the field has a default value or default expression defined, it is evaluated and set instantly. Falls back to stored control defaults if no schema default exists. Use this when you want the field to snap back to its default value on the spot:
{
"type": "clear",
"field": "text1",
"mode": "reset"
}
unset vs resetBoth modes mark the field as pristine and allow defaults to be applied again. The difference is when:
unset— waits; the default is applied only if it changes later (reactive)reset— acts immediately; the default is applied right now
2. SET - Set Value with Expression
Sets field value using an evaluated expression:
{
"type": "set",
"field": "text2",
"value": "${$value.text1.toUpperCase()}"
}
Properties:
field- field path (for example, "text1", "nested.field", "array.0.item")value- JEXL expression that will be evaluated and its result set as field value
Expression Examples:
// Static value
{ "type": "set", "field": "status", "value": "\"active\"" }
// Copy value
{ "type": "set", "field": "text2", "value": "${$value.text1}" }
// Transform value
{ "type": "set", "field": "upper", "value": "${$value.text1.toUpperCase()}" }
// Conditional expression
{ "type": "set", "field": "label", "value": "${$value.active ? 'Active' : 'Inactive'}" }
// Complex calculation
{ "type": "set", "field": "total", "value": "${$value.quantity * $value.price}" }
3. COPY - Copy Value
Copies value from one field to another (including deep clone for objects/arrays):
{
"type": "copy",
"from": "sourceField",
"to": "targetField"
}
Properties:
from- source field pathto- target field path
Note: Copying creates a deep clone, so modifying the copied value does not affect the original.
4. SCRIPT - Execute Server-Side Script
Executes a server-side script with success/error action handling:
{
"type": "script",
"scriptCode": "notify_user_status_change",
"scriptData": {
"userId": "${$value.userId}",
"status": "${$value.status}"
},
"hideScriptError": false,
"successActions": [
{
"action": "SHOW_NOTIFICATION",
"actionData": {"message": "Status updated"}
}
],
"errorActions": [
{
"action": "SHOW_ERROR",
"actionData": {"message": "Update failed"}
}
]
}
Properties:
| Property | Required | Type | Description |
|---|---|---|---|
scriptCode | Yes | string | Script code to execute |
scriptData | No | unknown | Expression or object containing expressions, evaluated recursively before execution |
hideScriptError | No | boolean | Whether to hide script errors from the user (default: false) |
successActions | No | StoreAction[] | NgRx store actions to dispatch on successful script completion |
errorActions | No | StoreAction[] | NgRx store actions to dispatch on script error |
successDo | No | EffectAction[] | Effect actions to execute on success, with $response available in expressions |
errorDo | No | EffectAction[] | Effect actions to execute on error, with $response available in expressions |
await | No | boolean | Whether the current effect run waits for the script and its selected outcome branch (default: false) |
successActions/errorActions and successDo/errorDo serve different layers. Store actions are dispatched by FormActionsService; Do actions continue the form-effects workflow and may update fields, trigger value-change cascades, or invoke another effect.
await
await controls the ordering of the current effect run. It does not block the browser UI thread.
| Value | Behavior |
|---|---|
true | Wait for the script result, execute the matching successDo or errorDo branch in the same execution chain, wait for that branch to finish, and only then continue with subsequent actions in the parent effect. |
false or omitted | Dispatch the script and continue the parent effect immediately. The matching outcome branch still runs when the response arrives, but as a detached asynchronous continuation. |
Use await: true for ordered business workflows, especially when an outcome branch updates the form or uses runEffect. Use fire-and-forget behavior only for an independent side operation such as audit or telemetry.
An awaited script has a 60-second timeout. A timeout executes errorDo with this $response payload and then lets the parent effect continue:
{
"error": {
"code": "SCRIPT_TIMEOUT",
"message": "Awaited script did not finish before the timeout.",
"scriptCode": "loadCustomer"
}
}
successDo / errorDo
successDo and errorDo execute effect actions after a script completes, with access to the script outcome via $response. Unlike successActions/errorActions, which dispatch NgRx store actions, they continue the form-effects workflow.
{
"type": "script",
"scriptCode": "calculate_price",
"scriptData": {"productId": "${$value.productId}"},
"successDo": [
{"type": "set", "field": "price", "value": "${$response.calculatedPrice}"},
{"type": "set", "field": "discount", "value": "${$response.discount}"},
{"type": "clear", "field": "errorMessage"}
],
"errorDo": [
{"type": "clear", "field": "price"},
{"type": "set", "field": "errorMessage", "value": "${$response.message}"}
]
}
The $response variable:
- Uses parsed
response.datawhen present; otherwise it contains the returned failure payload. A thrown error is normalized as{error: ...} - Available in expressions within
successDo/errorDo, includingset.valueandrunEffect.inputs - All standard expression variables (
$value,$context,$config, ...) remain available alongside$response
Expression examples:
${$response} // entire response data
${$response.price} // nested property
${$response.items[0].name} // optional chaining
Supported action types in successDo / errorDo:
| Type | Supported | Note |
|---|---|---|
set | Yes | $response is available in the value expression |
clear | Yes | Works the same as in regular do |
copy | Yes | Works the same as in regular do |
runEffect | Yes | $response can be mapped into the target's $input |
script | No | Nested SCRIPT actions are blocked; use runEffect instead |
Execution order:
- Script is called via API
successActions/errorActions(StoreAction[]) are dispatched as NgRx actionssuccessDo/errorDo(EffectAction[]) are executed with$responsein the expression context
Combining with existing properties:
successDo/errorDo and successActions/errorActions can be used together:
{
"type": "script",
"scriptCode": "validate_data",
"successActions": [{"action": "SHOW_TOAST", "actionData": {"severity": "success"}}],
"successDo": [{"type": "set", "field": "validated", "value": "${true}"}]
}
5. RUNEFFECT - Invoke Another Effect
Calls one effect declared with "trigger": {"type": "invoked"} in the same root schema:
{
"type": "runEffect",
"effectId": "processCustomer",
"inputs": {
"customer": "${$response}",
"requestedBy": "${$runtimeInfo.userName}"
}
}
effectId is a literal effect ID, not an expression. inputs is evaluated recursively, so expressions can appear inside objects and arrays. The resulting object is exposed to the invoked effect as $input.
The runtime rejects missing or ambiguous IDs, targets that are not invoked, disabled targets, and direct or indirect invocation cycles. Use runEffect for reusable workflow steps and explicit chaining; do not create artificial form fields solely to trigger the next effect.
Expressions
All expressions in effects (listen, when, do values, etc.) are standard JEXL expressions with the same evaluation context available as in other form scripting scenarios. See Frontend Scripting with JEXL for the full reference of available variables, functions, and transforms.
Three additional variables are scoped to particular workflow stages:
| Variable | Available in | Contents |
|---|---|---|
$event | A widgetEvent effect's condition and direct actions | Event name, payload, source objectPointer, and widget selector |
$input | An invoked effect's condition and actions | The recursively evaluated runEffect.inputs object |
$response | A script's direct successDo or errorDo actions | Parsed success data or failure payload |
The Form Designer's JEXL editor suggests only variables that are valid for the current effect. When a widget event declares payload metadata, autocomplete also includes known members such as $event.payload.activeIndex.
Invocation is an explicit context boundary. If an invoked effect needs widget-event or script-outcome data, map it through runEffect.inputs and read it from $input; $event and $response are not implicitly inherited by the invoked target.
Complete Examples
Example 1: Auto-clear field when checkbox changes
{
"effects": [
{
"id": "clear-text-when-disabled",
"listen": ["${$value.enableText}"],
"when": "${$value.enableText == false}",
"do": [
{
"type": "clear",
"field": "textField",
"mode": "unset"
}
]
}
]
}
Example 2: Synchronize fullName when first/last name changes
{
"effects": [
{
"id": "update-fullname",
"listen": ["${$value.firstName}", "${$value.lastName}"],
"do": [
{
"type": "set",
"field": "fullName",
"value": "${($value.firstName || '') + ' ' + ($value.lastName || '')}"
}
]
}
]
}
Example 3: Conditional address copying
{
"effects": [
{
"id": "copy-billing-address",
"listen": ["${$value.sameAsShipping}"],
"when": "${$value.sameAsShipping == true}",
"do": [
{
"type": "copy",
"from": "shippingAddress",
"to": "billingAddress"
}
]
},
{
"id": "clear-billing-when-different",
"listen": ["${$value.sameAsShipping}"],
"when": "${$value.sameAsShipping == false}",
"do": [
{
"type": "clear",
"field": "billingAddress",
"mode": "empty"
}
]
}
]
}
Example 4: Execute script on status change
{
"effects": [
{
"id": "notify-status-change",
"listen": ["${$value.status}"],
"when": "${$value.status == 'completed'}",
"do": [
{
"type": "script",
"scriptCode": "send_notification",
"scriptData": {
"userId": "${$value.userId}",
"oldStatus": "${$context.previousStatus}",
"newStatus": "${$value.status}"
},
"successActions": [
{
"action": "SHOW_NOTIFICATION",
"actionData": {"message": "Notification sent"}
}
],
"errorActions": [
{
"action": "SHOW_ERROR",
"actionData": {"message": "Failed to send notification"}
}
]
}
]
}
]
}
Example 4b: Script with successDo - update form fields from script response
{
"effects": [
{
"id": "calculate-price-from-product",
"listen": ["${$value.productId}"],
"when": "${$value.productId != null}",
"do": [
{
"type": "script",
"scriptCode": "calculate_price",
"scriptData": {"productId": "${$value.productId}"},
"successDo": [
{"type": "set", "field": "price", "value": "${$response.calculatedPrice}"},
{"type": "set", "field": "discount", "value": "${$response.discount}"},
{"type": "clear", "field": "errorMessage"}
],
"errorDo": [
{"type": "clear", "field": "price"},
{"type": "set", "field": "errorMessage", "value": "${$response.message}"}
],
"successActions": [{"action": "[Core] ShowMessage", "actionData": {"text": "Price calculated"}}]
}
]
}
]
}
Example 4c: Awaited script with explicit outcome chaining
This example waits for loadCustomer, maps the selected outcome into an invoked effect, waits for that effect to finish, and only then finishes the original run:
{
"effects": [
{
"id": "load-customer-on-selection",
"listen": ["${$value.customerId}"],
"when": "${$value.customerId != null}",
"do": [
{
"type": "script",
"scriptCode": "loadCustomer",
"scriptData": {"customerId": "${$value.customerId}"},
"await": true,
"successDo": [
{
"type": "runEffect",
"effectId": "processCustomer",
"inputs": {"customer": "${$response}"}
}
],
"errorDo": [
{
"type": "runEffect",
"effectId": "handleCustomerError",
"inputs": {"error": "${$response.error}"}
}
]
}
]
},
{
"id": "processCustomer",
"trigger": {"type": "invoked"},
"do": [
{"type": "set", "field": "customerName", "value": "${$input.customer.name}"},
{"type": "clear", "field": "errorMessage", "mode": "empty"}
]
},
{
"id": "handleCustomerError",
"trigger": {"type": "invoked"},
"do": [
{"type": "set", "field": "errorMessage", "value": "${$input.error.message}"}
]
}
]
}
Example 5: Complex calculation with multiple fields
{
"effects": [
{
"id": "calculate-total",
"listen": ["${$value.quantity}", "${$value.price}", "${$value.taxRate}"],
"do": [
{
"type": "set",
"field": "subtotal",
"value": "${($value.quantity || 0) * ($value.price || 0)}"
},
{
"type": "set",
"field": "tax",
"value": "${(($value.quantity || 0) * ($value.price || 0)) * (($value.taxRate || 0) / 100)}"
},
{
"type": "set",
"field": "total",
"value": "${(($value.quantity || 0) * ($value.price || 0)) * (1 + (($value.taxRate || 0) / 100))}"
}
]
}
]
}
Example 6: Load data from datasource
{
"effects": [
{
"id": "load-user-details",
"listen": ["${$value.userId}"],
"do": [
{
"type": "set",
"field": "userName",
"value": "${evalScriptByCode('GetUserName')}"
}
]
}
]
}
Advanced Features
Cycle Protection
The effects engine includes automatic protection against infinite cycles:
- Max depth: 10 iterations
- Warning: Console warning after 3rd iteration
- Field change tracking: Tracks how many times each field has changed
- Execution path logging: Logs execution path for debugging
- Invocation stack: Rejects direct and indirect
runEffectcycles
Examples of effects that cause cycles:
1. Self-loop — the simplest case, an effect listens to the same field it writes to:
{
"listen": ["${$value.text}"],
"id": "self-loop",
"do": [{"type": "set", "field": "text", "value": "${$value.text + '1'}"}]
}
text changes → effect fires → changes text → effect fires → ...
2. Ping-pong — two effects that write to each other's listened field:
[
{
"listen": ["${$value.a}"],
"id": "a-to-b",
"do": [{"type": "set", "field": "b", "value": "${$value.a + 'x'}"}]
},
{
"listen": ["${$value.b}"],
"id": "b-to-a",
"do": [{"type": "set", "field": "a", "value": "${$value.b + 'y'}"}]
}
]
a changes → sets b → sets a → sets b → ...
3. Chain cycle (A → B → C → A) — a longer chain that eventually loops back:
[
{
"listen": ["${$value.x}"],
"id": "x-to-y",
"do": [{"type": "set", "field": "y", "value": "${$value.x}"}]
},
{
"listen": ["${$value.y}"],
"id": "y-to-z",
"do": [{"type": "set", "field": "z", "value": "${$value.y}"}]
},
{
"listen": ["${$value.z}"],
"id": "z-to-x",
"do": [{"type": "set", "field": "x", "value": "${$value.z + '!'}"}]
}
]
Best Practices
1. Use meaningful IDs
{
"id": "clear-shipping-when-disabled", // Good
"id": "effect1" // Bad
}
2. Be explicit with conditions
// Good - explicit condition
"when": "${$value.checkbox == true}"
// Bad - implicit type coercion can cause issues
"when": "${$value.checkbox}"
3. Use optional chaining for safe access
"value": "${$value.user.address.street || 'N/A'}" // Good
"value": "${$value.user.address.street}" // Can crash
4. Prefer COPY over SET for objects
// Good - deep clone
{ "type": "copy", "from": "sourceObject", "to": "targetObject" }
// Worse - shared reference
{ "type": "set", "field": "targetObject", "value": "${$value.sourceObject}" }
5. Choose the right clear mode
// Reset to schema default value immediately:
{ "type": "clear", "field": "myField", "mode": "reset" } // Re-applies default now
// Clear and allow default to be re-applied later (if default expression changes):
{ "type": "clear", "field": "myField", "mode": "unset" } // Marks pristine
// Just clear the value (blocks defaults):
{ "type": "clear", "field": "myField", "mode": "empty" } // Sets null/[]/{}
6. Choose script ordering explicitly
{
"type": "script",
"scriptCode": "long_running_operation",
"await": true,
"hideScriptError": false,
"successActions": [
// NgRx store actions such as notifications
],
"successDo": [
// Form actions with $response, including runEffect
],
"errorActions": [
// NgRx error actions
],
"errorDo": [
// Form workflow error handling
]
}
Use await: true when later actions or invoked effects depend on the outcome. Omit await only for an independent fire-and-forget operation. Use successDo/errorDo for form workflow and successActions/errorActions for store-level UI behavior such as data reloads, notifications, toasts, or navigation.
Debugging
Console Logging
Effects log to the console:
[FluentEffects] Max depth reached - stopped due to depth limit
[FluentEffects] Execution depth warning - warning at 3rd iteration
[FluentEffects] Control not found - field not found
[FluentEffects] Error evaluating expression - expression error
[FluentEffects] Script action executed successfully - script succeeded
[FluentEffects] Script action failed - script failed
Execution Path
For debugging cycles, check the execution path in the error message:
{
depth: 10,
executionPath: ['effect-1', 'effect-2', 'effect-1', 'effect-2', ...]
}
When to use effects vs computed attributes
| Use Case | Solution |
|---|---|
| Calculated value (read-only) | Computed attribute |
| Set value on change | Effect with SET action |
| Side effect (script, clear) | Effect |
| Synchronous transformation | Computed attribute |
| Asynchronous operation | Effect with SCRIPT action |
Widget Events at the Schema Root
Widget behavior is represented by root effects[], not by embedding effect actions into each widget's config. Every widget plugin may declare zero or more semantic events with a stable name, a human-readable label, and optional payload metadata. Only declared events are accepted by the runtime.
Representative built-in events include:
| Widget | Events | Known payload |
|---|---|---|
dtl-button, dtl-create-entity-button, dtl-submit-request-button | success, error | Operation-specific response in $event.payload |
dtl-fluent-inplace | saveSuccess, saveError, scriptSuccess, scriptError | Save or script outcome in $event.payload |
dtl-fluent-accordion | panelOpened, panelClosed | index, header |
dtl-fluent-tab | activeTabChanged | previousIndex, activeIndex |
dtl-fluent-steps | activeStepChanged | previousIndex, activeIndex |
dtl-fluent-accordion-array | activeItemChanged | previousIndex, activeIndex |
dtl-form-input-text | leftIconClicked, rightIconClicked | No payload |
dtl-clipboard-button | copySuccess, copyError | copyError provides reason |
| LOV widgets with create support | itemCreated | id, value |
The Form Designer is the source of truth for the complete list: after selecting a widget source, its event dropdown contains exactly the events registered for that widget version.
Button result example
{
"type": "object",
"properties": {
"status": {"type": "string"},
"errorMessage": {"type": "string"}
},
"layout": [
"status",
"errorMessage",
{
"type": "layout",
"title": "Load customer",
"config": {
"buttonType": "request_button",
"scriptCode": "loadCustomer"
},
"widget": {"type": "dtl-button"}
}
],
"effects": [
{
"id": "customer-load-success",
"trigger": {
"type": "widgetEvent",
"objectPointer": "/layout/2",
"event": "success"
},
"do": [
{"type": "set", "field": "status", "value": "\"loaded\""},
{"type": "clear", "field": "errorMessage", "mode": "empty"}
]
},
{
"id": "customer-load-error",
"trigger": {
"type": "widgetEvent",
"objectPointer": "/layout/2",
"event": "error"
},
"do": [
{
"type": "set",
"field": "errorMessage",
"value": "${$event.payload.error.message || $event.payload.message}"
}
]
}
]
}
Legacy widget actions
Older schemas may contain successDo, errorDo, saveSuccessDo, or similar arrays inside widget config. They remain runtime-compatible. When such a schema is opened in the Form Designer, those arrays are migrated to root widget-event effects and removed from the widget config when the edited schema is saved.
During migration, expressions such as ${$response.total} become ${$event.payload.total} because the outcome is now carried by the widget event. No manual schema migration is required solely for this change.
Common Issues
Cycles
Problem: Effect A changes field B, effect B changes field A
Solution: Use when condition or combine into single effect
Field Not Found
Problem: "Control not found for set action"
Solution: Check field path - use dot notation for nested fields
Expression Does Not Evaluate Correctly
Problem: Value is not set or is undefined Solution: Check expression syntax and console for errors
Configuration Effects (Read-Only)
Configuration effects are predefined automatic actions defined in the plugin code for a given entity type (e.g., EntityCatalogSpecification). They appear in the Form Designer under the "Configuration Effects" section as read-only entries.
Unlike the user-defined effects described above, configuration effects:
- Cannot be edited — they are defined by the developer in plugin code
- Can only be deactivated (per form) by checking the checkbox in the read-only section
- Are automatically applied to all forms of the given entity type
Each configuration effect uses the same structure as a regular effect (listen, when, do) and supports the same action types and expression context.
Example: Automatic control of the entityInstanceSpecId field
The following two configuration effects work together — the first clears the field when it is not needed, the second sets a default value when it is needed:
Effect 1 — Clear on non-instantiable specification
- ID:
instantiable-clear-entityInstanceSpecId - Listen:
$context.form.instantiable(the "Instantiable" checkbox) - When:
$context.form.instantiable === false - Action: Clear
tsmControls.entityInstanceSpecId(mode:empty)
Effect 2 — Set default instance template
- ID:
instantiable-set-default-entityInstanceSpecId - Listen:
$context.form.instantiable - When:
$context.form.instantiable === trueandentityInstanceSpecIdis empty (blank or contains an empty UUID) - Action: Set
tsmControls.entityInstanceSpecIdto$context.entityInstanceSpecId(default instance template from context)
How both effects cooperate:
User checks "Instantiable" (instantiable = true)
└─ Effect 2 fires → sets the default instance template
User unchecks "Instantiable" (instantiable = false)
└─ Effect 1 fires → clears the instance template
User checks "Instantiable" but the template is already filled in
└─ Effect 2 does NOT fire (the "is empty" condition is not met)
└─ The user's existing selection is preserved
Deactivating a configuration effect
If a specific configuration effect should not run on a particular form:
- Open the form in the Form Designer
- Go to the Effects tab
- In the "Configuration Effects" section, expand the desired effect
- Check "Deactivate this effect in schema"
A deactivated effect is displayed with an orange "Deactivated" badge and will not execute at form runtime.