Part 21 of the series: "Extending bpmn-io Form-JS Beyond Its Limits"
Every extension in this series — the evaluators, validators, providers, renderers, file handlers — ultimately gets loaded into a form instance. Something has to create that instance, configure which modules it loads, and expose the capabilities that application code needs to call.
That something is CustomForm and CustomFormEditor.
These two classes are thin subclasses of Form-JS's Form and FormEditor. They don't reimagine the base classes — they add exactly what the extension system needs and nothing more. The _isRendered flag so consumers know when it's safe to interact with the form. The _pendingFilesRef store so file handling works across framework boundaries. The form.rendered event so evaluators know when the DOM exists. The module list so every extension is loaded consistently.
This article explains why each addition exists, why subclassing is the right approach over wrapping, and shows the complete implementation of both classes.
Why Subclass, Not Wrap
The instinct is to wrap: create a class that instantiates Form internally and delegates to it:
// ❌ The wrapper approach
class CustomForm {
private _form: Form;
constructor(options: FormOptions) {
this._form = new Form(options);
}
async importSchema(schema: any, data?: any) {
const result = await this._form.importSchema(schema, data);
// Custom logic here
return result;
}
get(name: string) {
return this._form.get(name);
}
}
This breaks immediately when your code needs to call this.get('eventBus') from inside the constructor — before _form is initialized. More importantly, it breaks when application code passes the instance to something that checks instanceof Form. Many Form-JS internal operations check the type of the form object. A wrapper fails these checks.
The critical issue: this.get() and this._setState() are methods on the Form prototype that access the form's internal DI container and state. A wrapper would need to proxy every method and property access. That's not a wrapper — it's a reimplementation.
Subclassing is the correct approach:
// ✅ The subclass approach
export class CustomForm extends Form {
constructor(options: FormOptions = {}) {
super(mergedOptions); // Form's DI container initializes here
// Now this.get() works — the container exists
const eventBus = this.get('eventBus');
}
}
After super(), the DI container is initialized. this.get('eventBus'), this.get('formFields'), and any other service are available immediately. instanceof Form checks pass. All Form-JS internals work correctly.
The _isRendered Flag
export class CustomForm extends Form {
private _isRendered: boolean = false;
Form-JS's importSchema is async — it loads the schema, initializes field state, and renders the form. But there's no built-in signal for "rendering is complete and the DOM is ready."
Without _isRendered, application code does this:
// ❌ Race condition — form might not be rendered yet
await form.importSchema(schema, data);
form.on('changed', handler); // Might attach before DOM exists
const input = document.querySelector('#fjs-form-abc123-key1'); // Might be null
With _isRendered:
// ✅ Explicit ready check
await form.importSchema(schema, data);
// After importSchema returns, _isRendered is true
// DOM exists, events are safe to attach, elements can be queried
The flag serves two audiences: application code that needs to know when the form is ready, and evaluators that need to apply DOM changes after rendering.
The importSchema Override
async importSchema(schema: any, data?: any): Promise<any> {
try {
// ✅ Call super first — Form-JS handles schema loading and rendering
const result = await super.importSchema(schema, data);
// ✅ Set rendered flag after super completes
this._isRendered = true;
try {
// ✅ Fire custom event — evaluators listen for this
const eventBus = this.get('eventBus');
if (eventBus) {
eventBus.fire('form.rendered', { form: this });
}
} catch (eventError) {
// ✅ Swallow event bus errors — don't fail importSchema over this
console.warn(
'⚠️ [CustomForm] Could not fire form.rendered event:',
eventError
);
}
return result;
} catch (error) {
// ✅ If importSchema fails, form is not rendered
this._isRendered = false;
throw error; // Re-throw — caller needs to handle this
}
}
Why try/catch around the event fire?
super.importSchema can fail — malformed schema, DI container initialization error, network failure if schema is remote. When it fails, it throws. The catch block catches that throw, sets _isRendered = false, and re-throws it.
The inner try/catch around eventBus.fire is separate. The event bus should always be available after a successful super.importSchema, but defensive programming costs nothing here. If this.get('eventBus') throws for any reason, the form is still rendered — we just couldn't fire the event. Swallowing that error is correct. Re-throwing it would cause importSchema to appear to fail when the form actually rendered successfully.
Why fire form.rendered and not use form.init?
form.init fires during DI container initialization — before the schema is loaded, before fields are rendered, before the DOM has any form elements. Evaluators that listen to form.init for initialization are correct to do so (they set up their state). But evaluators that need to touch DOM elements cannot do so at form.init — the elements don't exist yet.
form.rendered fires after importSchema completes — after the form is in the DOM, after field elements exist, after everything is ready. DisabledEvaluator._applyToDOM(), for example, listens to form.rendered to apply disabled states to DOM elements that now exist.
The destroy Override
destroy() {
// ✅ Reset rendered state — form is being destroyed
this._isRendered = false;
// ✅ Clear pending files — no upload after destroy
try {
const eventBus = this.get('eventBus');
if (eventBus?._pendingFilesRef) {
eventBus._pendingFilesRef.current.clear();
}
} catch (e) {
// Ignore — eventBus may already be torn down
}
// ✅ Delegate to Form's destroy
super.destroy();
}
Without the destroy override, _isRendered stays true even after the form is destroyed. If application code holds a reference to the CustomForm instance and checks form.isRendered after destroying, it gets true — incorrect.
More importantly: when a form is replaced on the page (user navigates to a different task, form is re-initialized with new data), the old form's _pendingFilesRef might still have file references. Clearing it on destroy prevents old files from being uploaded when the new form submits.
The _pendingFilesRef Initialization
constructor(options: FormOptions = {}) {
const mergedOptions: FormOptions = {
...options,
additionalModules: [
...sharedModules,
...formOnlyModules,
...(options.additionalModules || [])
],
properties: {
...DEFAULT_PROPERTIES,
...(options.properties || {})
}
};
super(mergedOptions);
// ✅ Initialize _pendingFilesRef AFTER super() — DI container now exists
try {
const eventBus = this.get('eventBus');
if (eventBus && !eventBus._pendingFilesRef) {
eventBus._pendingFilesRef = { current: new Map() };
}
} catch (e) {
console.warn('⚠️ [CustomForm] Could not initialize _pendingFilesRef:', e);
}
}
Why in the constructor, not in importSchema?
Module initialization happens during super(). When the DI container initializes, it runs __init__ for every registered module. Some of those modules — specifically the file upload field renderers — might try to access eventBus._pendingFilesRef during their initialization.
If _pendingFilesRef is created in importSchema (which runs after module initialization), it would be undefined when the renderers first look for it. The renderers handle this with null checks, but it's cleaner to guarantee _pendingFilesRef exists before any module runs.
Creating it in the constructor, immediately after super(), ensures it exists for the entire lifetime of the form instance.
The !eventBus._pendingFilesRef guard:
This guard prevents double-initialization. If CustomForm is extended again by a consumer who calls super(), or if the constructor somehow runs twice, the guard ensures only one Map is created.
The uploadFileAsAttachment Method
async uploadFileAsAttachment(
taskId: string,
file: File,
description = ''
): Promise<any> {
const formData = new FormData();
formData.append('attachmentName', file.name);
formData.append('attachmentType', file.type || 'application/octet-stream');
formData.append(
'attachmentDescription',
description || `File uploaded via form field`
);
formData.append('content', file);
const response = await fetch(`/engine-rest/task/${taskId}/attachment`, {
method: 'POST',
body: formData
// ✅ No Content-Type header — fetch sets it automatically
// for FormData with the correct multipart boundary
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`Failed to upload attachment "${file.name}": ${response.status} ${text}`
);
}
return response.json();
}
async uploadPendingFiles(taskId: string): Promise<any[]> {
const eventBus = this.get('eventBus');
if (!eventBus?._pendingFilesRef) {
console.warn('[CustomForm] _pendingFilesRef not initialized');
return [];
}
const pendingFilesMap = eventBus._pendingFilesRef.current;
if (pendingFilesMap.size === 0) return [];
const results: any[] = [];
const errors: string[] = [];
for (const [fieldKey, files] of pendingFilesMap.entries()) {
for (const file of files) {
try {
const result = await this.uploadFileAsAttachment(
taskId,
file,
`Uploaded from form field: ${fieldKey}`
);
results.push(result);
} catch (err) {
console.error(`[CustomForm] Failed to upload ${file.name}:`, err);
errors.push(`${file.name}: ${(err as Error).message}`);
}
}
}
// ✅ Clear after upload — prevent double-upload on re-submit
pendingFilesMap.clear();
if (errors.length > 0) {
throw new Error(
`${errors.length} file(s) failed to upload:\n${errors.join('\n')}`
);
}
return results;
}
Why on CustomForm rather than a separate service?
Two reasons. First, uploadFileAsAttachment could be on a DI service — but DI services aren't accessible from application code without calling form.get('uploadService'). Putting it on CustomForm makes it a first-class method of the form's public API: form.uploadPendingFiles(taskId). Clean, discoverable, easy to call.
Second, uploadPendingFiles needs access to _pendingFilesRef, which is attached to the event bus, which is accessible via this.get('eventBus'). This method needs to be on a class that has this.get() — which means it needs to be on Form or a subclass.
A standalone utility function couldn't call this.get('eventBus'). It would need the event bus passed in as a parameter — uploadPendingFiles(form, taskId). Putting it on the subclass is cleaner.
The Module Lists
The key design decision: CustomForm and CustomFormEditor share some modules but not all.
// ✅ Modules loaded by BOTH CustomForm and CustomFormEditor
const sharedModules = [
// Field renderers — used in both runtime form and editor preview
DateTimeFieldRenderExtension, // Replace datetime with rsuite DatePicker
DropdownFieldRenderExtension, // Custom dropdown with search/API options
GridFieldRenderExtension, // Custom grid field
// Runtime evaluators — run in both contexts
// (editor preview needs evaluators for the preview canvas)
BindingEvaluatorModule,
HideEvaluatorModule,
DisabledEvaluatorModule,
RequiredEvaluatorModule,
PersistentEvaluatorModule,
ShowLatestValueEvaluatorModule,
DateTimeValidationEvaluatorModule,
TicketAutoFillEvaluatorModule,
// Runtime validators
FeelValidatorModule,
RequiredValidatorModule,
GridFieldValidationModule,
// File handling
FileUploadFieldModule,
];
// ✅ Modules loaded ONLY by CustomForm (runtime, not editor)
const formOnlyModules: any[] = [
// No form-only modules currently
// This is where you'd put modules that should never run in the editor
// e.g., analytics, submission handlers, external integrations
];
// ✅ Modules loaded ONLY by CustomFormEditor (editor, not runtime form)
const editorOnlyModules = [
// Properties panel providers — only needed in the editor
DisabledPropertiesModule,
ReadonlyPropertiesModule,
RequiredPropertiesModule,
PersistentPropertiesModule,
ShowLatestValuePropertiesModule,
HideIfPropertiesModule,
FeelExpressionPropertiesModule,
BindingPropertiesModule,
TicketAutoFillPropertiesModule,
// Field-specific property panels
DropdownPropertiesModule,
GridFieldPropertiesModule,
DateTimePropertiesModule,
FileUploadPropertiesModule,
];
Why evaluators in sharedModules?
The editor has a preview canvas — a live rendering of the form as the designer configures it. That preview needs evaluators to work correctly. If a field has a hideExpression configured, the preview should hide it when conditions are met. If a field has a binding expression, the preview should show computed values.
Evaluators in sharedModules ensures the editor preview behaves identically to the runtime form.
Why properties panel providers NOT in sharedModules?
Properties panel providers register panel entries for the editor's sidebar. They have no effect in the runtime form — there's no properties panel in the runtime. But more importantly, they add overhead. Registering 12 providers with the DI container when none of them will be used is unnecessary. Keeping them in editorOnlyModules ensures they're only loaded when actually needed.
The Complete CustomForm
// CustomForm.ts
import { Form } from '@bpmn-io/form-js';
import type { FormOptions } from '@bpmn-io/form-js';
// Shared modules
import { DateTimeFieldRenderExtension } from './fields/datetime/DateTimeFieldModule';
import { DropdownFieldRenderExtension } from './fields/dropdown/DropdownFieldModule';
import { GridFieldRenderExtension } from './fields/grid/GridFieldModule';
import { FileUploadFieldModule } from './fields/fileupload/FileUploadFieldModule';
import { BindingEvaluatorModule } from './evaluators/BindingEvaluatorModule';
import { HideEvaluatorModule } from './evaluators/HideEvaluatorModule';
import { DisabledEvaluatorModule } from './evaluators/DisabledEvaluatorModule';
import { RequiredEvaluatorModule } from './evaluators/RequiredEvaluatorModule';
import { PersistentEvaluatorModule } from './evaluators/PersistentEvaluatorModule';
import { ShowLatestValueEvaluatorModule } from './evaluators/ShowLatestValueEvaluatorModule';
import { DateTimeValidationEvaluatorModule } from './evaluators/DateTimeValidationEvaluatorModule';
import { TicketAutoFillEvaluatorModule } from './evaluators/TicketAutoFillEvaluatorModule';
import { FeelValidatorModule } from './validators/FeelValidatorModule';
import { RequiredValidatorModule } from './validators/RequiredValidatorModule';
import { GridFieldValidationModule } from './fields/grid/GridFieldValidationModule';
const sharedModules = [
DateTimeFieldRenderExtension,
DropdownFieldRenderExtension,
GridFieldRenderExtension,
FileUploadFieldModule,
BindingEvaluatorModule,
HideEvaluatorModule,
DisabledEvaluatorModule,
RequiredEvaluatorModule,
PersistentEvaluatorModule,
ShowLatestValueEvaluatorModule,
DateTimeValidationEvaluatorModule,
TicketAutoFillEvaluatorModule,
FeelValidatorModule,
RequiredValidatorModule,
GridFieldValidationModule,
];
const DEFAULT_PROPERTIES = {
readOnly: false,
};
export class CustomForm extends Form {
private _isRendered: boolean = false;
constructor(options: FormOptions = {}) {
const mergedOptions: FormOptions = {
...options,
additionalModules: [
...sharedModules,
...(options.additionalModules || [])
],
properties: {
...DEFAULT_PROPERTIES,
...(options.properties || {})
}
};
super(mergedOptions);
// ✅ Initialize cross-boundary file store
try {
const eventBus = this.get('eventBus');
if (eventBus && !eventBus._pendingFilesRef) {
eventBus._pendingFilesRef = { current: new Map() };
}
} catch (e) {
console.warn('⚠️ [CustomForm] Could not initialize _pendingFilesRef:', e);
}
}
async importSchema(schema: any, data?: any): Promise<any> {
try {
const result = await super.importSchema(schema, data);
this._isRendered = true;
try {
const eventBus = this.get('eventBus');
if (eventBus) {
eventBus.fire('form.rendered', { form: this });
}
} catch (eventError) {
console.warn('⚠️ [CustomForm] Could not fire form.rendered:', eventError);
}
return result;
} catch (error) {
this._isRendered = false;
throw error;
}
}
destroy() {
this._isRendered = false;
try {
const eventBus = this.get('eventBus');
if (eventBus?._pendingFilesRef) {
eventBus._pendingFilesRef.current.clear();
}
} catch (e) {
// Ignore — may already be torn down
}
super.destroy();
}
get isRendered(): boolean {
return this._isRendered;
}
async uploadFileAsAttachment(
taskId: string,
file: File,
description = ''
): Promise<any> {
const formData = new FormData();
formData.append('attachmentName', file.name);
formData.append('attachmentType', file.type || 'application/octet-stream');
formData.append('attachmentDescription', description || 'File uploaded via form');
formData.append('content', file);
const response = await fetch(`/engine-rest/task/${taskId}/attachment`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Upload failed for "${file.name}": ${response.status} ${text}`);
}
return response.json();
}
async uploadPendingFiles(taskId: string): Promise<any[]> {
const eventBus = this.get('eventBus');
if (!eventBus?._pendingFilesRef) {
console.warn('[CustomForm] _pendingFilesRef not initialized');
return [];
}
const pendingFilesMap = eventBus._pendingFilesRef.current;
if (pendingFilesMap.size === 0) return [];
const results: any[] = [];
const errors: string[] = [];
for (const [fieldKey, files] of pendingFilesMap.entries()) {
for (const file of files) {
try {
const result = await this.uploadFileAsAttachment(
taskId,
file,
`Field: ${fieldKey}`
);
results.push(result);
} catch (err) {
errors.push(`${file.name}: ${(err as Error).message}`);
}
}
}
pendingFilesMap.clear();
if (errors.length > 0) {
throw new Error(`${errors.length} upload(s) failed:\n${errors.join('\n')}`);
}
return results;
}
registerFileUploadHandler(taskId: string) {
const inputs = (this as any)._container?.querySelectorAll<HTMLInputElement>(
'input[type="file"][data-attach="true"]'
);
if (!inputs?.length) return;
inputs.forEach((input: HTMLInputElement) => {
input.addEventListener('change', async (event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
await this.uploadFileAsAttachment(taskId, file, `Field: ${input.name}`);
alert(`✅ "${file.name}" uploaded successfully`);
} catch (err) {
alert(`❌ Upload failed: ${(err as Error).message}`);
}
});
});
}
}
The Complete CustomFormEditor
// CustomFormEditor.ts
import { FormEditor } from '@bpmn-io/form-js';
import type { FormEditorOptions } from '@bpmn-io/form-js';
// Shared modules (same as CustomForm)
import { DateTimeFieldRenderExtension } from './fields/datetime/DateTimeFieldModule';
import { DropdownFieldRenderExtension } from './fields/dropdown/DropdownFieldModule';
import { GridFieldRenderExtension } from './fields/grid/GridFieldModule';
import { FileUploadFieldModule } from './fields/fileupload/FileUploadFieldModule';
import { BindingEvaluatorModule } from './evaluators/BindingEvaluatorModule';
import { HideEvaluatorModule } from './evaluators/HideEvaluatorModule';
import { DisabledEvaluatorModule } from './evaluators/DisabledEvaluatorModule';
import { RequiredEvaluatorModule } from './evaluators/RequiredEvaluatorModule';
import { PersistentEvaluatorModule } from './evaluators/PersistentEvaluatorModule';
import { ShowLatestValueEvaluatorModule } from './evaluators/ShowLatestValueEvaluatorModule';
import { DateTimeValidationEvaluatorModule } from './evaluators/DateTimeValidationEvaluatorModule';
import { TicketAutoFillEvaluatorModule } from './evaluators/TicketAutoFillEvaluatorModule';
import { FeelValidatorModule } from './validators/FeelValidatorModule';
import { RequiredValidatorModule } from './validators/RequiredValidatorModule';
import { GridFieldValidationModule } from './fields/grid/GridFieldValidationModule';
// Editor-only modules
import { DisabledPropertiesModule } from './providers/DisabledPropertiesModule';
import { ReadonlyPropertiesModule } from './providers/ReadonlyPropertiesModule';
import { RequiredPropertiesModule } from './providers/RequiredPropertiesModule';
import { PersistentPropertiesModule } from './providers/PersistentPropertiesModule';
import { ShowLatestValuePropertiesModule } from './providers/ShowLatestValuePropertiesModule';
import { HideIfPropertiesModule } from './providers/HideIfPropertiesModule';
import { FeelExpressionPropertiesModule } from './providers/FeelExpressionPropertiesModule';
import { TicketAutoFillPropertiesModule } from './providers/TicketAutoFillPropertiesModule';
import { DropdownPropertiesModule } from './providers/DropdownPropertiesModule';
import { GridFieldPropertiesModule } from './providers/GridFieldPropertiesModule';
import { DateTimePropertiesModule } from './providers/DateTimePropertiesModule';
import { FileUploadPropertiesModule } from './providers/FileUploadPropertiesModule';
const sharedModules = [
DateTimeFieldRenderExtension,
DropdownFieldRenderExtension,
GridFieldRenderExtension,
FileUploadFieldModule,
BindingEvaluatorModule,
HideEvaluatorModule,
DisabledEvaluatorModule,
RequiredEvaluatorModule,
PersistentEvaluatorModule,
ShowLatestValueEvaluatorModule,
DateTimeValidationEvaluatorModule,
TicketAutoFillEvaluatorModule,
FeelValidatorModule,
RequiredValidatorModule,
GridFieldValidationModule,
];
const editorOnlyModules = [
DisabledPropertiesModule,
ReadonlyPropertiesModule,
RequiredPropertiesModule,
PersistentPropertiesModule,
ShowLatestValuePropertiesModule,
HideIfPropertiesModule,
FeelExpressionPropertiesModule,
TicketAutoFillPropertiesModule,
DropdownPropertiesModule,
GridFieldPropertiesModule,
DateTimePropertiesModule,
FileUploadPropertiesModule,
];
const DEFAULT_EDITOR_PROPERTIES = {
readOnly: false,
};
export class CustomFormEditor extends FormEditor {
private _isRendered: boolean = false;
constructor(options: FormEditorOptions = {}) {
const mergedOptions: FormEditorOptions = {
...options,
additionalModules: [
...sharedModules,
...editorOnlyModules,
...(options.additionalModules || [])
],
properties: {
...DEFAULT_EDITOR_PROPERTIES,
...(options.properties || {})
}
};
super(mergedOptions);
// ✅ Initialize _pendingFilesRef for editor preview file uploads
// (Editor preview can render file upload fields that need the store)
try {
const eventBus = this.get('eventBus');
if (eventBus && !eventBus._pendingFilesRef) {
eventBus._pendingFilesRef = { current: new Map() };
}
} catch (e) {
console.warn('⚠️ [CustomFormEditor] Could not initialize _pendingFilesRef:', e);
}
}
async importSchema(schema: any, data?: any): Promise<any> {
try {
const result = await super.importSchema(schema, data);
this._isRendered = true;
try {
const eventBus = this.get('eventBus');
if (eventBus) {
eventBus.fire('form.rendered', { form: this });
}
} catch (eventError) {
console.warn(
'⚠️ [CustomFormEditor] Could not fire form.rendered:',
eventError
);
}
return result;
} catch (error) {
this._isRendered = false;
throw error;
}
}
destroy() {
this._isRendered = false;
try {
const eventBus = this.get('eventBus');
if (eventBus?._pendingFilesRef) {
eventBus._pendingFilesRef.current.clear();
}
} catch (e) {
// Ignore
}
super.destroy();
}
get isRendered(): boolean {
return this._isRendered;
}
async saveSchema(): Promise<any> {
return (this as any).saveSchema?.() || (this as any).getSchema?.();
}
}
CustomFormEditor is structurally identical to CustomForm — same lifecycle methods, same _pendingFilesRef setup, same form.rendered event — but loads the editor-only modules in addition to the shared ones. The saveSchema convenience method wraps Form-JS's schema export API, normalizing the method name regardless of which version of the API is available.
How Application Code Uses These Classes
// In the form player page:
import { CustomForm } from './CustomForm';
const form = new CustomForm({
container: document.getElementById('form-container')!
});
await form.importSchema(formSchema, initialData);
// form.isRendered is now true
// form.rendered event has fired
// All evaluators have initialized
// DOM is ready
// Submit handler:
async function handleSubmit(taskId: string) {
const errors = form.validate();
if (Object.keys(errors).length > 0) return;
const formData = form.getValues();
await camundaClient.completeTask(taskId, { variables: formData });
await form.uploadPendingFiles(taskId);
form.destroy();
}
// In the form editor page:
import { CustomFormEditor } from './CustomFormEditor';
const editor = new CustomFormEditor({
container: document.getElementById('editor-container')!
});
await editor.importSchema(existingSchema || emptySchema);
// Save button handler:
async function handleSave() {
const schema = await editor.saveSchema();
await deploymentClient.post('/forms', { schema });
}
The public API is identical between the two — importSchema, destroy, isRendered. Application code doesn't need to know about the internal differences in module loading.
The Minimal Subclass Template
If you're building your own extension system and need to adapt this pattern:
// MinimalCustomForm.ts
import { Form } from '@bpmn-io/form-js';
import type { FormOptions } from '@bpmn-io/form-js';
// Import your modules
import { MyEvaluatorModule } from './MyEvaluatorModule';
import { MyRendererModule } from './MyRendererModule';
const customModules = [
MyEvaluatorModule,
MyRendererModule,
// Add more modules here
];
export class MinimalCustomForm extends Form {
private _isRendered: boolean = false;
constructor(options: FormOptions = {}) {
super({
...options,
additionalModules: [
...customModules,
...(options.additionalModules || [])
]
});
// Initialize any cross-boundary stores here
try {
const eventBus = this.get('eventBus');
// Your initialization code
} catch (e) {
console.warn('Initialization warning:', e);
}
}
async importSchema(schema: any, data?: any): Promise<any> {
try {
const result = await super.importSchema(schema, data);
this._isRendered = true;
// Fire your custom lifecycle events here
try {
this.get('eventBus')?.fire('form.rendered', { form: this });
} catch (e) {
console.warn('Could not fire form.rendered:', e);
}
return result;
} catch (error) {
this._isRendered = false;
throw error;
}
}
destroy() {
this._isRendered = false;
super.destroy();
}
get isRendered(): boolean {
return this._isRendered;
}
// Add your public API methods here
}
The template has five elements: the module list, the constructor with DI initialization, the importSchema override, the destroy override, and the isRendered getter. Everything else is specific to your extension system.
The Tradeoffs
The module list is a coupling point. Every module in sharedModules is a dependency of CustomForm. If a module fails to load (import error, missing dependency), CustomForm fails to construct. If you're building this for a product team rather than personal use, consider making modules configurable — letting the consumer pass their own module list rather than hard-coding it in the class.
importSchema override must always call super.importSchema. This is obvious but worth stating: if you override importSchema and forget to call super, Form-JS's schema loading doesn't run. You get an empty form container with no fields. The try/catch pattern shown ensures the super call always happens — you can't accidentally skip it by returning early.
this.get() in the constructor depends on super() completing successfully. If super() throws (which it shouldn't, but could if a module's constructor throws), this.get() will also throw. The try/catch around the _pendingFilesRef initialization handles this defensively. For your own DI initialization code in the constructor, always wrap in try/catch.
saveSchema on CustomFormEditor is not part of FormEditor's stable public API. The method name and return value have varied across Form-JS versions. The implementation shown attempts two method names (saveSchema and getSchema) to handle different versions. When upgrading Form-JS, verify this method still works correctly.
What Comes Next
CustomForm and CustomFormEditor are the entry points — the classes consumers instantiate to get a fully functional form with all extensions loaded. The final article brings the full architecture together: a system diagram, a complete lifecycle walkthrough, and the three things worth doing differently from the start.
Article 22 covers scoped re-evaluation — how DateTimeValidationEvaluator avoids running on every changed event by checking whether the changed field is actually a datetime field, and how to generalize this optimization to any evaluator.
This is Part 21 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 architecture typescript subclassing javascript devex
Top comments (0)