Part 5 of the series: "Extending bpmn-io Form-JS Beyond Its Limits"
I built my first properties panel provider by copying the official example, changing the field type check, and adding my entries. It worked. Then I built the second provider, copied the same pattern, and my first provider stopped working correctly. Some entries disappeared. Some appeared in the wrong order. One entry showed up twice with different values.
I had no idea why. The official documentation shows you the happy path — one provider, basic entries, everything works. It doesn't explain the contract that makes the system work, what breaks the contract, or why the system fails silently when you break it.
After building 12 providers — for disabled state, readonly state, required validation, FEEL bindings, hide-if conditions, conditional rules, auto-fill logic, tab titles, grid configuration, dropdown configuration, image validation, and ticket auto-fill — I know the contract completely. This article documents everything the official docs leave out.
The Problem
The Form-JS properties panel is extensible through providers. You register a provider, implement getGroups, and your entries appear in the sidebar when a field is selected in the editor. The official documentation shows this much.
What it doesn't explain:
-
getGroupsreturns a function, not groups — the middleware pattern - The priority number in
registerProviderhas specific semantics that determine override behavior -
isEditedhas three different signatures and controls something most developers don't know exists -
isDefaultVisibleexists and controls whether entries appear at all before the user sets a value - Entry IDs must be unique across the entire panel, not just your provider — and violations fail silently
- Creating a new group vs injecting into an existing group have different rules
Every one of these gaps causes bugs that are invisible without knowing what to look for. Let me walk through each one.
What I Tried First
My first provider looked like this:
// ❌ My first attempt
export class MyProvider {
constructor(propertiesPanel) {
propertiesPanel.registerProvider(this); // No priority
}
getGroups(field, editField) {
// ❌ Returning groups directly, not a function
const groups = [];
groups.push({
id: 'my-group',
label: 'My Config',
entries: [
{
id: 'my-entry', // ❌ Not unique — will conflict
component: MyEntry,
getValue: () => field.myProp,
setValue: (v) => editField(field, 'myProp', v)
}
]
});
return groups;
}
}
Three mistakes in nine lines. No priority — undefined behavior when running alongside other providers. Returning groups directly instead of a function — the middleware breaks, other providers' entries disappear. Non-unique entry ID — silent conflict with any other provider using 'my-entry'.
None of these produce error messages. The panel either renders incorrectly or not at all, and you have no stack trace pointing to why.
The Solution: Understanding the Full Contract
The Middleware Pattern — getGroups Returns a Function
This is the most important thing to understand and the most non-obvious.
getGroups does not return an array of groups. It returns a function that receives the current groups and returns modified groups. This is the middleware pattern — each provider is a function in a chain, receiving the output of the previous provider and passing modified output to the next.
// ❌ Wrong — returning groups directly
getGroups(field, editField) {
return [{
id: 'general',
entries: [...]
}];
}
// ✅ Correct — returning a function that modifies groups
getGroups(field, editField) {
return (groups) => {
// groups is what all previous providers have built
// Modify it and return it for the next provider
const generalGroup = groups.find(g => g.id === 'general');
if (generalGroup) {
generalGroup.entries.push(...myEntries);
}
return groups;
};
}
Why does returning groups directly break other providers? Because the panel calls each provider's getGroups in priority order, passing the result of one as input to the next. If your provider returns an array instead of a function, the panel receives an array where it expects a function, fails to call it, and the chain is broken. All providers that should run after yours produce nothing.
The symptom: your entries appear, but some or all other providers' entries disappear.
When I discovered this, I had been accidentally wiping out Form-JS's built-in entries for every field I touched with my provider. The provider appeared to work because my entries showed up, but every built-in entry — key, label, description — was gone.
The Priority System — What the Number Actually Means
propertiesPanel.registerProvider(this, 500);
The second argument is the priority. Higher numbers run later in the chain. Form-JS's built-in providers register at priority 500.
This means:
- Priority
< 500: Your provider runs before the built-ins. You can set up structure that built-ins will modify. - Priority
500: Your provider runs at the same level as built-ins. Order relative to other priority-500 providers depends on registration order. - Priority
> 500: Your provider runs after the built-ins. You can see everything they've added and modify or remove it.
For most custom entries, priority 500 is correct — you're adding new entries alongside the built-ins, not modifying them.
For overriding built-in entries — replacing the default disabled toggle with a FEEL-capable version — you need priority 1000:
// ✅ Priority 1000 — runs after all built-ins have added their entries
// Can see and remove entries the built-ins added
export class DisabledPropertiesProvider {
constructor(propertiesPanel) {
propertiesPanel.registerProvider(this, 1000);
}
getGroups(element, editField) {
return (groups) => {
// Remove the default disabled entry that built-ins added at priority 500
groups.forEach(group => {
if (group?.entries) {
group.entries = group.entries.filter(entry =>
entry.id !== 'disabled' &&
entry.id !== 'logic-disabled'
);
}
});
// Add our enhanced version
const formLogicsGroup = this._getOrCreateFormLogicsGroup(groups);
formLogicsGroup.entries.unshift(...DisabledEntry({
field: element,
editField,
eventBus: this._eventBus
}));
return groups;
};
}
}
The built-in entry IDs you need to know for overriding:
| Property | Entry IDs to filter out |
|---|---|
| Disabled |
'disabled', 'logic-disabled'
|
| Readonly |
'readonly', 'logic-readonly'
|
| Required |
'required', 'logic-required'
|
| Key | 'key' |
| Label | 'label' |
| Description | 'description' |
These IDs are not documented. I found them by logging groups in a provider running at priority 1000 and inspecting the entry objects.
One critical thing: Priority controls when your provider runs, but it does not automatically remove anything. If you want to remove a built-in entry, you must explicitly filter it out. Running at priority 1000 alone is not enough — you still need the filter.
isEdited — The Blue Dot Nobody Explains
Every entry definition has an isEdited property. This controls the blue dot indicator that appears in the properties panel sidebar next to a group name when any entry in that group has been changed from its default value:
General ● ← blue dot means something in this group has been edited
isEdited is a function that receives the current field element and returns true if the property has a non-default value.
Here is where it gets complicated: isEdited has three different signatures depending on context, and the official docs only show one.
Signature 1: Field element as argument
The most common case. The function receives the field object and you inspect it directly:
{
id: `my-entry-${field.id}`,
component: MyEntryComponent,
isEdited: (element) => {
// Return true when the property has a non-default value
return !!element.myProperty;
}
}
Signature 2: Using imported helpers from @bpmn-io/properties-panel
For standard entry types, the properties panel package exports pre-built isEdited functions:
import {
isTextFieldEntryEdited,
isCheckboxEntryEdited,
isNumberFieldEntryEdited,
isSelectEntryEdited
} from '@bpmn-io/properties-panel';
// ✅ Use these for standard entry types
{
id: `grid-columns-${field.id}`,
component: GridColumnsEntry,
isEdited: isTextFieldEntryEdited // ← Pass the function reference, not a call
}
These helpers check the entry's current value against its default. They work correctly with the debounced update cycle that the panel uses.
Signature 3: Custom function for nested config paths
When your property is stored nested inside an object (like field.grid_config.dynamic_grid), the built-in helpers don't know how to check it. Write a custom function:
{
id: `dynamic-grid-${field.id}`,
component: DynamicGridEntry,
isEdited: (element) => {
// Check nested path manually
const config = element.grid_config || {};
return config.dynamic_grid === true;
}
}
What happens when isEdited is wrong:
- If you always return
true: the blue dot is always on, even for new unmodified fields — confusing to form designers - If you always return
false: no visual feedback when properties have been changed — form designers can't tell what's been configured - If you omit
isEditedentirely: the entry renders but the blue dot behavior is undefined — sometimes works, sometimes doesn't, depending on Form-JS version
isDefaultVisible — The Invisible Gate
This property is completely absent from the Form-JS documentation. I discovered it by reading the properties panel source code when trying to understand why some of my entries weren't appearing.
isDefaultVisible is a function on an entry definition that controls whether the entry appears before the user has set any value for it. If isDefaultVisible returns false, the entry is hidden until the property has been given a value — at which point isEdited returns true and the entry appears.
{
id: `feel-expression-${field.id}`,
component: FeelExpressionEntry,
getValue: () => get(field, ['feelExpression'], ''),
setValue: (value) => editField(field, ['feelExpression'], value),
isEdited: (field) => !!get(field, ['feelExpression']),
// ✅ Only show this entry when the field type supports it
isDefaultVisible: (field) => SUPPORTED_FIELD_TYPES.includes(field.type)
}
In my system, I use isDefaultVisible to show FEEL expression entries only for field types that support them. A textfield supports FEEL bindings. A separator does not. Rather than filtering entries before building them, I build them for all field types and use isDefaultVisible to hide them for unsupported types.
The practical difference between isDefaultVisible and type checking:
// Approach 1: Filter before building (what I started with)
getGroups(field, editField) {
return (groups) => {
if (!SUPPORTED_TYPES.includes(field.type)) return groups; // Skip entirely
const formLogicsGroup = getOrCreate(groups);
formLogicsGroup.entries.push(...myEntries);
return groups;
};
}
// Approach 2: Build always, control visibility with isDefaultVisible
getGroups(field, editField) {
return (groups) => {
const formLogicsGroup = getOrCreate(groups);
formLogicsGroup.entries.push({
id: `my-entry-${field.id}`,
component: MyEntry,
isDefaultVisible: (f) => SUPPORTED_TYPES.includes(f.type),
// ...
});
return groups;
};
}
Approach 2 is more declarative and easier to reason about when you have complex visibility rules. It also plays better with the panel's animation system — entries that hide/show via isDefaultVisible animate smoothly, while entries added/removed by the middleware chain do not.
Entry ID Uniqueness — The Silent Collision
Every entry in the properties panel must have a unique ID across the entire panel, not just within your provider or your group.
When two entries share an ID, the behavior depends on Form-JS version and order of registration. In all cases, it's wrong:
- The second entry silently replaces the first
- Or the first remains and the second is silently ignored
- Or both render but share state, causing values to bleed between them
There is no error. No warning. Just wrong behavior.
The most common mistake is using a static ID:
// ❌ This conflicts with any other field that has this entry
{
id: 'my-configuration-entry',
component: MyEntry,
// ...
}
If two textfields are on the form simultaneously and both have a 'my-configuration-entry' entry, one of them will not work correctly.
The fix is always to suffix the ID with the field's unique identifier:
// ✅ Unique per field instance
{
id: `my-configuration-entry-${field.id}`,
component: MyEntry,
// ...
}
field.id is the unique identifier assigned by Form-JS to each field instance in the schema. It's different from field.key (which the form designer sets and can accidentally duplicate) and from field.type (which is shared across all fields of the same type).
When you have multiple entries in the same provider:
function createEntries(field, editField) {
const fieldId = field.id; // Store once, use consistently
return [
{
id: `dropdown-is-multi-${fieldId}`, // ✅
component: IsMultiEntry,
// ...
},
{
id: `dropdown-data-source-${fieldId}`, // ✅
component: DataSourceEntry,
// ...
},
{
id: `dropdown-placeholder-${fieldId}`, // ✅
component: PlaceholderEntry,
// ...
}
];
}
A subtle case: dynamic entries that are conditionally added:
If you conditionally push entries based on field configuration, and those conditional entries use static IDs, you get collisions when multiple fields with the same configuration exist simultaneously:
// ❌ Collision when two manual-source dropdowns are on the form
if (config.data_source === 'manual') {
entries.push({
id: 'dropdown-static-values', // Static ID — collides!
component: StaticValuesGroup,
// ...
});
}
// ✅ No collision
if (config.data_source === 'manual') {
entries.push({
id: `dropdown-static-values-${field.id}`, // Unique per field
component: StaticValuesGroup,
// ...
});
}
Injecting Into Existing Groups vs Creating New Groups
You have two choices when adding entries: find an existing group and inject into it, or create a new group. The rules are different for each.
Injecting into an existing group:
getGroups(field, editField) {
return (groups) => {
// Find the existing 'general' group
const generalGroup = groups.find(g => g.id === 'general');
if (!generalGroup) return groups; // ✅ Always guard against missing group
// Push to the end of existing entries
generalGroup.entries.push(...myEntries);
// Or insert at a specific position
const keyEntryIndex = generalGroup.entries.findIndex(e => e.id === 'key');
if (keyEntryIndex !== -1) {
// Insert after the 'key' entry
generalGroup.entries.splice(keyEntryIndex + 1, 0, ...myEntries);
}
return groups;
};
}
The built-in groups and their IDs:
| Group ID | Contents |
|---|---|
'general' |
Key, label, description, type-specific entries |
'condition' |
Hide/show condition |
'validation' |
Required, min/max length, pattern |
'appearance' |
Style-related properties |
Creating a new group:
getGroups(field, editField) {
return (groups) => {
// Check if the group already exists
// (another provider may have created it first)
let myGroup = groups.find(g => g.id === 'my-custom-group');
if (!myGroup) {
myGroup = {
id: 'my-custom-group',
label: 'My Configuration',
entries: []
};
// Find insertion position — after 'general', before 'condition'
const generalIndex = groups.findIndex(g => g.id === 'general');
const insertIndex = generalIndex >= 0 ? generalIndex + 1 : groups.length;
groups.splice(insertIndex, 0, myGroup);
}
// Add entries to the group
myGroup.entries.push(...myEntries);
return groups;
};
}
The create-if-not-exists pattern is critical when multiple providers share a group:
I use a group called 'form-logics' across seven different providers. Each provider adds its own entries to this group. The pattern ensures the group is created exactly once, regardless of which provider runs first:
function getOrCreateFormLogicsGroup(groups) {
let group = groups.find(g => g.id === 'form-logics');
if (!group) {
group = {
id: 'form-logics',
label: 'Form Logics',
entries: []
};
const generalIndex = groups.findIndex(g => g.id === 'general');
const insertIndex = generalIndex >= 0 ? generalIndex + 1 : 0;
groups.splice(insertIndex, 0, group);
}
return group;
}
Any provider that calls getOrCreateFormLogicsGroup(groups) gets the group — creating it if it doesn't exist, returning the existing one if it does. Seven providers all add to the same group without knowing about each other.
Entry Component Props — What Gets Passed
When Form-JS renders your entry component, it passes a specific set of props. Knowing these prevents you from wondering why props you defined in the entry definition aren't arriving in your component.
// Entry definition
{
id: `my-entry-${field.id}`,
component: MyEntryComponent,
field, // ← Your field reference
editField, // ← Edit function (sometimes passed, sometimes not)
getValue: () => get(field, ['myProp']),
setValue: (v) => editField(field, 'myProp', v),
isEdited: (el) => !!el.myProp,
// Any additional props you add here...
myCustomProp: 'hello'
}
// Entry component receives:
function MyEntryComponent(props) {
const {
id, // ← The entry ID
element, // ← The field (not "field" — it's "element" in the component)
getValue, // ← Your getValue function
setValue, // ← Your setValue function
// Any props you added to the entry definition:
myCustomProp, // ← 'hello'
// Props NOT automatically passed:
// field — it's called "element" here
// editField — not passed unless you explicitly add it
} = props;
// getValue and setValue are called without arguments in most cases
const value = getValue();
return TextFieldEntry({
element,
id,
label: 'My Property',
getValue,
setValue
});
}
The naming inconsistency (field in the definition, element in the component) is a Form-JS convention. It trips up everyone who reads the source code of one and writes the other.
editField is not automatically passed to components. If your component needs to call editField directly (for complex multi-path updates), you must explicitly pass it in the entry definition:
{
id: `my-entry-${field.id}`,
component: MyEntryComponent,
field,
editField, // ✅ Explicitly include editField if the component needs it
getValue: () => get(field, ['myProp']),
setValue: (v) => editField(field, 'myProp', v)
}
The Complete Provider Template
Here is the complete, production-ready provider template incorporating every pattern:
// MyPropertiesProvider.js
import { get } from 'min-dash';
import {
isTextFieldEntryEdited,
isCheckboxEntryEdited
} from '@bpmn-io/properties-panel';
import { useService } from '@bpmn-io/form-js';
import { TextFieldEntry, CheckboxEntry } from '@bpmn-io/properties-panel';
// ✅ Define supported field types once
const SUPPORTED_TYPES = [
'textfield', 'textarea', 'number', 'dropdown',
'select', 'radio', 'checkbox', 'checklist',
'datetime', 'date', 'time'
];
// ✅ Shared group ID constant — prevents typos across providers
const FORM_LOGICS_GROUP_ID = 'form-logics';
export class MyPropertiesProvider {
constructor(propertiesPanel, eventBus) {
// ✅ Priority 500 for new entries alongside built-ins
// Use 1000 if you need to remove/override built-in entries
propertiesPanel.registerProvider(this, 500);
this._eventBus = eventBus;
}
getGroups(field, editField) {
// ✅ Return a FUNCTION (middleware pattern) — not the groups themselves
return (groups) => {
// ✅ Early return for unsupported field types
if (!SUPPORTED_TYPES.includes(field.type)) {
return groups;
}
// ✅ Get or create your target group
const targetGroup = this._getOrCreateFormLogicsGroup(groups);
// ✅ Add entries — IDs must include field.id for uniqueness
targetGroup.entries.push(
...createMyEntries(field, editField, this._eventBus)
);
return groups;
};
}
// ✅ Create-if-not-exists pattern for shared groups
_getOrCreateFormLogicsGroup(groups) {
let group = groups.find(g => g.id === FORM_LOGICS_GROUP_ID);
if (!group) {
group = {
id: FORM_LOGICS_GROUP_ID,
label: 'Form Logics',
entries: []
};
// Insert after 'general' group
const generalIndex = groups.findIndex(g => g.id === 'general');
const insertIndex = generalIndex >= 0 ? generalIndex + 1 : 0;
groups.splice(insertIndex, 0, group);
}
return group;
}
}
// ✅ Always inject required services
MyPropertiesProvider.$inject = ['propertiesPanel', 'eventBus'];
// ============================================================
// Entry factory — creates entry definitions for the panel
// ============================================================
function createMyEntries(field, editField, eventBus) {
const fieldId = field.id; // ✅ Use field.id (not field.key) for uniqueness
// ✅ Helper: update a nested config path
const onChange = (configPath, key) => (value) => {
const currentConfig = get(field, [configPath], {});
editField(field, configPath, { ...currentConfig, [key]: value });
};
// ✅ Helper: read from a nested config path
const getValue = (configPath, key) => () => {
return get(field, [configPath, key]);
};
return [
{
// ✅ Unique ID — includes field.id
id: `my-text-prop-${fieldId}`,
// The Preact component to render
component: MyTextEntry,
// Passed to the component as props
field,
editField,
// Called to get the current value for the component
getValue: getValue('my_config', 'textProp'),
// Called when the user changes the value
setValue: onChange('my_config', 'textProp'),
// ✅ Controls the blue dot indicator
// Use imported helper for standard types
isEdited: isTextFieldEntryEdited,
// ✅ Controls visibility before any value is set
// Omit if the entry should always be visible
isDefaultVisible: (f) => SUPPORTED_TYPES.includes(f.type)
},
{
id: `my-bool-prop-${fieldId}`,
component: MyCheckboxEntry,
field,
getValue: getValue('my_config', 'boolProp'),
setValue: onChange('my_config', 'boolProp'),
// ✅ Custom isEdited for nested boolean paths
isEdited: (element) => {
return get(element, ['my_config', 'boolProp']) === true;
}
},
{
id: `my-feel-prop-${fieldId}`,
component: MyFeelEntry,
field,
// ✅ Pass eventBus explicitly — FeelEntry requires it
eventBus,
getValue: getValue('my_config', 'feelProp'),
setValue: onChange('my_config', 'feelProp'),
isEdited: (element) => {
return !!get(element, ['my_config', 'feelProp']);
}
}
];
}
// ============================================================
// Entry components — Preact functional components
// ============================================================
function MyTextEntry(props) {
// ✅ Props come from the entry definition
// Note: it's "element" not "field" in the component
const { element, id, getValue, setValue } = props;
// ✅ Always get debounce from the service
const debounce = useService('debounceInput') ?? ((fn) => fn);
return TextFieldEntry({
debounce,
element,
getValue,
id,
label: 'Text Property',
description: 'A text configuration value',
setValue
});
}
function MyCheckboxEntry(props) {
const { element, id, getValue, setValue } = props;
return CheckboxEntry({
element,
getValue,
id,
label: 'Boolean Property',
setValue
});
}
function MyFeelEntry(props) {
const { element, id, getValue, setValue, eventBus } = props;
const debounce = useService('debounceInput') ?? ((fn) => fn);
// ✅ Variables from form context enable FEEL autocomplete
let variables = [];
try {
const variablesService = useService('variables', false);
if (variablesService) {
const vars = variablesService();
if (Array.isArray(vars)) {
variables = vars.map(name => ({ name }));
}
}
} catch (err) {
// Variables service not available — autocomplete won't work but entry still renders
}
// ✅ Import FeelEntry from your propertiesPanel re-export
const { FeelEntry } = require('@/formjs/propertiesPanel');
return FeelEntry({
debounce,
element,
eventBus, // ✅ Required for FEEL editor to function
feel: 'optional',
getValue,
id,
label: 'FEEL Expression',
description: 'Optional FEEL expression for dynamic behavior',
tooltip: 'Use fx to open the FEEL expression editor',
inline: true,
setValue,
variables
});
}
// ============================================================
// Module export — wires provider into the DI system
// ============================================================
export default {
__init__: ['myPropertiesProvider'],
myPropertiesProvider: ['type', MyPropertiesProvider]
};
The Debugging Checklist
When entries don't appear or behave wrong, work through this list:
Entry doesn't appear at all:
- Is
getGroupsreturning a function? Not returning groups directly? - Is the provider registered with
propertiesPanel.registerProvider(this, priority)? - Is the provider class in
__init__in your module definition? - Does
isDefaultVisiblereturntruefor this field type? - Does the field type check at the top of your middleware pass?
Entry appears for one field but not another of the same type:
- Are entry IDs unique per field? Do they include
field.id? - Is there an ID collision with another provider's entry?
Entry appears but doesn't save values:
- Does
setValuecalleditFieldwith the correct path? - Is
editFieldavailable in the closure? (It's passed togetGroups, notgetGroups's return function — close over it correctly) - Is the path you're writing to the same path
getValuereads from?
Entry appears but value resets on every re-render:
- Is
getValuereading fromfield(the snapshot passed togetGroups) or fromelement(the live field passed to the component)? Useelementin components.
Blue dot doesn't appear when it should:
- Is
isEditedreturningtruewhen the property has a non-default value? - Are you using the right
isEditedhelper for the entry type?
Other providers' entries disappeared:
- Did your
getGroupsreturn groups directly instead of a function? - Did a high-priority provider replace the groups array instead of modifying it?
The Tradeoffs
The middleware pattern is invisible. There's no indication in the API that getGroups should return a function. The error you get when you return groups directly is not "wrong return type" — it's the absence of other providers' entries, which looks like a different problem entirely.
Priority ordering within the same priority level is undefined. If two providers both register at priority 500 and both modify the general group, their relative order depends on module registration order — which depends on the order in additionalModules. This is an implicit dependency that isn't enforced.
The isDefaultVisible / isEdited interaction is subtle. An entry hidden by isDefaultVisible becomes visible when isEdited returns true. If isEdited always returns false (missing property, wrong path), the entry stays hidden forever even after the user sets a value. This produces the frustrating experience of: user sets a value, saves the form, reopens the form, entry is gone.
Entry components receive element, not field. This inconsistency between the entry definition (where the field is field) and the component (where the field is element) is a convention that isn't documented. It causes exactly one bug per developer before they learn it.
What Comes Next
You now have the full properties panel contract. The next article goes deep on one specific thing you'll do inside providers constantly: overriding the default entries that Form-JS ships with.
Article 6 covers how to remove disabled, readonly, and required default entries and replace them with enhanced versions that support FEEL expressions — the filter-by-ID pattern, priority ordering, and the dual-path getValue/setValue that makes toggle-or-FEEL work.
This is Part 5 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 properties-panel form-editor javascript devex
Top comments (0)