tAutoFillEvaluator` β async field population from external APIs with caching
12 Properties Panel Providers β classes that extend the form editor's configuration UI:
DisabledPropertiesProvider, ReadonlyPropertiesProvider, RequiredPropertiesProvider β override built-in properties with FEEL-capable versions
FeelExpressionPropertiesProvider β adds FEEL binding entry to Form Logics
HideIfPropertiesProvider β adds hide-if condition entry to Form Logics
PersistentPropertiesProvider β adds persistent flag entry to Form Logics
ShowLatestValuePropertiesProvider β adds show-latest-value entry to Form Logics
TicketAutoFillPropertiesProvider β adds cascading API-backed configuration UI
DropdownPropertiesProvider β replaces simple dropdown with full configuration panel
GridFieldPropertiesProvider β adds grid-specific validation rule configuration
DateTimePropertiesPanelExtension β configures datetime replacement field
FileUploadPropertiesProvider β adds file validation configuration
3 Custom Validators β classes that extend form.validate():
FeelValidator β FEEL expression-based field validation at submit time
GridFieldValidationValidator β validates grid cell contents on submit
RequiredValidator β enforces dynamic required state at submit time
5 Custom Field Renderers β registered for custom or replaced field types:
DropdownFieldRenderer β bridges five React dropdown components into Preact
GridFieldRenderer β full custom grid with Excel import, validation, and formulas
DateTimeFieldRenderer β replaces built-in datetime with rsuite DatePicker
FileUploadFieldRenderer β three-stage file handling with Camunda attachment upload
ImageViewFieldRenderer β custom image display field
Infrastructure:
CustomForm β subclasses Form, bootstraps all modules, fires form.rendered, initializes _pendingFilesRef
CustomFormEditor β subclasses FormEditor, bootstraps all editor modules
SearchableSelect β Preact searchable dropdown used throughout the properties panel
PANEL_GROUPS and FORM_EVENTS constants β shared registries for group IDs and event names
excelUtils β shared import/export utility for label/value pairs
Every form session follows this sequence:
Step 1: Instantiation
const form = new CustomForm({
container: document.getElementById('form-container'),
// additionalModules declared inside CustomForm constructor
});
CustomForm calls super() with a merged options object that includes all modules. The DI container is created. All services declared in __init__ are instantiated immediately. Evaluators and validators run their constructors, subscribing to events. At the end of the constructor, CustomForm attaches _pendingFilesRef to the event bus instance.
What's happening invisibly: The DI container resolves all $inject arrays. RequiredEvaluator.$inject = ['eventBus', 'form'] means the DI container calls new RequiredEvaluator(eventBus, form). If $inject is missing or has a typo, the parameter is undefined β the most common error in Form-JS extension development.
Step 2: Schema Import
await form.importSchema(schema, initialData);
Form-JS reads the schema JSON and creates the Preact component tree. For each field, it looks up the registered renderer in formFields β your replacements win because last-registration-wins. The field renderers render their Preact output. For dropdown fields, the Preact renderer renders a <div id="..."> mount point and calls createRoot() to mount the React component inside it. The reactRoots Map tracks the root for lifecycle management. After super.importSchema() completes, CustomForm.importSchema fires form.rendered. This signals that the DOM is ready.
Step 3: Evaluators Initialize
Each evaluator's form.init handler fires. The evaluators:
- Set
this._initialized = true
- Call
evaluateAll() after a 50ms delay
TicketAutoFillEvaluator calls _buildWatchMap() to scan the schema
DateTimeValidationEvaluator calls _buildDatetimeFieldRegistry() to build its Set
The initial evaluateAll() run applies the current state of all expressions against the pre-populated data. Fields with disabled: "= status = 'closed'" get the disabled attribute applied. Fields with conditional.hide: "= role != 'admin'" get display: none applied.
Step 4: External Context (Optional)
If the form needs ticket data for auto-fill, the application fires it into the form after import:
const eventBus = form.get('eventBus');
eventBus.fire('ticket.context.set', {
ticket_id: currentTicket.id,
ticket_data: currentTicket
});
TicketAutoFillEvaluator receives this via its ticket.context.set listener.
Step 5: User Interaction
The user types in a field. Form-JS updates form state. changed fires with the changed field's data. Each evaluator's changed handler fires:
Unscoped evaluators: DisabledEvaluator, HideEvaluator, RequiredEvaluator, PersistentEvaluator, BindingEvaluator β all check _evaluating and proceed if clear. After a 10ms debounce, evaluateAll() runs. Each checks all components, finds ones with FEEL expressions, evaluates against current form data, detects changes, updates DOM.
Scoped evaluators: DateTimeValidationEvaluator checks whether the changed field is in _datetimeFieldKeys. If not, it returns immediately. If yes, it runs evaluation only for datetime fields with changed values.
TicketAutoFillEvaluator: checks whether the changed field is in _watchedFields. If not, returns immediately. If yes, and the ticket selection changed, resets dependent fields, fetches from API (with cache), evaluates conditions, formats values, writes results.
Step 6: Properties Panel Changes (Editor Only)
In the form editor, when the form designer changes a property:
editField(field, path, value) is called β updates the schema
propertiesPanel.updated fires
- All providers'
getGroups functions run with the updated field
getOrCreateFormLogicsGroup() is called by each provider β creates or finds the Form Logics group
- Override providers filter out replaced entries
- All providers add their entries to the appropriate groups
- The panel re-renders with the updated configuration
If the change was to a FEEL expression (say, changing disabledExpression), the dual-path setValue stores it in the schema. On the next changed event, DisabledEvaluator reads disabledExpression, evaluates it, and applies the result.
Step 7: Validation on Submit
The user clicks Submit. Form-JS calls form.validate(). Because three validators have wrapped form.validate() with the merge-errors pattern:
form.validate() called
β RequiredValidator's wrapper runs
β original validate() called
β FeelValidator's wrapper runs
β original validate() called
β GridFieldValidationValidator's wrapper runs
β actual Form-JS validate() runs
β returns built-in errors
β GridFieldValidationValidator merges grid errors
β FeelValidator merges FEEL errors
β merge
β RequiredValidator merges dynamic required errors
β return merged errors
The merged errors object is written to form state via _setState({ errors }). Fields with errors get red borders and error messages below them. If errors exist, submission stops. If no errors, the application proceeds.
Step 8: File Upload and Submission
After successful validation, the application code:
// 1. Complete the Camunda task
await camundaClient.completeTask(taskId, { variables: formData });
// 2. Upload files from _pendingFilesRef
await form.uploadPendingFiles(taskId);
uploadPendingFiles reads eventBus._pendingFilesRef.current (the Map populated by FileUpload React components via useEffect), and posts each file to /engine-rest/task/${taskId}/attachment as multipart FormData. Files are uploaded after task completion β not before. If task completion fails, no orphaned attachments are created. After upload, pendingFilesRef.current.clear() prevents double-upload on re-submit.
Pitfall Guide
- Missing
$inject Arrays: Form-JS's DI container silently resolves missing dependencies as undefined. Always declare $inject explicitly and validate constructor parameters during development.
- Unscoped Evaluator Performance Degradation: Running
evaluateAll() on every changed event without field filtering causes O(n) DOM thrashing. Implement scoped registries (_datetimeFieldKeys, _watchedFields) to limit evaluation to relevant components.
- Validation Merge Order Conflicts: Wrapping
form.validate() without preserving the original error object leads to overwritten validation states. Use a strict merge-errors pattern that concatenates arrays and avoids object key collisions.
- React/Preact Root Lifecycle Mismanagement: Mounting React components inside Preact renderers without tracking
reactRoots causes memory leaks and stale DOM nodes. Maintain a Map of roots and call root.unmount() on component teardown.
- Premature File Upload Timing: Uploading attachments before task completion creates orphaned files in the engine when the transaction rolls back. Always complete the task first, then upload from
_pendingFilesRef, and clear the reference afterward.
- Properties Panel Entry Overwrite Conflicts: Multiple providers registering identical entry keys will silently override each other. Implement a filter-and-replace middleware pattern that explicitly removes native entries before injecting FEEL-capable alternatives.
Deliverables
- π Architecture Blueprint: Complete component dependency graph, DI resolution flow, and lifecycle state machine diagram. Includes registry mappings for evaluators, providers, validators, and renderers.
- β
Extension Readiness Checklist: 24-point validation protocol covering
$inject declarations, scoped evaluator registration, validation wrapper ordering, React/Preact root cleanup, and file upload sequencing.
- βοΈ Configuration Templates: Production-ready boilerplate for
CustomForm/CustomFormEditor bootstrapping, evaluator base classes with debounce/scoping hooks, merge-validation wrappers, and properties panel provider contracts. Ready for direct integration into Form-JS v1.x/v2.x codebases.