DEV Community

Cover image for The Properties Panel Provider Contract: What the Official Docs Leave Out
Sam Abaasi
Sam Abaasi

Posted on

The Properties Panel Provider Contract: What the Official Docs Leave Out

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:

  • getGroups returns a function, not groups — the middleware pattern
  • The priority number in registerProvider has specific semantics that determine override behavior
  • isEdited has three different signatures and controls something most developers don't know exists
  • isDefaultVisible exists 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  };
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 isEdited entirely: 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)
}
Enter fullscreen mode Exit fullscreen mode

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;
  };
}
Enter fullscreen mode Exit fullscreen mode

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,
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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,
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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,
      // ...
    }
  ];
}
Enter fullscreen mode Exit fullscreen mode

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,
    // ...
  });
}
Enter fullscreen mode Exit fullscreen mode

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;
  };
}
Enter fullscreen mode Exit fullscreen mode

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;
  };
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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'
}
Enter fullscreen mode Exit fullscreen mode
// 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
  });
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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]
};
Enter fullscreen mode Exit fullscreen mode

The Debugging Checklist

When entries don't appear or behave wrong, work through this list:

Entry doesn't appear at all:

  1. Is getGroups returning a function? Not returning groups directly?
  2. Is the provider registered with propertiesPanel.registerProvider(this, priority)?
  3. Is the provider class in __init__ in your module definition?
  4. Does isDefaultVisible return true for this field type?
  5. Does the field type check at the top of your middleware pass?

Entry appears for one field but not another of the same type:

  1. Are entry IDs unique per field? Do they include field.id?
  2. Is there an ID collision with another provider's entry?

Entry appears but doesn't save values:

  1. Does setValue call editField with the correct path?
  2. Is editField available in the closure? (It's passed to getGroups, not getGroups's return function — close over it correctly)
  3. Is the path you're writing to the same path getValue reads from?

Entry appears but value resets on every re-render:

  1. Is getValue reading from field (the snapshot passed to getGroups) or from element (the live field passed to the component)? Use element in components.

Blue dot doesn't appear when it should:

  1. Is isEdited returning true when the property has a non-default value?
  2. Are you using the right isEdited helper for the entry type?

Other providers' entries disappeared:

  1. Did your getGroups return groups directly instead of a function?
  2. 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)