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
- How to discover built-in entry IDs when you need to override them
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 — three mistakes in nine lines
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. 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.
Critical: 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.
How to Discover Built-in Entry IDs
Before you can override a built-in entry, you need to know its ID. These IDs are not in the official documentation. The correct way to find them is to inspect the panel at runtime with a debug provider:
// Register this temporarily at priority 1000 to see everything
export class DebugProvider {
constructor(propertiesPanel) {
propertiesPanel.registerProvider(this, 1000);
}
getGroups(field, editField) {
return (groups) => {
// Log every group and every entry ID
groups.forEach(group => {
console.group(`Group: "${group.id}" — ${group.label || 'no label'}`);
(group.entries || []).forEach(entry => {
console.log(` Entry ID: "${entry.id}"`, entry.component?.name || '');
});
console.groupEnd();
});
return groups; // Always return groups unmodified
};
}
}
DebugProvider.$inject = ['propertiesPanel'];
Run this against a standard textfield. The console output will show you every group and every entry ID currently in the panel. This technique works regardless of Form-JS version — you're reading the actual runtime state, not relying on documentation that might be outdated.
For a textfield, you'll see something like:
Group: "general" — General
Entry ID: "key"
Entry ID: "label"
Entry ID: "description"
Entry ID: "disabled"
Entry ID: "readonly"
Entry ID: "required"
Entry ID: "defaultValue"
Group: "condition" — Condition
Entry ID: "hide"
Group: "validation" — Validation
Entry ID: "minLength"
Entry ID: "maxLength"
Entry ID: "pattern"
These are the IDs you filter to remove built-in entries.
Overriding Built-in Entries — The Complete Pattern
Here is the full pattern I use for replacing the built-in disabled entry with an enhanced version that supports FEEL expressions.
The key insight: I filter two IDs, not one. The first is the Form-JS built-in ('disabled'). The second is my own entry ('logic-disabled') — which I named deliberately so I can clean it up from a higher-priority provider if needed.
Why would my own entry need cleaning up? Because in a complex system with multiple providers at different priorities, a lower-priority provider might have already added a version of the disabled entry. The higher-priority override provider needs to remove both the built-in AND any of its own earlier entries to ensure exactly one entry appears.
export class DisabledPropertiesProvider {
constructor(propertiesPanel, eventBus, injector) {
this._propertiesPanel = propertiesPanel;
this._eventBus = eventBus;
this._injector = injector;
// ✅ Priority 1000 — runs after all built-ins at 500
// Can see and remove entries the built-ins added
propertiesPanel.registerProvider(this, 1000);
}
getGroups(element, editField) {
return (groups) => {
const SUPPORTED_TYPES = [
'textfield', 'textarea', 'number', 'select',
'radio', 'checkbox', 'checklist', 'taglist',
'datetime', 'date', 'time', 'dropdown',
'datagrid', 'button', 'gridfield', 'fileupload'
];
// ✅ Step 1: Remove entries across ALL groups
// Filter both the Form-JS built-in AND our own custom entry
// so exactly one version appears
groups.forEach(group => {
if (group?.entries) {
group.entries = group.entries.filter(entry =>
entry.id !== 'disabled' && // ← Form-JS built-in entry
entry.id !== 'logic-disabled' // ← Our own custom entry (if added by lower-priority provider)
);
}
});
// ✅ Step 2: Only add enhanced version for supported types
if (!SUPPORTED_TYPES.includes(element.type)) {
return groups;
}
// ✅ Step 3: Get or create the Form Logics group
let formLogicsGroup = groups.find(g => g.id === 'form-logics');
if (!formLogicsGroup) {
formLogicsGroup = {
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, formLogicsGroup);
}
if (!formLogicsGroup.entries) {
formLogicsGroup.entries = [];
}
// ✅ Step 4: Add enhanced entry at the beginning
const disabledEntries = DisabledEntry({
field: element,
editField,
eventBus: this._eventBus,
injector: this._injector
});
formLogicsGroup.entries.unshift(...disabledEntries);
return groups;
};
}
}
DisabledPropertiesProvider.$inject = ['propertiesPanel', 'eventBus', 'injector'];
The same pattern applies to readonly and required. In each case you filter two IDs — the Form-JS built-in and your own custom entry — and add your enhanced version:
// For readonly:
group.entries = group.entries.filter(entry =>
entry.id !== 'readonly' && // Form-JS built-in
entry.id !== 'logic-readonly' // Your custom entry
);
// For required:
group.entries = group.entries.filter(entry =>
entry.id !== 'required' && // Form-JS built-in
entry.id !== 'logic-required' // Your custom entry
);
The naming convention: The logic- prefix on your custom entries is deliberate. It signals "this is a logic-related property" and it's distinct enough from the built-in IDs that collisions are unlikely. More importantly, it gives you a consistent, predictable ID to filter in override providers. Establish your own naming convention and stick to it — the key is consistency, not the specific prefix.
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,
isFeelEntryEdited
} 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:
- Always return
true: the blue dot is always on, even for new unmodified fields - Always return
false: no visual feedback when properties have been changed - Omit entirely: undefined behavior depending on Form-JS version
The isFeelEntryEdited helper is particularly useful for FEEL-capable entries like your disabled/readonly/required overrides — it correctly handles both the expression path and the static boolean path:
import { isFeelEntryEdited } from '@bpmn-io/properties-panel';
// In your DisabledEntry, RequiredEntry, ReadonlyEntry:
{
id: `logic-disabled-${field.id}`,
component: LogicDisabled,
isEdited: isFeelEntryEdited // ✅ Handles both = expression and static boolean
}
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 for field types that support FEEL bindings
isDefaultVisible: (field) => SUPPORTED_FIELD_TYPES.includes(field.type)
}
In practice 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 control visibility per type.
The practical difference between type-checking and isDefaultVisible:
// Approach 1: Filter before building
getGroups(field, editField) {
return (groups) => {
if (!SUPPORTED_TYPES.includes(field.type)) return groups;
const formLogicsGroup = getOrCreate(groups);
formLogicsGroup.entries.push(...myEntries);
return groups;
};
}
// Approach 2: Build always, control visibility declaratively
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 visibility rules are complex. It also plays better with the panel's animation system.
The isDefaultVisible / isEdited interaction is subtle. An entry hidden by isDefaultVisible becomes visible when isEdited returns true. If isEdited always returns false — wrong path, missing property — 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 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 fields
There is no error. No warning. Just wrong behavior.
The most common mistake is using a static ID:
// ❌ This conflicts when multiple fields of this type are on the form
{
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: always suffix with field.id:
// ✅ 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 (shared across all fields of the same type).
With multiple entries in the same provider:
function createEntries(field, editField) {
const fieldId = field.id; // Store once, suffix consistently
return [
{ id: `dropdown-is-multi-${fieldId}`, component: IsMultiEntry, ... },
{ id: `dropdown-data-source-${fieldId}`, component: DataSourceEntry, ... },
{ id: `dropdown-placeholder-${fieldId}`, component: PlaceholderEntry, ... }
];
}
The conditional entry trap:
If you conditionally push entries and those entries use static IDs, you get collisions when multiple fields with the same configuration exist:
// ❌ Collision when two manual-source dropdowns are on the form
if (config.data_source === 'manual') {
entries.push({
id: 'dropdown-static-values', // Static — 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
Injecting into an existing group:
getGroups(field, editField) {
return (groups) => {
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 — after the 'key' entry
const keyEntryIndex = generalGroup.entries.findIndex(e => e.id === 'key');
if (keyEntryIndex !== -1) {
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, pattern |
'appearance' |
Style-related properties |
Creating a new group with the create-if-not-exists pattern:
function getOrCreateFormLogicsGroup(groups) {
let group = groups.find(g => g.id === 'form-logics');
if (!group) {
group = {
id: 'form-logics',
label: 'Form Logics',
entries: []
};
// Insert after 'general', before 'condition'
const generalIndex = groups.findIndex(g => g.id === 'general');
const insertIndex = generalIndex >= 0 ? generalIndex + 1 : 0;
groups.splice(insertIndex, 0, group);
}
return group;
}
I use a shared 'form-logics' group across seven different providers — disabled, readonly, required, FEEL binding, hide-if, persistent, and ticket auto-fill. Each provider calls getOrCreateFormLogicsGroup. The group is created exactly once (by whichever provider runs first) and all subsequent providers find it already there and add to it.
This is the cooperative pattern: providers don't know about each other but they all contribute to the same group through a shared convention.
Entry Component Props — What Gets Passed
When Form-JS renders your entry component, it passes a specific set of props. Knowing this prevents confusion about why some props arrive and others don't.
// Entry definition
{
id: `my-entry-${field.id}`,
component: MyEntryComponent,
field, // ← Your field reference
editField, // ← Must be explicitly included — not auto-passed
getValue: () => get(field, ['myProp']),
setValue: (v) => editField(field, 'myProp', v),
isEdited: (el) => !!el.myProp,
myCustomProp: 'hello' // ← Any extra props you add
}
// What the component receives:
function MyEntryComponent(props) {
const {
id, // ← The entry ID
element, // ← The field (NOT "field" — it's "element" in components)
getValue, // ← Your getValue function
setValue, // ← Your setValue function
myCustomProp, // ← 'hello'
// editField is NOT automatically passed — include it explicitly if needed
} = props;
const value = getValue(); // Called without arguments
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 that is not documented anywhere. It causes exactly one bug per developer before they learn it.
editField is not automatically passed to components. If your component needs to call editField directly — for complex multi-path updates where setValue isn't enough — include it explicitly:
{
id: `my-entry-${field.id}`,
component: MyEntryComponent,
field,
editField, // ✅ Explicit — the component can now access props.editField
getValue: () => get(field, ['myProp']),
setValue: (v) => editField(field, 'myProp', v)
}
The Complete Provider Template
// MyPropertiesProvider.js
import { get } from 'min-dash';
import {
isTextFieldEntryEdited,
isCheckboxEntryEdited,
isFeelEntryEdited
} from '@bpmn-io/properties-panel';
import { useService } from '@bpmn-io/form-js';
import {
TextFieldEntry,
CheckboxEntry
} from '@bpmn-io/properties-panel';
const SUPPORTED_TYPES = [
'textfield', 'textarea', 'number', 'dropdown',
'select', 'radio', 'checkbox', 'checklist',
'datetime', 'date', 'time'
];
const FORM_LOGICS_GROUP_ID = 'form-logics'; // ✅ Constant — prevents typos
export class MyPropertiesProvider {
constructor(propertiesPanel, eventBus) {
// ✅ Priority 500 for new entries alongside built-ins
// Use 1000 to remove/override built-in entries
propertiesPanel.registerProvider(this, 500);
this._eventBus = eventBus;
}
getGroups(field, editField) {
// ✅ Return a FUNCTION — the middleware pattern
return (groups) => {
// ✅ Early return for unsupported field types
if (!SUPPORTED_TYPES.includes(field.type)) {
return groups;
}
// ✅ Get or create shared group
const targetGroup = this._getOrCreateFormLogicsGroup(groups);
// ✅ Add entries with unique IDs
targetGroup.entries.push(
...createMyEntries(field, editField, this._eventBus)
);
return 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: []
};
const generalIndex = groups.findIndex(g => g.id === 'general');
const insertIndex = generalIndex >= 0 ? generalIndex + 1 : 0;
groups.splice(insertIndex, 0, group);
}
return group;
}
}
MyPropertiesProvider.$inject = ['propertiesPanel', 'eventBus'];
// ============================================================
// Entry factory
// ============================================================
function createMyEntries(field, editField, eventBus) {
const fieldId = field.id; // ✅ Always use field.id for uniqueness
const onChange = (configPath, key) => (value) => {
const current = get(field, [configPath], {});
editField(field, configPath, { ...current, [key]: value });
};
const getValue = (configPath, key) => () => {
return get(field, [configPath, key]);
};
return [
{
id: `my-text-prop-${fieldId}`, // ✅ Unique per field
component: MyTextEntry,
field,
editField, // ✅ Explicit if component needs it
getValue: getValue('my_config', 'textProp'),
setValue: onChange('my_config', 'textProp'),
isEdited: isTextFieldEntryEdited, // ✅ Built-in helper
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'),
isEdited: (element) => { // ✅ Custom for nested path
return get(element, ['my_config', 'boolProp']) === true;
}
},
{
id: `my-feel-prop-${fieldId}`,
component: MyFeelEntry,
field,
eventBus, // ✅ Required for FeelEntry
getValue: getValue('my_config', 'feelProp'),
setValue: onChange('my_config', 'feelProp'),
isEdited: isFeelEntryEdited // ✅ Handles expression + boolean
}
];
}
// ============================================================
// Entry components
// ============================================================
function MyTextEntry(props) {
const { element, id, getValue, setValue } = props; // ✅ "element" not "field"
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);
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 in this context
// Autocomplete won't work but entry still renders correctly
}
return FeelEntry({
debounce,
element,
eventBus, // ✅ Required — without this the FEEL editor breaks
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
// ============================================================
export default {
__init__: ['myPropertiesProvider'],
myPropertiesProvider: ['type', MyPropertiesProvider]
};
The Debugging Checklist
When entries don't appear or behave wrong, work through this list in order:
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? - Log all entry IDs with the debug provider — is there a collision?
Entry appears but doesn't save values:
- Does
setValuecalleditFieldwith the correct path? - Is
editFieldin scope? It's passed togetGroups, not the returned function — close over it. - Is the write path the same as the read path in
getValue?
Entry appears but value resets on every re-render:
- Is
getValuereading fromfield(the snapshot) or fromelement(the live prop)? Useelementin components.
Blue dot doesn't appear:
- Is
isEditedreturningtruewhen the property has been set? - Are you using the right helper (
isTextFieldEntryEdited,isFeelEntryEdited, etc.)? - Is the path in
isEditedthe same path where the value is stored?
Other providers' entries disappeared:
- Did
getGroupsreturn groups directly instead of a function? This is the most common cause.
Override isn't working — built-in entry still appears:
- Are you at priority
1000? - Are you filtering the right entry ID? Use the debug provider to confirm the actual ID.
- Are you filtering across ALL groups, not just one specific group?
The Tradeoffs
The middleware pattern is invisible in the API. There's no indication 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 completely different problem.
Priority ordering within the same level is undefined. Two providers at priority 500 execute in module registration order — the order of entries in additionalModules. This is an implicit dependency that isn't enforced or documented.
Entry IDs are a global namespace with no registry. There's no central place to register entry IDs and no conflict detection. You find collisions through symptoms (wrong behavior) not through errors. Use a consistent naming convention — I prefix with the field type and suffix with field.id — and document it for your team.
Your custom entry IDs become part of your system's contract. Once you name an entry 'logic-disabled' and filter for it in override providers, that name is load-bearing. Changing it means updating every provider that filters for it. Treat your custom entry IDs with the same care as public API names.
isDefaultVisible and isEdited must agree. If isDefaultVisible returns false and isEdited always returns false, the entry is permanently invisible. This is a silent failure with no error — the entry simply never appears and you have to know to look for this specific combination.
What Comes Next
You now have the full properties panel contract — the middleware pattern, priority system, override mechanics, isEdited, isDefaultVisible, and ID uniqueness. The next article goes deep on one pattern you'll use constantly inside providers.
Article 6 covers the toggle-or-FEEL pattern — properties like disabled, readonly, and required that can be either a static boolean or a dynamic FEEL expression. The dual-path getValue/setValue, why you clear one path when writing the other, and how FeelToggleSwitchEntry renders both modes in one control.
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)