Part 7 of the series: "Extending bpmn-io Form-JS Beyond Its Limits"

Look at the Form Logics section in these screenshots from my production editor:
-
Read only — has a FEEL expression input (
fx) and an=prefix field for expressions -
Required — toggle switch with a FEEL (
fx) button -
Disabled — toggle switch with a FEEL (
fx) button -
Show Latest Value — toggle switch with a FEEL (
fx) button -
Persistent — toggle switch with a FEEL (
fx) button, showing= true -
Hide if — pure FEEL expression field with
=prefix
Each of these properties can be either a static value (flip the toggle) or a dynamic expression (write FEEL). The same control, the same field in the schema, supporting both modes simultaneously.
Building this took me longer than it should have because the pattern isn't documented. By the time I had built it four times — for disabled, readonly, required, and persistent — I had a pattern clean enough to extract and explain. This article documents that pattern.
The Problem
A property like disabled seems simple. It's a boolean. The form designer checks it, the field is disabled. Unchecks it, the field is enabled.
Until a process designer says: "I need this field to be disabled when the ticket status is closed, but editable when it's open."
Now you need a FEEL expression: = status = "closed". Not a boolean. A string that starts with =, evaluated at runtime against the form data.
The problem is storing both. Your schema for a field might look like:
{
"type": "textfield",
"key": "assignee",
"label": "Assignee",
"disabled": true
}
Or it might look like:
{
"type": "textfield",
"key": "assignee",
"label": "Assignee",
"disabled": "= status = \"closed\""
}
The disabled property holds either a boolean or a string. This works at first. But the problems start immediately:
Problem 1: Type ambiguity at runtime. Your runtime evaluator receives field.disabled. Is it true (boolean — always disabled) or "= status = \"closed\"" (string — evaluate this FEEL expression)? You have to check the type every time you read the value.
Problem 2: Ghost values in the schema. The form designer sets disabled to true. Later they decide to add a FEEL expression instead. They type = status = "closed". Now disabled is the string "= status = \"closed\"" — but the previous boolean true was overwritten. What if they then clear the expression? Is disabled now false? Empty string? Undefined? The single-path approach has no clean empty state.
Problem 3: The FeelToggleSwitchEntry UI doesn't match the storage. Form-JS's FeelToggleSwitchEntry expects a specific value shape depending on whether it's in toggle mode or expression mode. Mixing types in a single path confuses the component's internal state.
The solution is two separate storage paths.
What I Tried First
My first attempt stored everything in the same disabled field on the schema:
// ❌ Single path — type ambiguity
const getValue = () => {
return get(field, ['disabled'], false);
// Returns true, false, or "= some expression"
// Caller has to figure out which
};
const setValue = (value) => {
editField(field, ['disabled'], value);
// Stores true, false, or "= some expression"
// in the same path
};
This broke the runtime evaluator immediately. The evaluator checked field.disabled. If it was a string starting with =, it was a FEEL expression. If it was a boolean, it was static. This branching worked.
But then a form designer set disabled to true, saved the form, opened it again, and saw the toggle was on. They decided to add a FEEL expression. They clicked the fx button and typed = status = "closed". The string replaced the boolean.
Then they changed their mind and cleared the expression. The setValue was called with undefined or an empty string. I wrote undefined to disabled. Now field.disabled was undefined — not false. At runtime, if (field.disabled) was false (correct) but if (field.disabled === false) was also false (incorrect — it's undefined, not false). Subtle bugs in every evaluator that checked this property.
I needed a clean separation. Boolean values live in one place. Expressions live in another.
The Solution: Two Paths, Mutually Exclusive
The pattern uses two schema paths per property:
| Property | Boolean Path | Expression Path |
|---|---|---|
| Disabled | field.disabled |
field.disabledExpression |
| Readonly | field.readonly |
(handled by FeelToggleSwitchEntry internally) |
| Required | field.validate.required |
field.requiredExpression |
| Persistent | field.persistent |
field.persistentExpression |
The two paths are mutually exclusive: setting one always clears the other. The getter reads the expression path first and falls back to the boolean path. The setter routes to the correct path based on value type.
FeelToggleSwitchEntry vs FeelEntry — Understanding the Difference
Before showing the implementation, it's important to understand which component you're using and why.
FeelEntry renders a single input field with an = prefix, designed for properties that are always expressions. Used for Read only and Hide if in the screenshots — notice these have just the = prefix input, no toggle switch.
FeelToggleSwitchEntry renders a toggle switch combined with an optional FEEL expression input. When the toggle is off (false), no expression input shows. When the designer clicks the toggle on, it either enables the static boolean or reveals a FEEL expression input depending on how they interact with the fx button. Used for Required, Disabled, Show Latest Value, and Persistent in the screenshots — notice these have a toggle switch with the fx label next to the property name.
Form Logics
├── Read only fx ← FeelEntry (expression input only, = prefix)
│ = [Opened in editor]
├── Required fx [●] ← FeelToggleSwitchEntry (toggle + optional FEEL)
├── Disabled fx [●] ← FeelToggleSwitchEntry (toggle + optional FEEL)
├── Show Latest Value fx [●] ← FeelToggleSwitchEntry (toggle + optional FEEL)
├── Persistent fx [●] ← FeelToggleSwitchEntry, showing "= true"
│ = true
└── Hide if fx ← FeelEntry (expression input only)
=
The FeelToggleSwitchEntry is the right choice when the property has a meaningful static boolean state (disabled/enabled, required/not required) that a designer might want to set without writing FEEL. The fx button on the toggle switches it from boolean mode to expression mode.
The Complete Implementation: Disabled
Here is the full implementation for disabled, with every decision explained:
// DisabledEntry.js
import { get } from 'min-dash';
import { useService } from '@bpmn-io/form-js';
import { FeelToggleSwitchEntry, isFeelEntryEdited } from '@/formjs/propertiesPanel';
const INPUTS = [
'textfield', 'textarea', 'number', 'select',
'radio', 'checkbox', 'checklist', 'taglist',
'datetime', 'date', 'time', 'dropdown',
'datagrid', 'button', 'gridfield', 'fileupload', 'newImageView'
];
export function DisabledEntry(props) {
const { editField, field, eventBus } = props;
return [{
id: `logic-disabled-${field.id}`,
component: LogicDisabled,
editField,
field,
eventBus,
isEdited: isFeelEntryEdited,
isDefaultVisible: (field) => INPUTS.includes(field.type)
}];
}
function LogicDisabled(props) {
const { editField, field, id, eventBus } = props;
const debounce = useService('debounceInput') ?? ((fn) => fn);
let variables = [];
try {
const variablesService = useService('variables');
if (variablesService) {
const vars = variablesService();
if (Array.isArray(vars)) {
variables = vars.map(name => ({ name }));
}
}
} catch (err) {
console.warn('[DisabledEntry] Could not load variables:', err);
}
// ============================================================
// THE DUAL-PATH getValue
// ============================================================
const getValue = () => {
// ✅ Expression path takes priority
// If a FEEL expression exists, show it — ignore the boolean
const expr = get(field, ['disabledExpression'], '');
if (typeof expr === 'string' && expr.trim().length > 0) {
return expr;
}
// ✅ Fall back to the static boolean path
return get(field, ['disabled'], false);
};
// ============================================================
// THE DUAL-PATH setValue
// ============================================================
const setValue = (value) => {
// ✅ Detect expression: strings starting with '=' are FEEL expressions
if (typeof value === 'string' && value.trim().startsWith('=')) {
// Expression path: store in disabledExpression
editField(field, ['disabledExpression'], value);
// ✅ Clear the boolean path to avoid ghost values
// If disabled is still true, the runtime evaluator would read
// both and might use the wrong one
editField(field, ['disabled'], false);
return;
}
// ✅ Boolean path: store in disabled
const boolVal = !!value;
editField(field, ['disabled'], boolVal);
// ✅ Clear the expression path to avoid ghost values
// If the designer switches from expression to static,
// the old expression must not survive
if (typeof field.disabledExpression === 'string') {
editField(field, ['disabledExpression'], undefined);
}
};
// ============================================================
// THE CONTROL
// ============================================================
return FeelToggleSwitchEntry({
debounce,
element: field,
eventBus,
feel: 'optional', // ✅ 'optional' — can be toggle OR expression
getValue,
id,
label: 'Disabled',
tooltip: 'Toggle disabled state or add a FEEL expression (e.g., = status = "closed")',
setValue,
variables
});
}
Why Expression Path Takes Priority in getValue
The priority order in getValue is not arbitrary. Consider this sequence:
- Designer sets
disabled: true(boolean path has a value) - Designer adds expression
= status = "closed"(expression path now has a value) -
getValueis called
At step 3, both paths have values. Which one wins?
The expression must win. The designer's most recent intention was to add a FEEL expression. The boolean from step 1 is now stale — a ghost value that survived because we didn't know to clear it until step 2. If the boolean won, the field would be always-disabled regardless of the expression, because disabled: true would take effect before the evaluator even checked disabledExpression.
In the setValue implementation, whenever we write to the expression path we immediately clear the boolean. But getValue still defends against any case where both paths have values — which can happen with forms created before the dual-path system was introduced.
Why Clear the Other Path Every Time
Ghost values are values that shouldn't affect behavior but do. Here's a concrete example:
// Designer sets static disabled = true
field = { disabled: true, disabledExpression: undefined }
// Designer adds FEEL expression
// setValue called with "= status = \"closed\""
editField(field, ['disabledExpression'], '= status = "closed"');
// ✅ Clear disabled
editField(field, ['disabled'], false);
field = { disabled: false, disabledExpression: '= status = "closed"' }
// Designer removes the expression (clears the FEEL input)
// setValue called with '' or undefined
editField(field, ['disabled'], false); // boolVal = !!'' = false
// ✅ Clear expression
editField(field, ['disabledExpression'], undefined);
field = { disabled: false, disabledExpression: undefined }
// Result: field is not disabled. Correct.
Without the clear on every write:
// Designer sets static disabled = true
field = { disabled: true }
// Designer adds FEEL expression (without clearing disabled)
editField(field, ['disabledExpression'], '= status = "closed"');
field = { disabled: true, disabledExpression: '= status = "closed"' }
// Runtime evaluator runs
// getValue checks expression first → '= status = "closed"'
// evaluates → false (status is "open")
// BUT: the runtime also checks field.disabled directly for some operations
// field.disabled is still true → field is disabled regardless of expression
// Ghost value is causing wrong behavior
The clear is not defensive programming. It's correctness. Ghost values in form schemas persist because schemas are saved and loaded. A ghost value you create today will be in the schema for the lifetime of that form.
All Four Properties — Same Pattern, Different Paths
Once you have disabled working, the other three properties are the same pattern applied to different schema paths. Here is each one:
Readonly
readonly is simpler because FeelToggleSwitchEntry handles both the boolean and expression internally when the path is a single field that accepts both:
// ReadonlyEntry.js
export function ReadonlyEntry(props) {
const { editField, field, eventBus } = props;
return [{
id: `logic-readonly-${field.id}`,
component: LogicReadonly,
editField,
field,
eventBus,
isEdited: isFeelEntryEdited,
isDefaultVisible: (field) => INPUTS.includes(field.type)
}];
}
function LogicReadonly(props) {
const { editField, field, id, eventBus } = props;
const debounce = useService('debounceInput') ?? ((fn) => fn);
let variables = [];
try {
const variablesService = useService('variables');
if (variablesService) {
const vars = variablesService();
if (Array.isArray(vars)) variables = vars.map(name => ({ name }));
}
} catch (err) {}
const path = ['readonly'];
const getValue = () => {
// readonly stores expression and boolean in the same path
// FeelToggleSwitchEntry handles the type distinction internally
return get(field, path, '');
};
const setValue = (value) => {
// ✅ Store whatever comes in — boolean or expression string
// FeelToggleSwitchEntry ensures type correctness before calling setValue
const result = editField(field, path, value || false);
return result;
};
return FeelToggleSwitchEntry({
debounce,
element: field,
eventBus,
feel: 'optional',
getValue,
id,
label: 'Read only',
tooltip: 'Toggle read-only state or add a FEEL expression (e.g., = role != "editor")',
setValue,
variables
});
}
Required
required has a more complex storage path because Form-JS stores the required flag inside validate.required, not at the top level. The expression goes in requiredExpression at the top level:
// RequiredEntry.js
export function RequiredEntry(props) {
const { editField, field, eventBus } = props;
return [{
id: `logic-required-${field.id}`,
component: LogicRequired,
editField,
field,
eventBus,
isEdited: isFeelEntryEdited,
isDefaultVisible: (field) => INPUTS.includes(field.type)
}];
}
function LogicRequired(props) {
const { editField, field, id, eventBus } = props;
const debounce = useService('debounceInput') ?? ((fn) => fn);
let variables = [];
try {
const variablesService = useService('variables');
if (variablesService) {
const vars = variablesService();
if (Array.isArray(vars)) variables = vars.map(name => ({ name }));
}
} catch (err) {}
const getValue = () => {
// ✅ Expression path takes priority
const expr = get(field, ['requiredExpression'], '');
if (typeof expr === 'string' && expr.trim().length > 0) {
return expr;
}
// ✅ Fall back to validate.required (Form-JS native path)
return get(field, ['validate', 'required'], false);
};
const setValue = (value) => {
if (typeof value === 'string' && value.trim().startsWith('=')) {
// ✅ Expression: store in requiredExpression
editField(field, ['requiredExpression'], value);
// ✅ Clear validate.required to avoid conflict
const v = { ...(field.validate || {}) };
if ('required' in v) delete v.required;
editField(field, ['validate'], v);
return;
}
// ✅ Boolean: store in validate.required (Form-JS native location)
const boolVal = !!value;
const v = { ...(field.validate || {}) };
if (boolVal) {
v.required = true;
} else {
if ('required' in v) delete v.required;
}
editField(field, ['validate'], v);
// ✅ Clear expression path
if (typeof field.requiredExpression === 'string') {
editField(field, ['requiredExpression'], undefined);
}
};
return FeelToggleSwitchEntry({
debounce,
element: field,
eventBus,
feel: 'optional',
getValue,
id,
label: 'Required',
tooltip: 'Toggle required or enter a FEEL expression (e.g., = category = "other")',
setValue,
variables
});
}
The key difference from disabled: the boolean path is ['validate', 'required'] not ['required']. This matters because Form-JS's built-in validation system reads validate.required natively. Storing true in the right path means Form-JS's own validation runs for free.
Persistent
persistent follows the same pattern as disabled exactly — top-level boolean path, top-level expression path:
// PersistentEntry.js
export function PersistentEntry(props) {
const { editField, field, eventBus } = props;
return [{
id: `logic-persistent-${field.id}`,
component: LogicPersistent,
editField,
field,
eventBus,
isEdited: isFeelEntryEdited,
isDefaultVisible: (field) => INPUTS.includes(field.type)
}];
}
function LogicPersistent(props) {
const { editField, field, id, eventBus } = props;
const debounce = useService('debounceInput') ?? ((fn) => fn);
let variables = [];
try {
const variablesService = useService('variables');
if (variablesService) {
const vars = variablesService();
if (Array.isArray(vars)) variables = vars.map(name => ({ name }));
}
} catch (err) {}
const path = ['persistentExpression'];
const getValue = () => {
// ✅ Expression takes priority
const expr = get(field, path, '');
if (typeof expr === 'string' && expr.trim().length > 0) {
return expr;
}
// ✅ Fall back to static boolean
return get(field, ['persistent'], false);
};
const setValue = (value) => {
if (typeof value === 'string' && value.trim().startsWith('=')) {
editField(field, ['persistentExpression'], value);
editField(field, ['persistent'], false); // ✅ Clear boolean
return;
}
editField(field, ['persistent'], !!value);
if (typeof field.persistentExpression === 'string') {
editField(field, ['persistentExpression'], undefined); // ✅ Clear expression
}
};
return FeelToggleSwitchEntry({
debounce,
element: field,
eventBus,
feel: 'optional',
getValue,
id,
label: 'Persistent',
tooltip: 'Toggle persistent state or add a FEEL expression (e.g., = status = "approved")',
setValue,
variables
});
}
Notice in screenshot 4, Persistent shows = true in the expression field. This is a FEEL expression that evaluates to true — different from the static boolean true. A form designer used the FEEL expression mode to write = true. The runtime evaluator evaluates true as a FEEL expression (it's valid FEEL) and gets true. Same result, different path. The dual-path system stores it in persistentExpression, not persistent.
How the Runtime Evaluators Read Both Paths
The dual-path storage is only half the system. The runtime evaluators that run during form execution must read both paths and use the right one.
Here is how DisabledEvaluator reads the values:
// In DisabledEvaluator.js
_hasExpression(component) {
// ✅ Check expression path
return typeof component.disabledExpression === 'string' &&
component.disabledExpression.trim().length > 0;
}
_getExpression(component) {
if (this._hasExpression(component)) {
return component.disabledExpression;
}
return null;
}
// In evaluateAll():
components.forEach((component) => {
const identifier = component.key || component.id;
if (!identifier) return;
const rawDisabled = component.disabled;
let isDisabled = false;
// ✅ Priority 1: FEEL expression
if (this._hasExpression(component)) {
const expr = this._getExpression(component);
try {
const result = this.evaluateDisabled(expr, data);
isDisabled = this._interpretAsBoolean(result);
} catch (err) {
isDisabled = false;
}
}
// ✅ Priority 2: Static boolean
else if (rawDisabled === true) {
isDisabled = true;
}
// ✅ Priority 3: Default
else {
isDisabled = false;
}
this._disabledStates.set(identifier, isDisabled);
});
The evaluator mirrors the properties panel priority: expression first, static boolean second, default third. The dual-path storage makes this priority unambiguous — there's no need to check the type of a single value to determine what it means.
The same pattern applies in RequiredEvaluator:
const expr = this._getExpression(component);
// _getExpression checks requiredExpression first
const rawStatic = component?.validate?.required;
// rawStatic is the validate.required boolean
// Priority: expression > validate.required > component.required (legacy) > default
The Pattern Abstracted
All four properties share the same structure. Here is the abstract pattern for any property that needs toggle-or-FEEL:
function createToggleOrFeelEntry({
field,
editField,
eventBus,
entryId,
label,
tooltip,
booleanPath, // Where to store the static boolean: ['disabled']
expressionPath, // Where to store the FEEL expression: ['disabledExpression']
supportedTypes
}) {
return {
id: entryId,
component: createToggleOrFeelComponent({
field, editField, eventBus, label, tooltip,
booleanPath, expressionPath
}),
field,
eventBus,
isEdited: isFeelEntryEdited,
isDefaultVisible: (f) => supportedTypes.includes(f.type)
};
}
function createToggleOrFeelComponent({
field, editField, eventBus, label, tooltip,
booleanPath, expressionPath
}) {
return function ToggleOrFeelComponent(props) {
const { id } = props;
const debounce = useService('debounceInput') ?? ((fn) => fn);
let variables = [];
try {
const svc = useService('variables');
if (svc) variables = (svc() || []).map(name => ({ name }));
} catch (e) {}
const getValue = () => {
// Expression takes priority
const expr = get(field, expressionPath, '');
if (typeof expr === 'string' && expr.trim()) return expr;
return get(field, booleanPath, false);
};
const setValue = (value) => {
if (typeof value === 'string' && value.trim().startsWith('=')) {
editField(field, expressionPath, value);
editField(field, booleanPath, false); // Clear boolean
} else {
editField(field, booleanPath, !!value);
const currentExpr = get(field, expressionPath);
if (typeof currentExpr === 'string') {
editField(field, expressionPath, undefined); // Clear expression
}
}
};
return FeelToggleSwitchEntry({
debounce, element: field, eventBus,
feel: 'optional', getValue, id,
label, tooltip, setValue, variables
});
};
}
In practice I wrote each entry separately rather than using this abstraction — partly because required has a nested boolean path (validate.required) that doesn't fit the simple pattern cleanly, and partly because each entry needs its own ID format and supported types list. But the abstraction makes the shared structure visible.
Edge Cases Worth Knowing
Empty expression string. When a designer opens the FEEL expression editor and closes it without typing anything, setValue is called with '' or undefined. The check value.trim().startsWith('=') correctly identifies this as not-an-expression and routes to the boolean path with !!'' = false. The field becomes not-disabled. Correct.
FEEL expression that evaluates to a string, not a boolean. What if a designer writes = category where category is "A"? The expression evaluates to the string "A", not a boolean. The runtime evaluator's _interpretAsBoolean method handles this: non-empty strings return true, empty strings return false. So = category where category is any non-empty string means "disabled". This is probably not what the designer intended but it's consistent behavior.
Legacy schemas with required: true at top level. Some older Form-JS schemas store required at field.required not field.validate.required. The RequiredPropertiesProvider (from Article 6) handles migration: it reads the legacy path and writes it to the canonical path on load. Once migrated, the dual-path system takes over. The dual-path getValue also reads the legacy path as a last fallback.
Two forms on the same page. Each form instance has its own schema state. The dual-path system is per-field in the schema, so there's no cross-form interference. This is one advantage of schema-stored state over global state.
The Tradeoffs
Two schema paths per property means larger schemas. A field that uses both disabled and disabledExpression has more keys than one that only uses disabled. For forms with many fields and many logic properties, the schema grows. In practice this is not a problem — schemas are small JSON documents — but it's worth being aware of for very large forms.
The clear-on-write approach loses information. When a designer switches from expression = status = "closed" to static true, the expression is deleted from the schema. If they switch back to expression mode, they have to type the expression again. An alternative would be to preserve the expression in a separate "draft" path and restore it when switching back to expression mode. I chose simplicity over convenience here.
The runtime evaluator must know both paths. Any evaluator that reads the disabled state must check both field.disabled and field.disabledExpression in the right priority order. If you add a new evaluator that only checks field.disabled, it will miss FEEL expressions. The dual-path system requires discipline in every place that reads these values.
isFeelEntryEdited may not reflect the true edited state. The isFeelEntryEdited helper checks whether the entry's value is non-empty. But with dual paths, a field could have disabled: false (default) and disabledExpression: undefined (default) and still show as "not edited" even if it was previously configured and then reset. This is cosmetically imperfect but functionally correct.
What Comes Next
The toggle-or-FEEL pattern handles the editor side: storing the right values in the right schema paths. But storing the values is only half the work. The runtime side — reading those paths, evaluating FEEL expressions, and applying the results to the form — is what the evaluators do.
Article 8 covers the five evaluators (Binding, Hide, Disabled, Required, Persistent) that run at runtime and read the schema paths you've been writing to. It shows how they all share the same event-driven architecture and the same priority chain: FEEL expression first, static value second, default third.
This is Part 7 of "Extending bpmn-io Form-JS Beyond Its Limits." The series covers the complete architecture for production-grade Form-JS extensions — the documentation that doesn't exist yet.
Tags: camunda bpmn formjs feel properties-panel form-editor dynamic-forms javascript devex

Top comments (0)