Part 18 of the series: "Extending bpmn-io Form-JS Beyond Its Limits"
Form-JS handles text, numbers, dates, selections, and booleans. All of these fit neatly into JSON — the format form data is stored and transmitted in. Files don't fit into JSON. A File object is a browser-specific binary object that cannot be serialized to JSON, stored in form state, or sent as part of a form submission payload.
Form-JS's built-in file picker stores file metadata (name, size, type) in form data but doesn't solve the core problem: getting the actual file bytes to a server when the form submits. That's your problem to solve.
I needed to solve it for a Camunda-integrated form where files selected by users needed to be uploaded as task attachments — associated with the active Camunda task through the REST API. The solution required three separate stages that work together across framework boundaries, DI container boundaries, and the browser's JavaScript sandbox.
This article documents all three stages, why each design decision was made, and how they connect into a complete system.
The Problem
When a user selects a file in a browser input, the browser gives you a File object. This object:
- Lives in memory as long as the JavaScript reference to it exists
- Cannot be serialized to JSON (
JSON.stringify(file)returns"{}") - Cannot be stored in Form-JS's form state (which is a JSON-serializable object)
- Cannot be passed through React or Preact Context across framework boundaries
- Cannot survive a page navigation or form reset
Form-JS's form state looks like this:
{
assignee: "john.doe",
dueDate: "2024-01-15",
priority: "high",
attachment: null // ← What do you put here?
}
You can put the file's name in form state. You can put the file's size. But the actual bytes — the thing you need to upload — cannot go here. They have to live somewhere else while the form is being filled, then be retrieved and uploaded when the user submits.
The three questions this system must answer:
- Where do
Fileobjects live while the user is filling the form? - How does the upload code find those
Fileobjects at submit time? - How do files get from the browser to the Camunda server?
What I Tried First
Attempt 1: Store files in module-level variables
// ❌ Module-level storage
const pendingFiles = new Map();
export function storePendingFile(fieldKey, file) {
pendingFiles.set(fieldKey, file);
}
export function getPendingFiles() {
return pendingFiles;
}
This works for one form. It breaks immediately when two forms exist on the same page — they share the same module-level Map. Form A's files are visible to Form B's upload code. If Form A is destroyed and Form B is submitted, Form A's files are uploaded as Form B's attachments.
Attempt 2: Store files in React Context
// ❌ React Context
const FileContext = React.createContext(new Map());
// In the file upload component:
const files = useContext(FileContext);
files.set(fieldKey, selectedFile);
This works within a React component tree. It breaks when the CustomForm class — a plain TypeScript class, not a React component — needs to read the files at submit time. CustomForm has no access to React Context. The context doesn't cross the React/JavaScript boundary.
Attempt 3: Store files in form state as base64
// ❌ Base64 in form state
const base64 = await file.arrayBuffer()
.then(buffer => btoa(String.fromCharCode(...new Uint8Array(buffer))));
onChange({ value: base64 });
This technically works but is destructive at scale. A 5MB file becomes a 6.7MB base64 string. Form-JS re-renders on every changed event. Storing a multi-megabyte string in form state makes the form unusably slow. For images and documents that users routinely attach to tasks, this approach fails completely.
The correct approach: The File objects live in local component state. A cross-boundary store attached to the event bus instance bridges them to the upload code. The form data carries only serializable metadata.
The Three-Stage System
Stage 1: SELECTION
User selects file
→ File stored in component localFiles state
→ File name stored in form data (serializable)
→ File object registered in _pendingFilesRef Map
Stage 2: TRACKING
_pendingFilesRef is attached to eventBus instance
Accessible from: React components, Preact renderers, CustomForm class
Key: fieldKey, Value: File[]
Stage 3: UPLOAD (on submit)
CustomForm reads _pendingFilesRef
For each field with pending files:
POST to /engine-rest/task/{taskId}/attachment
multipart/form-data: name, type, description, content
Clear _pendingFilesRef after upload
Stage 1: Local State in the Component
The file upload component is a React component (following the bridge pattern from Article 9). It maintains its own local state for selected files:
// FileUpload.tsx
interface LocalFile {
file: File;
id: number; // Unique ID for key prop
}
const FileUpload: React.FC<FileUploadProps> = ({
field,
inputId,
readonly,
disabled,
value = [],
onChange,
onChangeValue,
eventBus
}) => {
// ✅ Stage 1: Files live in local component state
// File objects are not serializable — they cannot go in form state
const [localFiles, setLocalFiles] = useState<LocalFile[]>([]);
const [toasts, setToasts] = useState<Toast[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const fieldKey = field.key || 'unknown_field';
When the user selects files, they go into localFiles:
const handleInput = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (files.length === 0) return;
const validFiles: LocalFile[] = [];
for (const file of files) {
if (validateFile(file)) {
validFiles.push({
file,
id: Math.floor(Math.random() * 1000000)
});
}
}
if (validFiles.length > 0) {
// ✅ Files go into local state (not form state)
setLocalFiles(prev => [...prev, ...validFiles]);
// ✅ File NAMES go into form state (serializable)
const allFiles = [...localFiles, ...validFiles];
const fileNames = allFiles.map(lf => lf.file.name);
onChange?.(fileNames);
onChangeValue?.(fileNames);
showToast(`${validFiles.length} file(s) added`, 'success');
}
event.target.value = '';
}, [localFiles, validateFile, onChange, onChangeValue]);
The split: File objects → localFiles state. File names → form data via onChange. The form data always has a human-readable list of selected file names. The actual file objects are held in memory by the component.
The validation that runs before accepting a file:
const validateFile = useCallback((file: File): boolean => {
const currentNumberOfFiles = localFiles.length;
const currentTotalSize = localFiles.reduce((sum, lf) => sum + lf.file.size, 0);
// Max file count
if (currentNumberOfFiles + 1 > (field.validate?.maxFileNumber || Infinity)) {
showToast(
`${file.name}: Maximum ${field.validate?.maxFileNumber} files allowed`,
'error'
);
return false;
}
// Max total size
const maxTotalSizeBytes = (field.validate?.maxTotalSize || Infinity) * 1024 * 1024;
if (currentTotalSize + file.size > maxTotalSizeBytes) {
showToast(
`${file.name}: Total size exceeds ${field.validate?.maxTotalSize} MB`,
'error'
);
return false;
}
// Blacklist
if (field.validate?.attachedValidationType === 'Blacklist' &&
field.validate?.blacklistAttachment) {
const blackList = field.validate.blacklistAttachment
.trim().replaceAll(' ', '').split(',');
const ext = file.name.split('.').pop()?.toLowerCase() || '';
if (blackList.includes(ext)) {
showToast(`${file.name}: '${ext}' format is not allowed`, 'error');
return false;
}
}
// Whitelist
if (field.validate?.attachedValidationType === 'Whitelist' &&
field.validate?.whitelistAttachment) {
const whiteList = field.validate.whitelistAttachment
.trim().replaceAll(' ', '').split(',');
const ext = file.name.split('.').pop()?.toLowerCase() || '';
if (!whiteList.includes(ext)) {
showToast(
`${file.name}: '${ext}' not allowed. Allowed: ${whiteList.join(', ')}`,
'error'
);
return false;
}
}
return true;
}, [localFiles, field.validate, showToast]);
Stage 2: The _pendingFilesRef Pattern
The bridge between the React component (Stage 1) and the upload code (Stage 3) is _pendingFilesRef — a { current: Map } object attached to the event bus instance.
Why the Event Bus Instance
The event bus instance is the one object that satisfies all requirements:
| Requirement | Event Bus | Global Variable | React Context | DI Service |
|---|---|---|---|---|
| Accessible in React components | ✅ (passed as prop via bridge) | ✅ | ✅ | ❌ |
| Accessible in Preact renderers | ✅ (passed as prop) | ✅ | ❌ | ❌ |
| Accessible in CustomForm class | ✅ (this.get('eventBus')) |
✅ | ❌ | ✅ |
| Isolated per form instance | ✅ (one bus per form) | ❌ | Depends | ✅ |
| No framework dependency | ✅ (plain JS object) | ✅ | ❌ | ❌ |
The event bus is already a plain JavaScript object. Attaching a property to it is attaching a property to a JavaScript object — no framework involvement, no serialization, no boundary restrictions.
Global variables fail the "isolated per form instance" requirement. React Context fails the "accessible in Preact renderers" and "accessible in CustomForm class" requirements. DI services fail the "accessible in React components" requirement — React components can't access the DI container, only props passed through the bridge.
The event bus instance is the only object that satisfies all requirements simultaneously.
Initialization in CustomForm
_pendingFilesRef is created in the CustomForm constructor — before any module initializes, before any renderer runs:
// CustomForm.ts
export class CustomForm extends Form {
private _isRendered: boolean = false;
constructor(options: FormOptions = {}) {
const mergedOptions: FormOptions = {
...options,
additionalModules: [
// ... all modules
]
};
super(mergedOptions);
// ✅ Attach _pendingFilesRef to the event bus instance
// Done in constructor — before any renderer has a chance to run
try {
const eventBus = this.get('eventBus');
if (eventBus && !eventBus._pendingFilesRef) {
eventBus._pendingFilesRef = { current: new Map() };
// Structure: Map<fieldKey: string, files: File[]>
}
} catch (e) {
console.warn('⚠️ [CustomForm] Could not initialize _pendingFilesRef:', e);
}
}
The { current: Map } wrapper (rather than the Map directly) mirrors React's useRef shape. This makes it clear that _pendingFilesRef holds a mutable reference and that _pendingFilesRef.current is the actual data. It also makes the TypeScript type clear:
// On the event bus:
eventBus._pendingFilesRef: { current: Map<string, File[]> }
// Accessed as:
eventBus._pendingFilesRef.current.get(fieldKey) // → File[]
eventBus._pendingFilesRef.current.set(fieldKey, files) // → void
Writing to the Store From React
The FileUpload component syncs its local files to the store whenever localFiles changes:
// ✅ Stage 2: Sync local files to the cross-boundary store
useEffect(() => {
if (eventBus?._pendingFilesRef) {
const pendingFilesMap = eventBus._pendingFilesRef.current;
const files = localFiles.map(lf => lf.file);
if (files.length > 0) {
pendingFilesMap.set(fieldKey, files);
} else {
// ✅ Clean up when all files are removed
pendingFilesMap.delete(fieldKey);
}
}
}, [localFiles, eventBus, fieldKey]);
This useEffect runs every time localFiles changes — when files are added or removed. The Map is always current with the component's local state.
Removing Files
When a user removes a file, both the local state and the store are updated:
const removeFile = useCallback((index: number) => {
const newFiles = [...localFiles];
const removedFile = newFiles[index];
newFiles.splice(index, 1);
// ✅ Update local state
setLocalFiles(newFiles);
// ✅ Update form data (file names list)
const fileNames = newFiles.map(lf => lf.file.name);
onChange?.(fileNames);
onChangeValue?.(fileNames);
showToast(`${removedFile.file.name} removed`, 'info');
// The useEffect above will sync the Map automatically
}, [localFiles, onChange, onChangeValue, showToast]);
The Sequence Diagram
User FileUpload localFiles _pendingFilesRef Form Data
| | | | |
|--selects file->| | | |
| |--validateFile() | | |
| |--setLocalFiles--->| | |
| |<--useEffect fires | | |
| |--Map.set(key,files)------------------>| |
| |--onChange(names)---------------------------------------------->|
| | | | |
|--removes file->| | | |
| |--setLocalFiles--->| | |
| |<--useEffect fires | | |
| |--Map.set(key,fewer files)------------>| |
| |--onChange(fewer names)---------------------------------------->|
| | | | |
|--clicks Submit | | | |
| | | CustomForm reads Map |
| | | for each field with files:|
| | | POST to Camunda API |
| | | Map.clear() after upload |
Stage 3: Upload on Submit
CustomForm handles the upload. It reads the _pendingFilesRef Map and uploads each file to Camunda's task attachment REST endpoint.
The Upload Method
// CustomForm.ts
/**
* Upload a file as a Camunda task attachment.
* Called once per file after form submission.
*/
async uploadFileAsAttachment(
taskId: string,
file: File,
description = ''
) {
// ✅ multipart/form-data — required by Camunda's attachment API
const formData = new FormData();
formData.append('attachmentName', file.name);
formData.append('attachmentType', file.type || 'application/octet-stream');
formData.append(
'attachmentDescription',
description || 'Uploaded via form'
);
formData.append('content', file); // ✅ The actual file bytes
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: ${text}`);
}
return await response.json();
}
The Content-Type header note is important. When using FormData with fetch, do NOT set Content-Type: multipart/form-data manually. If you set it manually, the multipart boundary — a unique string that separates file parts — won't be included. The server receives a malformed request and rejects it. Let fetch set the Content-Type automatically — it includes the boundary.
Uploading All Pending Files
The CustomForm exposes a method to upload all pending files for a given task:
/**
* Upload all files stored in _pendingFilesRef for the given task.
* Call this after successful form submission.
*
* @param taskId - The Camunda task ID to attach files to
* @returns Array of upload results
*/
async uploadPendingFiles(taskId: string): Promise<any[]> {
const eventBus = this.get('eventBus');
if (!eventBus?._pendingFilesRef) {
console.warn('[CustomForm] _pendingFilesRef not found');
return [];
}
const pendingFilesMap = eventBus._pendingFilesRef.current;
if (pendingFilesMap.size === 0) {
return []; // No files to upload
}
const results: any[] = [];
const uploadErrors: string[] = [];
for (const [fieldKey, files] of pendingFilesMap.entries()) {
for (const file of files) {
try {
const result = await this.uploadFileAsAttachment(
taskId,
file,
`File field: ${fieldKey}`
);
results.push(result);
} catch (err) {
console.error(`[CustomForm] Failed to upload ${file.name}:`, err);
uploadErrors.push(`${file.name}: ${(err as Error).message}`);
}
}
}
// ✅ Clear after upload — prevent double-upload on re-submit
pendingFilesMap.clear();
if (uploadErrors.length > 0) {
throw new Error(
`${uploadErrors.length} file(s) failed to upload: ${uploadErrors.join(', ')}`
);
}
return results;
}
Integration With Form Submission
In the application code that hosts the form:
// ApplicationFormHandler.ts
async function handleFormSubmit(
form: CustomForm,
taskId: string,
variables: Record<string, any>
) {
// Step 1: Validate the form
const errors = form.validate();
if (Object.keys(errors).length > 0) {
return; // Form has errors — don't submit
}
try {
// Step 2: Complete the Camunda task with form variables
await camundaClient.completeTask(taskId, { variables });
// Step 3: Upload pending files as task attachments
// Note: upload AFTER task completion — if task completion fails,
// we don't want orphaned attachments
const uploadResults = await form.uploadPendingFiles(taskId);
if (uploadResults.length > 0) {
console.log(`Uploaded ${uploadResults.length} attachment(s)`);
}
// Step 4: Handle success
navigateToNextStep();
} catch (err) {
if (err.message.includes('failed to upload')) {
// Task completed but files failed to upload
showWarning('Form submitted but some files failed to upload. Please re-attach them.');
} else {
// Task completion failed
showError('Submission failed. Please try again.');
}
}
}
Upload order matters. Upload files after task completion, not before. If you upload files before completing the task and the task completion fails (network error, validation failure), you have files attached to a task that wasn't actually completed. The user sees a success indicator on the file upload but the form is in a broken state. The reverse error — task completes but files fail to upload — is annoying but recoverable. The user can re-attach files manually.
The registerFileUploadHandler Pattern
CustomForm also provides a utility for connecting HTML file inputs that exist in the rendered form DOM to the upload handler. This is useful for simple file inputs that bypass the React component system:
/**
* Attach event listeners to file inputs with data-attach="true".
* Call this after the form is rendered.
*
* @param taskId - The Camunda task ID to attach files to
*/
registerFileUploadHandler(taskId: string) {
// ✅ Find file inputs marked for direct upload
const inputs = this._container?.querySelectorAll<HTMLInputElement>(
'input[type="file"][data-attach="true"]'
);
if (!inputs?.length) return;
inputs.forEach((input) => {
input.addEventListener('change', async (event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
await this.uploadFileAsAttachment(
taskId,
file,
`File field: ${input.name}`
);
alert(`✅ File "${file.name}" uploaded successfully!`);
} catch (err) {
console.error('Attachment upload failed', err);
alert(`❌ Upload failed: ${(err as Error).message}`);
}
});
});
}
Usage in the application:
await form.importSchema(schema, data);
// Register after rendering — DOM exists now
form.registerFileUploadHandler(taskId);
This pattern is for simple file inputs in custom HTML field renderers that don't use the React component system. The data-attach="true" attribute marks which inputs should trigger direct upload behavior.
The Complete FileUpload Component
For completeness, here is the full component including the drag-and-drop handler:
// FileUpload.tsx — complete component
export const FileUpload: React.FC<FileUploadProps> = ({
field,
inputId = 'attachmentId',
readonly = false,
disabled = false,
value = [],
onChange,
onChangeValue,
eventBus
}) => {
const [localFiles, setLocalFiles] = useState<LocalFile[]>([]);
const [toasts, setToasts] = useState<Toast[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const fieldKey = field.key || 'unknown_field';
// ✅ Stage 2: Sync to _pendingFilesRef whenever localFiles changes
useEffect(() => {
if (eventBus?._pendingFilesRef) {
const pendingFilesMap = eventBus._pendingFilesRef.current;
const files = localFiles.map(lf => lf.file);
if (files.length > 0) {
pendingFilesMap.set(fieldKey, files);
} else {
pendingFilesMap.delete(fieldKey);
}
}
}, [localFiles, eventBus, fieldKey]);
const showToast = useCallback((message: string, type: Toast['type']) => {
const toast: Toast = {
id: `toast-${Date.now()}-${Math.random()}`,
message,
type,
};
setToasts(prev => [...prev, toast]);
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== toast.id));
}, 5000);
}, []);
// Validation, handleInput, handleDrop, removeFile
// ... (shown in Stage 1 section above)
const handleDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
if (disabled || readonly) return;
event.preventDefault();
event.stopPropagation();
const files: File[] = [];
if (event.dataTransfer.items) {
Array.from(event.dataTransfer.items).forEach((item) => {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) files.push(file);
}
});
} else {
files.push(...Array.from(event.dataTransfer.files));
}
if (files.length === 0) return;
const validFiles: LocalFile[] = [];
for (const file of files) {
if (validateFile(file)) {
validFiles.push({ file, id: Math.floor(Math.random() * 1000000) });
}
}
if (validFiles.length > 0) {
setLocalFiles(prev => [...prev, ...validFiles]);
const allFiles = [...localFiles, ...validFiles];
const fileNames = allFiles.map(lf => lf.file.name);
onChange?.(fileNames);
onChangeValue?.(fileNames);
showToast(`${validFiles.length} file(s) added`, 'success');
}
}, [disabled, readonly, localFiles, validateFile, onChange, onChangeValue, showToast]);
return (
<div className={styles.fileUploadContainer}>
<div
className={styles.dropZone}
onDrop={handleDrop}
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
>
<div className={styles.dropZoneBrowse}>
<a className={styles.browseLink} onClick={() => fileInputRef.current?.click()}>
{/* Upload icon SVG */}
Browse file
</a>
<input
ref={fileInputRef}
id={inputId}
type="file"
onChange={handleInput}
multiple
disabled={disabled || readonly}
className={styles.fileInput}
/>
<div className={styles.dragDropText}>Drag and drop file here</div>
</div>
{/* File list */}
<div className={styles.droppedList}>
{localFiles.map((localFile, index) => (
<div key={localFile.id} className={styles.attachedItem}>
<div
className={styles.image}
onClick={() => {
// Create temporary URL for preview/download
const url = URL.createObjectURL(localFile.file);
const link = document.createElement('a');
link.href = url;
link.download = localFile.file.name;
link.click();
URL.revokeObjectURL(url);
}}
>
{/* File type icon */}
</div>
<div className={styles.uploaded}>
<div className={styles.fileName} title={localFile.file.name}>
{localFile.file.name}
</div>
<div>{(localFile.file.size / (1024 * 1024)).toFixed(2)} MB</div>
</div>
{!disabled && !readonly && (
<button
type="button"
onClick={() => removeFile(index)}
className={styles.removeButton}
aria-label={`Remove ${localFile.file.name}`}
>
{/* Delete icon */}
</button>
)}
</div>
))}
</div>
</div>
{/* Toast notifications */}
{toasts.length > 0 && (
<div className={styles.toastContainer}>
{toasts.map((toast) => (
<div
key={toast.id}
className={`${styles.toast} ${styles[toast.type]}`}
onClick={() => setToasts(prev => prev.filter(t => t.id !== toast.id))}
>
<span>{toast.message}</span>
<button onClick={() => setToasts(prev => prev.filter(t => t.id !== toast.id))}>
✕
</button>
</div>
))}
</div>
)}
</div>
);
};
Edge Cases
What if the user submits without selecting files? pendingFilesMap is empty. uploadPendingFiles finds nothing, returns an empty array, and returns without error. The form submits normally.
What if the component unmounts before submission? When a field is hidden or the form section changes, the React component may unmount. The localFiles state is lost — React clears it on unmount. But the _pendingFilesRef Map still holds the references from the last useEffect sync. The files are still accessible at upload time.
What if the user clears a file field after selecting? removeFile updates localFiles. The useEffect fires, calls pendingFilesMap.delete(fieldKey). The Map is updated. At upload time, that field has no pending files.
What about file size limits on the Camunda side? Camunda's task attachment API accepts files up to the server's configured limit (typically 10MB default). Client-side validation in validateFile enforces the limit configured in the field's validate.maxTotalSize property. The mismatch between client-side and server-side limits should be handled by showing a clear error message when the server rejects a file, which the uploadFileAsAttachment catch block handles.
What about concurrent uploads? uploadPendingFiles uses await in a for...of loop — uploads happen sequentially, not concurrently. Sequential upload is slower but easier to reason about for error handling. If one file fails, subsequent files still attempt to upload. For better performance with many files, you could use Promise.all() — but you lose sequential error recovery.
The Tradeoffs
_pendingFilesRef is imperative mutation, not declarative state. The Map is mutated directly — set, delete, clear — without going through React's state mechanism or Form-JS's state mechanism. This means React and Form-JS are not aware when the Map changes. They don't re-render. They don't fire events. If you need UI that reflects the Map's current state (a "files pending upload" counter in the form header, for example), you can't read it from the Map directly — you have to duplicate that state in the React component's localFiles state and read from there.
File objects don't survive form reset. If the user clicks a "Reset" button on the form, Form-JS clears form data. The file names in form data are cleared. But the React component's localFiles state is managed by React — Form-JS's reset doesn't touch it. After a reset, the UI shows no files selected (if the component re-renders from empty form data) but _pendingFilesRef might still hold references from before the reset. The fix: listen to form reset events and clear both local state and the Map:
useEffect(() => {
const handleReset = () => {
setLocalFiles([]);
if (eventBus?._pendingFilesRef) {
eventBus._pendingFilesRef.current.delete(fieldKey);
}
};
eventBus?.on('form.reset', handleReset);
return () => eventBus?.off('form.reset', handleReset);
}, [eventBus, fieldKey]);
Upload happens outside the form submit lifecycle. Form-JS's form.submit() event and the submitForm() handler don't know about file uploads. uploadPendingFiles is a manual call by the application code after the Camunda task is completed. If the application code forgets to call it, files are silently not uploaded. A more integrated approach would override form.submit() to include file upload in the submission flow — but this creates coupling between the generic form and the Camunda-specific upload logic.
What Comes Next
The file handling system completes the cross-cutting concerns of the Form-JS extension system. The remaining articles cover the architecture pieces that make everything work together: the Form Logics group that organizes properties panel entries, the subclassed Form and FormEditor that bootstrap all modules, and the capstone architecture overview.
Article 19 covers the Form Logics panel group — how seven independent providers contribute to the same panel section without knowing about each other, and the create-if-not-exists pattern that makes cooperative group ownership work.
This is Part 18 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 file-upload react lifecycle javascript devex
Top comments (0)