Part 8 of the series: "Extending bpmn-io Form-JS Beyond Its Limits"
By the time I had built the third evaluator, I noticed something. The code looked almost identical to the first two. The same constructor structure. The same event subscriptions. The same state Map. The same change detection. The same _evaluating flag. Only the specific thing each evaluator did with its results was different.
I had accidentally designed a pattern without realizing it. The fourth and fifth evaluators were written by copying the third, changing the class name, and swapping out the specific evaluation and application logic. Each took about 30 minutes instead of the days the first one took.
This article documents that pattern — the six responsibilities every evaluator shares, what's unique in each of the five I built, and the template you can use to build your own.
The Problem
Form-JS supports static properties: a field is disabled or it isn't, required or it isn't, hidden or it isn't. These are set in the schema at design time and don't change at runtime.
What Form-JS doesn't support out of the box is dynamic properties: a field that becomes disabled when status = "closed", required when category = "other", hidden when role != "admin". For these you need runtime evaluators — classes that watch the form data for changes and update field states accordingly.
The challenge is building evaluators that:
- Don't evaluate unnecessarily (every
changedevent on a 50-field form would be 50 evaluations per keystroke without guards) - Don't cause infinite loops (evaluation →
_setState→changedevent → evaluation → ...) - Apply results correctly (different properties apply differently —
display: nonefor hidden,disabledattribute for disabled, schema mutation for required) - Stay in sync with the schema (when the form designer changes a FEEL expression in the editor, the runtime should update immediately)
Building this correctly for one property is work. Building it for five without a shared pattern is maintenance debt.
What I Tried First
My first evaluator — BindingEvaluator, for computing derived field values — worked. Then I built HideEvaluator. I copied large sections from BindingEvaluator, changed some variable names, and added the hide-specific DOM logic. Then I needed to fix a debounce timing issue in BindingEvaluator. I fixed it there. I forgot to fix it in HideEvaluator. Two weeks later a bug report came in about hide logic not updating reliably.
The problem was obvious in retrospect: I had copied code instead of extracting the pattern. Every fix needed to be applied to five files. Every improvement needed to be replicated five times.
The right approach — which I arrived at after building all five — is to understand the six shared responsibilities first, extract those into a shared base, and let each evaluator implement only what's unique.
I'm presenting the pattern here before the implementations so you don't make the same mistake I did.
The Six Shared Responsibilities
Every evaluator in my system does exactly these six things, in this order:
1. INITIALIZE
On form.init: set _initialized = true, trigger first evaluation
2. SUBSCRIBE
On changed: if initialized and not evaluating, schedule evaluation (10ms)
On propertiesPanel.updated: if initialized and not evaluating, schedule evaluation (50ms)
On form.rendered: apply current state to DOM
3. EVALUATE ALL
Get schema and form data from form state
For each component: determine the correct state value
Using priority chain: FEEL expression → static flag → default
4. DETECT CHANGES
Compare new states to previous states
Only proceed if something changed
5. FIRE EVENT
Broadcast state changes via eventBus
Other parts of the system can react
6. APPLY TO DOM
Update the DOM to reflect new states
Each evaluator does this differently
Every evaluator has all six. What varies is Step 3 (what the evaluation produces) and Step 6 (how results are applied).
Let me show the shared structure first, then the unique parts.
The Shared Structure
export class [Name]Evaluator {
constructor(eventBus, form) {
this._eventBus = eventBus;
this._form = form;
this._evaluating = false; // Re-entry guard
this._initialized = false; // Initialization guard
this._feelEngine = null; // FEEL engine
this._states = new Map(); // Current state per field key/id
// Initialize FEEL engine
try {
this._feelEngine = { evaluate };
} catch (err) {
console.warn('[NAME_Evaluator] FEEL engine not available, using JS fallback');
}
// =========================================================
// RESPONSIBILITY 1 + 2: Initialize and Subscribe
// =========================================================
// Initialize on form.init
this._eventBus.on('form.init', () => {
this._initialized = true;
// Delay first evaluation to let the form fully render
setTimeout(() => this.evaluateAll(), 50);
});
// Re-evaluate when form data changes
this._eventBus.on('changed', () => {
if (!this._initialized || this._evaluating) return;
// Short delay — data just changed, evaluate soon
setTimeout(() => {
if (!this._evaluating) this.evaluateAll();
}, 10);
});
// Re-evaluate when schema changes (editor property panel updates)
this._eventBus.on('propertiesPanel.updated', () => {
if (!this._initialized || this._evaluating) return;
// Longer delay — schema change may trigger multiple updates
setTimeout(() => {
if (!this._evaluating) this.evaluateAll();
}, 50);
});
// Apply current state to DOM after form renders
// (DOM may have been rebuilt by a re-render)
this._eventBus.on('form.rendered', () => {
setTimeout(() => this._applyToDOM(), 50);
});
}
// =========================================================
// RESPONSIBILITY 3 + 4 + 5: Evaluate, Detect, Fire
// =========================================================
evaluateAll() {
if (!this._form?._state?.schema) return;
this._evaluating = true; // ✅ Set re-entry guard FIRST
try {
const schema = this._form._state.schema;
const data = this._form._state.data || {};
const components = this._getAllComponents(schema.components || []);
if (!components.length) return;
let hasChanges = false;
const previousStates = new Map(this._states);
components.forEach((component) => {
const identifier = component.key || component.id;
if (!identifier) return;
// ✅ The priority chain — same in every evaluator
const newState = this._determineState(component, data);
const previousState = previousStates.get(identifier);
if (previousState !== newState) hasChanges = true;
this._states.set(identifier, newState);
});
// ✅ Only proceed if something changed
if (hasChanges) {
this._applyToDOM();
// ✅ Fire event so other parts of the system can react
this._eventBus.fire('[name].states.changed', {
states: Object.fromEntries(this._states)
});
}
} catch (err) {
console.error('[NAME_Evaluator] Error:', err);
} finally {
this._evaluating = false; // ✅ Always release the guard
}
}
// =========================================================
// The Priority Chain — implemented in each evaluator
// =========================================================
_determineState(component, data) {
// Priority 1: FEEL expression
if (this._hasExpression(component)) {
const expr = this._getExpression(component);
try {
const result = this._evaluate(expr, data);
return this._interpretAsBoolean(result);
} catch (err) {
return false; // Error → safe default
}
}
// Priority 2: Static flag
if (component.[staticProperty] === true) {
return true;
}
// Priority 3: Default
return false;
}
// =========================================================
// RESPONSIBILITY 6: Apply to DOM — unique per evaluator
// =========================================================
_applyToDOM() {
// Each evaluator implements this differently
// See individual evaluator sections below
}
// =========================================================
// Shared utilities
// =========================================================
_getAllComponents(components = []) {
const all = [];
for (const component of components) {
all.push(component);
if (component.type === 'group' && Array.isArray(component.components)) {
all.push(...this._getAllComponents(component.components));
}
}
return all;
}
_evaluate(expression, data) {
// Three-stage pipeline from Article 3
let cleanExpr = expression.trim();
if (cleanExpr.startsWith('=')) cleanExpr = cleanExpr.substring(1).trim();
const context = this.prepareContext(data);
try {
const result = this._feelEngine.evaluate('expression', cleanExpr, context);
if (result !== null && result !== undefined) return result;
} catch (feelErr) {
// Fall through to JavaScript
}
return this._evaluateJavaScript(cleanExpr, context);
}
_interpretAsBoolean(value) {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
const n = value.trim().toLowerCase();
if (n === 'true') return true;
if (n === 'false') return false;
return n.length > 0;
}
if (typeof value === 'number') return value !== 0;
return false;
}
}
This shared structure handles all six responsibilities. The unique parts — _determineState, _hasExpression, _getExpression, and _applyToDOM — are what each evaluator customizes.
Why the _evaluating Flag Exists
Without the re-entry guard, you get an infinite loop:
evaluateAll() runs
→ _setState({ data: updates })
→ form fires 'changed' event
→ setTimeout callback runs
→ evaluateAll() runs again
→ _setState again
→ 'changed' fires again
→ infinite loop
The _evaluating flag breaks the loop:
// ✅ Set before any evaluation
this._evaluating = true;
try {
// ... evaluation logic ...
this._form._setState({ data: updates }); // Fires 'changed'
// But the 'changed' handler checks _evaluating first:
// this._eventBus.on('changed', () => {
// if (!this._initialized || this._evaluating) return; // ← exits here
// });
} finally {
// ✅ Always release — even if evaluation throws
this._evaluating = false;
}
The finally block is not optional. If evaluation throws and you never release the flag, the evaluator silently stops working for the rest of the form session. No errors. No updates. Forms just stop responding to data changes.
Why Two Different Debounce Delays
// After data changes — short delay
this._eventBus.on('changed', () => {
setTimeout(() => { ... evaluateAll() }, 10);
});
// After schema changes (editor) — longer delay
this._eventBus.on('propertiesPanel.updated', () => {
setTimeout(() => { ... evaluateAll() }, 50);
});
The changed event fires when form data changes — a user types a character. You want evaluation to run quickly so the UI responds. Ten milliseconds is fast enough to feel immediate but gives the event loop time to batch multiple rapid changed events.
propertiesPanel.updated fires when a form designer changes a property in the editor. Schema changes can trigger cascading updates — the editor may fire multiple propertiesPanel.updated events as it processes a single user action. Fifty milliseconds gives these updates time to settle before you evaluate, preventing multiple redundant evaluations from a single designer action.
The double-check pattern (if (!this._evaluating) this.evaluateAll()) inside the setTimeout handles the case where multiple events fire before the timeout resolves:
setTimeout(() => {
// ✅ By the time this runs, another evaluation may have already started
// Check again before evaluating
if (!this._evaluating) this.evaluateAll();
}, 10);
The Five Evaluators — What's Unique in Each
1. BindingEvaluator — Updates Form Data
What it does: Evaluates FEEL expressions stored in field.feelExpression and writes the results back into the form data via _setState. Used for computed fields — = firstName + " " + lastName, = quantity * price.
What makes it unique: It doesn't apply to the DOM at all. Instead of manipulating DOM attributes or styles, it calls this._form._setState({ data: newData }) to update the form's data state. Form-JS re-renders the affected fields automatically.
// BindingEvaluator — unique parts
_hasExpression(component) {
return typeof component.feelExpression === 'string' &&
component.feelExpression.trim().length > 0;
}
_getExpression(component) {
return component.feelExpression;
}
// BindingEvaluator has NO static fallback — expressions only
_determineState(component, data) {
if (!this._hasExpression(component)) return undefined;
try {
return this._evaluate(component.feelExpression, data);
// Returns the computed value, not a boolean
} catch (err) {
console.error(`[BindingEvaluator] Error evaluating ${component.key}:`, err);
return undefined;
}
}
// ✅ Unique: updates form data, not DOM
evaluateAll() {
this._evaluating = true;
try {
const schema = this._form._state.schema;
const data = this._form._state.data || {};
const components = this._getAllComponents(schema.components || []);
const bound = components.filter(c => c.feelExpression && c.key);
if (!bound.length) return;
const updates = {};
let hasChanges = false;
bound.forEach(component => {
const result = this._determineState(component, data);
if (result !== undefined && data[component.key] !== result) {
updates[component.key] = result;
hasChanges = true;
}
});
if (hasChanges) {
// ✅ Write results back into form data
this._form._setState({ data: { ...data, ...updates } });
// No DOM manipulation needed — form re-renders the fields
}
} finally {
this._evaluating = false;
}
}
// ✅ No _applyToDOM needed — _setState handles it
_applyToDOM() {
// No-op for BindingEvaluator
}
The re-entry risk is highest here. Writing to form data via _setState fires the changed event. Without the _evaluating flag, this causes an infinite loop: evaluate → update data → changed fires → evaluate again → ...
2. HideEvaluator — Toggles Visibility
What it does: Evaluates field.conditional.hide FEEL expressions and toggles field visibility in the DOM.
What makes it unique: It reads from a nested path (component.conditional.hide) rather than a top-level property, and applies results by setting display: none on the field's DOM element.
// HideEvaluator — unique parts
_hasExpression(component) {
// ✅ Reads from nested conditional.hide path
return typeof component.conditional?.hide === 'string' &&
component.conditional.hide.trim().length > 0;
}
_getExpression(component) {
return component.conditional?.hide;
}
_determineState(component, data) {
// No static fallback for hide — expressions only
if (!this._hasExpression(component)) return false;
try {
const result = this._evaluate(component.conditional.hide, data);
return this._interpretAsBoolean(result);
} catch (err) {
return false; // Error → show the field (safe default)
}
}
// ✅ Unique: toggles display:none and CSS class
_applyToDOM() {
requestAnimationFrame(() => {
this._states.forEach((isHidden, fieldKey) => {
const fieldElement = this._findFieldElement(fieldKey);
if (!fieldElement) return;
if (isHidden) {
fieldElement.style.display = 'none';
fieldElement.classList.add('fjs-hidden');
} else {
fieldElement.style.display = '';
fieldElement.classList.remove('fjs-hidden');
}
});
});
}
Why requestAnimationFrame? DOM operations should be batched to avoid layout thrashing. requestAnimationFrame ensures all the visibility updates happen in a single browser paint cycle rather than triggering a reflow for each field.
3. DisabledEvaluator — Sets Disabled Attribute
What it does: Evaluates field.disabledExpression (FEEL) or reads field.disabled (static) and sets or removes the disabled HTML attribute on input elements inside the field.
What makes it unique: It must find the actual <input>, <select>, or <textarea> elements inside the field wrapper — not just the wrapper element — because the disabled attribute must be on the actual form control.
// DisabledEvaluator — unique parts
_hasExpression(component) {
return typeof component.disabledExpression === 'string' &&
component.disabledExpression.trim().length > 0;
}
_getExpression(component) {
return component.disabledExpression;
}
_determineState(component, data) {
const identifier = component.key || component.id;
// ✅ Priority 1: FEEL expression
if (this._hasExpression(component)) {
try {
const result = this._evaluate(component.disabledExpression, data);
return this._interpretAsBoolean(result);
} catch (err) {
console.error(`[DisabledEvaluator] Error for ${identifier}:`, err);
return false;
}
}
// ✅ Priority 2: Static disabled flag
else if (component.disabled === true) {
return true;
}
// ✅ Priority 3: Default
return false;
}
// ✅ Unique: sets disabled attribute on actual form controls
_applyToDOM() {
requestAnimationFrame(() => {
this._states.forEach((isDisabled, fieldKey) => {
const fieldElement = this._findFieldElement(fieldKey);
if (!fieldElement) return;
// ✅ Find the actual input controls — not just the wrapper
const inputs = fieldElement.querySelectorAll(
'input, select, textarea, button'
);
inputs.forEach((input) => {
if (isDisabled) {
input.setAttribute('disabled', 'disabled');
} else {
input.removeAttribute('disabled');
}
});
// ✅ Also add CSS class for styling hooks
if (isDisabled) {
fieldElement.classList.add('fjs-disabled');
} else {
fieldElement.classList.remove('fjs-disabled');
}
});
});
}
The field-finding challenge. Form-JS generates field DOM IDs using both the form ID and the field ID: fjs-form-{formId}-{fieldId}. To find a field's element from its key, you first look up the component's id in the schema, then search the DOM. The _findFieldElement method handles this:
_findFieldElement(fieldKey) {
let fieldId = fieldKey;
// Map field key to field id via schema
if (this._form?._state?.schema) {
const allComponents = this._getAllComponents(
this._form._state.schema.components || []
);
const component = allComponents.find(c => c.key === fieldKey);
if (component?.id) fieldId = component.id;
}
// Try to find by input element ID suffix
const inputById = document.querySelector(
`input[id$="-${fieldId}"], select[id$="-${fieldId}"], textarea[id$="-${fieldId}"]`
);
if (inputById) return inputById.closest('.fjs-form-field');
// Try by input name attribute
const inputByName = document.querySelector(
`input[name="${fieldKey}"], select[name="${fieldKey}"], textarea[name="${fieldKey}"]`
);
if (inputByName) return inputByName.closest('.fjs-form-field');
// Fall back to scanning all fields
const allFields = document.querySelectorAll('.fjs-form-field');
for (const field of allFields) {
const input = field.querySelector('input, select, textarea, button');
if (input) {
const inputId = input.getAttribute('id') || '';
if (inputId.endsWith(`-${fieldId}`) || inputId === fieldId) {
return field;
}
}
}
return null;
}
All five evaluators use this same _findFieldElement method — this is another candidate for the shared base class.
4. RequiredEvaluator — Mutates the Schema
What it does: Evaluates field.requiredExpression (FEEL) or reads field.validate.required (static) and makes fields required or not required at runtime.
What makes it unique and controversial: Instead of just applying DOM attributes (which RequiredEvaluator also does), it mutates the schema object directly and triggers a form re-render. This is necessary because Form-JS's built-in validation reads validate.required from the schema — if you only set DOM attributes, the form's validate() method won't know the field is required.
// RequiredEvaluator — unique parts
_hasExpression(component) {
// ✅ Check dedicated expression path first
if (typeof component.requiredExpression === 'string' &&
component.requiredExpression.trim().length > 0) {
return true;
}
// ✅ Also check if required itself is a FEEL expression string
if (typeof component.required === 'string' &&
component.required.trim().startsWith('=')) {
return true;
}
return false;
}
_getExpression(component) {
if (typeof component.requiredExpression === 'string' &&
component.requiredExpression.trim().length > 0) {
return component.requiredExpression;
}
return component.required; // The expression string
}
_determineState(component, data) {
const key = component.key;
// ✅ Priority 1: FEEL expression
if (this._hasExpression(component)) {
const expr = this._getExpression(component);
try {
const result = this._evaluate(expr, data);
return this._interpretAsBoolean(result);
} catch (err) {
console.error(`[RequiredEvaluator] Error for ${key}:`, err);
return false;
}
}
// ✅ Priority 2: validate.required (Form-JS native)
const staticRequired = component?.validate?.required;
if (staticRequired === true || typeof staticRequired === 'string') {
return true;
}
// ✅ Priority 3: Legacy top-level required boolean
if (component.required === true) {
return true;
}
return false;
}
// ✅ UNIQUE: Mutates the schema to force Form-JS re-render
// This is necessary because form.validate() reads from the schema
evaluateAll() {
this._evaluating = true;
try {
const schema = this._form._state.schema;
const data = this._form._state.data || {};
const components = this._getAllComponents(schema.components || []);
if (!components.length) return;
let hasChanges = false;
const previousStates = new Map(this._states);
components.forEach((component) => {
if (!component.key) return;
const isRequired = this._determineState(component, data);
const previousState = previousStates.get(component.key);
if (previousState !== isRequired) hasChanges = true;
this._states.set(component.key, isRequired);
// ✅ Mutate the schema object so form.validate() sees the change
if (!component.validate) component.validate = {};
if (isRequired) {
component.validate.required = true;
} else {
if ('required' in component.validate) {
delete component.validate.required;
}
}
});
if (hasChanges) {
// ✅ Shallow clone triggers re-render without changing schema content
this._form._setState({ schema: { ...schema } });
// Also apply DOM attributes as a backup
setTimeout(() => this._applyToDOM(), 0);
this._eventBus.fire('required.states.changed', {
states: Object.fromEntries(this._states)
});
}
} finally {
this._evaluating = false;
}
}
// ✅ Applies DOM attributes AND aria attributes for accessibility
_applyToDOM() {
this._states.forEach((isRequired, fieldKey) => {
const fieldElement = this._findFieldElement(fieldKey);
if (!fieldElement) return;
const inputs = fieldElement.querySelectorAll('input, select, textarea');
inputs.forEach((input) => {
if (isRequired) {
input.setAttribute('required', 'required');
input.setAttribute('aria-required', 'true');
} else {
input.removeAttribute('required');
input.removeAttribute('aria-required');
}
});
if (isRequired) {
fieldElement.classList.add('fjs-required');
} else {
fieldElement.classList.remove('fjs-required');
}
});
}
The schema mutation approach explained. Form-JS's validate() method iterates the schema and checks component.validate.required. If you only set a DOM attribute, the schema still says the field is not required. The form would allow submission with an empty "required" field — only showing a browser-level validation message, not Form-JS's own validation UI.
By mutating component.validate.required directly and triggering a re-render with { schema: { ...schema } } (a shallow clone that signals state change), you make Form-JS's own validation system aware of the dynamic required state. The field gets Form-JS's asterisk indicator, its own validation message, and is blocked on submit.
The shallow clone { ...schema } is a technique to trigger Form-JS's change detection without actually changing schema content — only the object reference changes, which is enough to cause a re-render.
5. PersistentEvaluator — Sets Data Attributes
What it does: Evaluates field.persistentExpression (FEEL) or reads field.persistent (static) and marks fields with a data-persistent attribute. Unlike the other evaluators, it doesn't affect form behavior directly — it marks fields for application-layer consumption.
What makes it unique: The DOM application is the simplest of all five evaluators — just a data attribute — because persistent is a custom property with no built-in Form-JS behavior. Its meaning and effect are defined entirely by your application layer.
// PersistentEvaluator — unique parts
_hasExpression(component) {
return typeof component.persistentExpression === 'string' &&
component.persistentExpression.trim().length > 0;
}
_getExpression(component) {
return component.persistentExpression;
}
_determineState(component, data) {
const identifier = component.key || component.id;
// ✅ Priority 1: FEEL expression
if (this._hasExpression(component)) {
try {
const result = this._evaluate(component.persistentExpression, data);
return this._interpretAsBoolean(result);
} catch (err) {
return false;
}
}
// ✅ Priority 2: Static flag
else if (component.persistent === true) {
return true;
}
return false;
}
// ✅ Unique: sets data-persistent attribute for application-layer use
_applyToDOM() {
requestAnimationFrame(() => {
this._states.forEach((isPersistent, fieldKey) => {
const fieldElement = this._findFieldElement(fieldKey);
if (!fieldElement) return;
if (isPersistent) {
fieldElement.classList.add('fjs-persistent');
fieldElement.setAttribute('data-persistent', 'true');
} else {
fieldElement.classList.remove('fjs-persistent');
fieldElement.removeAttribute('data-persistent');
}
});
});
}
What "persistent" means in the application. When a form is submitted but has validation errors, some applications want to preserve the values of certain fields across form resets or re-opens. A field marked data-persistent="true" signals to the application layer: "save this field's value even if the form resets." The evaluator marks the field. The application decides what to do with the marking.
Side by Side: What Each Evaluator Does Uniquely
| Evaluator | Expression Path | Static Path |
_applyToDOM Effect |
Custom Behavior |
|---|---|---|---|---|
| Binding | feelExpression |
None | No DOM — _setState({data})
|
Returns computed values, not booleans |
| Hide | conditional.hide |
None |
display: none + fjs-hidden
|
Reads nested path |
| Disabled | disabledExpression |
disabled |
disabled attr + fjs-disabled
|
Targets inputs inside wrapper |
| Required | requiredExpression |
validate.required |
required attr + aria-required
|
Mutates schema + triggers re-render |
| Persistent | persistentExpression |
persistent |
data-persistent attr |
No form behavior — application signal |
The Custom Events Each Evaluator Fires
Every evaluator fires a custom event when states change. These events allow other parts of the system to react without coupling directly to the evaluators:
// BindingEvaluator fires nothing — it writes to form data directly
// HideEvaluator
this._eventBus.fire('hidden.states.changed', {
states: Object.fromEntries(this._hiddenStates)
});
// DisabledEvaluator
this._eventBus.fire('disabled.states.changed', {
states: Object.fromEntries(this._disabledStates)
});
// RequiredEvaluator — listened to by RequiredValidator
this._eventBus.fire('required.states.changed', {
states: Object.fromEntries(this._requiredStates)
});
// RequiredValidator listens:
this._eventBus.on('required.states.changed', () => this._applyErrorsToForm());
// PersistentEvaluator
this._eventBus.fire('persistent.states.changed', {
states: Object.fromEntries(this._persistentStates)
});
The required.states.changed event is the most important. RequiredValidator (a separate class, covered in Article 10) listens for this event and re-runs form validation to surface required-field errors in the form UI whenever required states change.
The Complete Template for a New Evaluator
Copy this to build a new evaluator. Replace [Name], [name], [expressionProperty], [staticProperty], and implement _hasExpression, _getExpression, _determineState, and _applyToDOM:
// [Name]Evaluator.js
import { evaluate } from '../FeelEngine';
export class [Name]Evaluator {
constructor(eventBus, form) {
this._eventBus = eventBus;
this._form = form;
this._evaluating = false;
this._initialized = false;
this._feelEngine = null;
this._states = new Map();
try {
this._feelEngine = { evaluate };
} catch (err) {
console.warn('[NAME_Evaluator] FEEL engine unavailable, using JS fallback');
}
// ① Initialize
this._eventBus.on('form.init', () => {
this._initialized = true;
setTimeout(() => this.evaluateAll(), 50);
});
// ② Subscribe — data changes
this._eventBus.on('changed', () => {
if (!this._initialized || this._evaluating) return;
setTimeout(() => {
if (!this._evaluating) this.evaluateAll();
}, 10);
});
// ② Subscribe — schema changes (editor)
this._eventBus.on('propertiesPanel.updated', () => {
if (!this._initialized || this._evaluating) return;
setTimeout(() => {
if (!this._evaluating) this.evaluateAll();
}, 50);
});
// ② Subscribe — re-apply after form re-renders
this._eventBus.on('form.rendered', () => {
setTimeout(() => this._applyToDOM(), 50);
});
}
// ③④⑤ Evaluate, Detect, Fire
evaluateAll() {
if (!this._form?._state?.schema) return;
this._evaluating = true;
try {
const schema = this._form._state.schema;
const data = this._form._state.data || {};
const components = this._getAllComponents(schema.components || []);
if (!components.length) return;
let hasChanges = false;
const previousStates = new Map(this._states);
components.forEach((component) => {
const identifier = component.key || component.id;
if (!identifier) return;
const newState = this._determineState(component, data);
if (previousStates.get(identifier) !== newState) hasChanges = true;
this._states.set(identifier, newState);
});
if (hasChanges) {
this._applyToDOM();
this._eventBus.fire('[name].states.changed', {
states: Object.fromEntries(this._states)
});
}
} catch (err) {
console.error('[[Name]Evaluator] Error:', err);
} finally {
this._evaluating = false; // ✅ Always release
}
}
// ⑥ Implement these four methods for your specific property:
_hasExpression(component) {
return typeof component.[expressionProperty] === 'string' &&
component.[expressionProperty].trim().length > 0;
}
_getExpression(component) {
return component.[expressionProperty];
}
_determineState(component, data) {
// Priority 1: FEEL expression
if (this._hasExpression(component)) {
try {
const result = this._evaluate(component.[expressionProperty], data);
return this._interpretAsBoolean(result);
} catch (err) {
return false;
}
}
// Priority 2: Static flag
if (component.[staticProperty] === true) return true;
// Priority 3: Default
return false;
}
_applyToDOM() {
// Implement: what does this state mean for the DOM?
requestAnimationFrame(() => {
this._states.forEach((state, fieldKey) => {
const element = this._findFieldElement(fieldKey);
if (!element) return;
// Apply your DOM changes here
});
});
}
// Public API — for use by other classes
isField[Name](identifier) {
return this._states.get(identifier) || false;
}
get[Name]States() {
return Object.fromEntries(this._states);
}
clearStates() {
this._states.clear();
}
// Shared utilities — copy these unchanged
_evaluate(expression, data) {
let cleanExpr = expression.trim();
if (cleanExpr.startsWith('=')) cleanExpr = cleanExpr.substring(1).trim();
const context = this.prepareContext(data);
try {
const result = this._feelEngine.evaluate('expression', cleanExpr, context);
if (result !== null && result !== undefined) return result;
} catch (feelErr) {}
return this._evaluateJavaScript(cleanExpr, context);
}
prepareContext(data) {
const context = {};
Object.keys(data).forEach(key => {
const value = data[key];
if (value === null || value === undefined) context[key] = null;
else if (typeof value === 'boolean') context[key] = value;
else if (typeof value === 'number') context[key] = value;
else if (typeof value === 'string') {
if (value === 'true') { context[key] = true; return; }
if (value === 'false') { context[key] = false; return; }
const num = Number(value);
context[key] = (!isNaN(num) && value.trim() !== '') ? num : value;
} else context[key] = value;
});
context.now = () => new Date();
context.today = () => { const d = new Date(); d.setHours(0,0,0,0); return d; };
return context;
}
_evaluateJavaScript(expression, context) {
try {
const keys = Object.keys(context);
const values = Object.values(context);
const fn = new Function(
...keys,
`'use strict'; try { return (${expression}); } catch(e) { return undefined; }`
);
return fn(...values);
} catch (err) {
return undefined;
}
}
_interpretAsBoolean(value) {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
const n = value.trim().toLowerCase();
if (n === 'true') return true;
if (n === 'false') return false;
return n.length > 0;
}
if (typeof value === 'number') return value !== 0;
return false;
}
_getAllComponents(components = []) {
const all = [];
for (const component of components) {
all.push(component);
if (component.type === 'group' && Array.isArray(component.components)) {
all.push(...this._getAllComponents(component.components));
}
}
return all;
}
_findFieldElement(fieldKey) {
let fieldId = fieldKey;
if (this._form?._state?.schema) {
const allComponents = this._getAllComponents(
this._form._state.schema.components || []
);
const component = allComponents.find(c => c.key === fieldKey);
if (component?.id) fieldId = component.id;
}
const inputById = document.querySelector(
`input[id$="-${fieldId}"], select[id$="-${fieldId}"], textarea[id$="-${fieldId}"]`
);
if (inputById) return inputById.closest('.fjs-form-field');
const inputByName = document.querySelector(
`input[name="${fieldKey}"], select[name="${fieldKey}"], textarea[name="${fieldKey}"]`
);
if (inputByName) return inputByName.closest('.fjs-form-field');
for (const field of document.querySelectorAll('.fjs-form-field')) {
const input = field.querySelector('input, select, textarea, button');
if (input) {
const id = input.getAttribute('id') || '';
if (id.endsWith(`-${fieldId}`) || id === fieldId) return field;
}
}
return null;
}
}
[Name]Evaluator.$inject = ['eventBus', 'form'];
The Tradeoffs
The _getAllComponents method is duplicated across all five evaluators. This is the most obvious refactoring opportunity — it should live in a shared utility or a base class. I left it duplicated because each evaluator was developed independently and the duplication was only obvious in retrospect. Article 13 ("What I Built Five Times Before I Extracted It") covers this refactoring.
DOM manipulation from a non-component class is fragile. Evaluators are DI-managed classes, not React/Preact components. They reach into the DOM with document.querySelector. If Form-JS changes its DOM structure or CSS class names, evaluators break silently. This is a real tradeoff — the alternative is hooking into Form-JS's rendering cycle, which is much more complex.
Schema mutation in RequiredEvaluator is the most risky approach. Mutating component.validate.required in place modifies the schema object that Form-JS considers the source of truth. If Form-JS ever caches schema components, this mutation might not be picked up. The shallow clone { ...schema } triggers change detection in most Form-JS versions but is not a guaranteed public API.
All five evaluators re-evaluate all components on every changed event. For a 10-field form this is fine. For a 100-field form with many FEEL expressions, this adds up. Article 22 ("Scoped Re-evaluation") covers how to optimize evaluators to only re-run when relevant fields change.
What Comes Next
You now have five working evaluators that cover the most common dynamic form behaviors. The next article connects the editor side (where form designers write FEEL expressions in the properties panel) to the runtime side (where evaluators read those expressions from the schema).
Article 9 covers the FeelEntry component in depth — the properties panel input that lets form designers write FEEL expressions, including the feel: 'optional' vs feel: 'required' distinction, why eventBus is a required prop, and how variables enables autocomplete for the form's field keys.
This is Part 8 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 runtime-evaluation dynamic-forms javascript devex
Top comments (0)