DEV Community

Cover image for The Form Logics Group: Building a Cross-Provider Panel Section
Sam Abaasi
Sam Abaasi

Posted on

The Form Logics Group: Building a Cross-Provider Panel Section

Part 19 of the series: "Extending bpmn-io Form-JS Beyond Its Limits"


By the time I had built seven properties panel providers — for disabled state, readonly state, required validation, hide-if conditions, FEEL bindings, persistent state, and ticket auto-fill — each one needed a home in the properties panel for its entries. The built-in groups didn't have a good fit for any of them.

The general group was already full of key, label, description, and field-specific configuration. Adding logic entries there would make it unwieldy — a form designer searching for the "Binding" entry shouldn't have to scroll past "Column Names" and "Max File Size" to find it.

The obvious solution: create a new group for logic-related entries. A "Form Logics" section that lives between general and condition. Clean, organized, purpose-built.

The problem: seven providers need to contribute to this group, and each provider runs independently. If each provider creates its own group, you get seven "Form Logics" sections stacked on top of each other. If only one provider creates the group and the others inject into it, you have a hard dependency between providers that was supposed to be decoupled.

The solution is the create-if-not-exists pattern: every provider checks for the group and creates it only if it doesn't already exist. The first provider to run creates it. Every subsequent provider finds it and adds to it. No coordination required. No central group registry. No dependencies between providers.


The Problem in Detail

Form-JS's properties panel has four built-in groups for most field types:

Properties Panel
├── General         (key, label, description, disabled, readonly)
├── Condition       (hide/show expressions)
├── Validation      (required, min/max, pattern)
└── Appearance      (placeholder, style)
Enter fullscreen mode Exit fullscreen mode

These groups exist without any configuration from you. They're created by Form-JS's built-in providers.

When you build custom providers, you have two choices for where to put your entries:

Choice 1: Inject into an existing group

const generalGroup = groups.find(g => g.id === 'general');
generalGroup.entries.push(...myLogicEntries);
Enter fullscreen mode Exit fullscreen mode

This works but creates a mess. After seven providers each push to general, the group has 30+ entries in no particular order. The form designer has to scroll through all of them to find anything. The built-in key/label/description entries are buried among custom logic entries.

Choice 2: Create a new group in each provider

// ❌ Each provider creates its own group
groups.push({
  id: 'disabled-logic',
  label: 'Disabled Logic',
  entries: [...]
});

// Another provider:
groups.push({
  id: 'readonly-logic',
  label: 'Readonly Logic',
  entries: [...]
});
Enter fullscreen mode Exit fullscreen mode

This creates seven separate group sections in the panel, each with one or two entries. Seven section headers for seven entries. The panel looks fragmented and the user has to mentally group related concepts that are visually separated.

The correct solution: One shared group that all providers contribute to. Created by the first provider that runs. Populated by all subsequent providers.


What I Tried First

My first attempt used a module-level variable to track whether the group had been created:

// ❌ Module-level flag — wrong approach
let formLogicsGroupCreated = false;

function getOrCreateFormLogicsGroup(groups) {
  if (!formLogicsGroupCreated) {
    const group = {
      id: 'form-logics',
      label: 'Form Logics',
      entries: []
    };
    groups.push(group);
    formLogicsGroupCreated = true;
    return group;
  }
  return groups.find(g => g.id === 'form-logics');
}
Enter fullscreen mode Exit fullscreen mode

This has the same problem as global variables from Article 18: it's shared across all form instances. If you have two forms on the same page, the flag is set to true by Form A's providers and Form B's providers never create the group. Form B's entries have no home.

My second attempt was to designate one provider as the "owner" of the group — it would always create the group, and other providers would find it:

// ❌ Designated owner approach — creates coupling
// DisabledPropertiesProvider is the "owner" — it always creates the group
// RequiredPropertiesProvider must know that DisabledPropertiesProvider runs first
propertiesPanel.registerProvider(disabledProvider, 1000); // Owner — creates group
propertiesPanel.registerProvider(requiredProvider, 999);  // Dependent — finds group
Enter fullscreen mode Exit fullscreen mode

This works but creates an invisible dependency. If the "owner" provider is removed or its priority changes, all dependent providers silently break — their entries have nowhere to go and disappear from the panel without error.

The correct approach has no owner, no flag, no dependency. Every provider is self-sufficient.


The Solution: Create-If-Not-Exists

The pattern is simple. Every provider calls the same helper function before adding entries. The helper creates the group if it doesn't exist; returns the existing group if it does:

function getOrCreateFormLogicsGroup(groups) {
  // ✅ Check if the group already exists
  let group = groups.find(g => g.id === 'form-logics');

  if (!group) {
    // ✅ Not found — create it
    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 the group (existing or newly created)
  return group;
}
Enter fullscreen mode Exit fullscreen mode

This function is idempotent — calling it multiple times with the same groups array always has the same result. The first call creates the group and inserts it. Every subsequent call finds the existing group and returns it without creating a duplicate.

Why is this safe?

The groups array is the same array passed through the middleware chain (from Article 5). Every provider receives this array, modifies it, and passes it to the next. When Provider A calls getOrCreateFormLogicsGroup(groups) and creates the group, that group is now in the array. When Provider B calls getOrCreateFormLogicsGroup(groups), groups.find(g => g.id === 'form-logics') returns the group Provider A created. Provider B doesn't create a duplicate.

Why findIndex + splice for insertion?

groups.push(group) would add the Form Logics section at the end of the panel, after condition, validation, and appearance. That's the wrong position — logic entries should be near the top, where form designers look first.

findIndex finds the position of general in the array, and splice inserts Form Logics immediately after it:

const generalIndex = groups.findIndex(g => g.id === 'general');
const insertIndex = generalIndex >= 0 ? generalIndex + 1 : 0;
groups.splice(insertIndex, 0, group);
Enter fullscreen mode Exit fullscreen mode

If general is at index 0, Form Logics is inserted at index 1. The panel becomes:

Properties Panel
├── General
├── Form Logics    ← New group, inserted here
├── Condition
├── Validation
└── Appearance
Enter fullscreen mode Exit fullscreen mode

The generalIndex >= 0 ? generalIndex + 1 : 0 fallback handles the case where general isn't found — which can happen for custom field types that don't have a general group. In that case, Form Logics is inserted at index 0 (the beginning).


All Seven Providers: The Cooperative Pattern

Each provider uses the same pattern: get or create the Form Logics group, then push entries into it. Here is each provider's contribution:

DisabledPropertiesProvider

// DisabledPropertiesProvider.js

export class DisabledPropertiesProvider {
  constructor(propertiesPanel, eventBus, injector) {
    propertiesPanel.registerProvider(this, 1000);
    this._eventBus = eventBus;
    this._injector = injector;
  }

  getGroups(element, editField) {
    return (groups) => {
      const SUPPORTED_TYPES = [
        'textfield', 'textarea', 'number', 'select',
        'radio', 'checkbox', 'checklist', 'taglist',
        'datetime', 'date', 'time', 'dropdown',
        'datagrid', 'button', 'gridfield', 'fileupload', 'newImageView'
      ];

      // ✅ Step 1: Remove default 'disabled' entry (from Article 6)
      groups.forEach(group => {
        if (group?.entries) {
          group.entries = group.entries.filter(e =>
            e.id !== 'disabled' && e.id !== 'logic-disabled'
          );
        }
      });

      if (!SUPPORTED_TYPES.includes(element.type)) return groups;

      // ✅ Step 2: Get or create the shared group
      const formLogicsGroup = getOrCreateFormLogicsGroup(groups);

      // ✅ Step 3: Add entries — unshift puts them 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'];
Enter fullscreen mode Exit fullscreen mode

unshift puts Disabled at the beginning of Form Logics. All other providers push — they add to the end. So Disabled is always the first entry in the Form Logics group.

ReadonlyPropertiesProvider

export class ReadonlyPropertiesProvider {
  constructor(propertiesPanel, eventBus, injector) {
    propertiesPanel.registerProvider(this, 1000);
    this._eventBus = eventBus;
    this._injector = injector;
  }

  getGroups(element, editField) {
    return (groups) => {
      groups.forEach(group => {
        if (group?.entries) {
          group.entries = group.entries.filter(e =>
            e.id !== 'readonly' && e.id !== 'logic-readonly'
          );
        }
      });

      if (!SUPPORTED_TYPES.includes(element.type)) return groups;

      const formLogicsGroup = getOrCreateFormLogicsGroup(groups);

      // ✅ unshift — Readonly appears before anything else except Disabled
      const readonlyEntries = ReadonlyEntry({
        field: element,
        editField,
        eventBus: this._eventBus,
        injector: this._injector
      });
      formLogicsGroup.entries.unshift(...readonlyEntries);

      return groups;
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

RequiredPropertiesProvider

export class RequiredPropertiesProvider {
  constructor(propertiesPanel, eventBus, injector) {
    propertiesPanel.registerProvider(this, 1000);
    this._eventBus = eventBus;
    this._injector = injector;
  }

  getGroups(element, editField) {
    return (groups) => {
      // ✅ Migrate legacy required at top level (from Article 6)
      if (SUPPORTED_TYPES.includes(element.type)) {
        if (typeof element.required === 'boolean') {
          const v = { ...(element.validate || {}) };
          if (element.required) v.required = true; else delete v.required;
          editField(element, ['validate'], v);
          editField(element, ['required'], undefined);
        }
      }

      groups.forEach(group => {
        if (group?.entries) {
          group.entries = group.entries.filter(e =>
            e.id !== 'required' && e.id !== 'logic-required'
          );
        }
      });

      if (!SUPPORTED_TYPES.includes(element.type)) return groups;

      const formLogicsGroup = getOrCreateFormLogicsGroup(groups);

      // ✅ unshift — Required appears at the top alongside Disabled and Readonly
      const requiredEntries = RequiredEntry({
        field: element,
        editField,
        eventBus: this._eventBus,
        injector: this._injector
      });
      formLogicsGroup.entries.unshift(...requiredEntries);

      return groups;
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

HideIfModule (FeelExpressionPropertiesProvider)

export class FeelExpressionPropertiesProvider {
  constructor(propertiesPanel, eventBus, injector, formEditor) {
    propertiesPanel.registerProvider(this, 500);
    this._eventBus = eventBus;
    this._injector = injector;
    this._formEditor = formEditor;
  }

  getGroups(element, editField) {
    return (groups) => {
      const SUPPORTED_TYPES = [
        'textfield', 'textarea', 'number', 'select',
        'radio', 'checkbox', 'checklist', 'taglist',
        'datetime', 'date', 'time', 'dropdown',
        'datagrid', 'button', 'gridfield', 'fileupload', 'newImageView'
      ];

      if (!SUPPORTED_TYPES.includes(element.type)) return groups;

      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);
      }

      // ✅ push — Binding appears after Required/Disabled/Readonly
      formLogicsGroup.entries.push(...BindingEntry({
        field: element,
        editField,
        eventBus: this._eventBus,
        injector: this._injector,
        formEditor: this._formEditor
      }));

      return groups;
    };
  }
}
FeelExpressionPropertiesProvider.$inject = [
  'propertiesPanel',
  'eventBus',
  'injector',
  'formEditor'
];
Enter fullscreen mode Exit fullscreen mode

This provider runs at priority 500 (not 1000) because it's adding new entries, not overriding defaults. It still uses the create-if-not-exists pattern — at priority 500, the group may or may not have been created by the priority-1000 providers depending on whether this is the first time getGroups is called for this field type.

PersistentPropertiesProvider

export class PersistentPropertiesProvider {
  constructor(propertiesPanel, eventBus, injector) {
    propertiesPanel.registerProvider(this, 1000);
    this._eventBus = eventBus;
    this._injector = injector;
  }

  getGroups(element, editField) {
    return (groups) => {
      const SUPPORTED_TYPES = [
        'textfield', 'textarea', 'number', 'select',
        'radio', 'checkbox', 'checklist', 'taglist',
        'datetime', 'date', 'time', 'dropdown', 'datagrid',
        'button', 'gridfield', 'fileupload', 'newImageView',
        'group', 'dynamiclist'
      ];

      if (!SUPPORTED_TYPES.includes(element.type)) return groups;

      const formLogicsGroup = getOrCreateFormLogicsGroup(groups);

      // ✅ push — Persistent appears after other entries
      const persistentEntries = PersistentEntry({
        field: element,
        editField,
        eventBus: this._eventBus,
        injector: this._injector
      });
      formLogicsGroup.entries.push(...persistentEntries);

      return groups;
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

HideIfPropertiesProvider

export class HideIfPropertiesProvider {
  constructor(propertiesPanel, eventBus) {
    propertiesPanel.registerProvider(this, 500);
    this._eventBus = eventBus;
  }

  getGroups(element, editField) {
    return (groups) => {
      if (!SUPPORTED_TYPES.includes(element.type)) return groups;

      const formLogicsGroup = getOrCreateFormLogicsGroup(groups);

      // ✅ push — Hide if appears after Required/Disabled/Readonly/Binding
      formLogicsGroup.entries.push(...HideIfEntry({
        field: element,
        editField,
        eventBus: this._eventBus
      }));

      return groups;
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

TicketAutoFillPropertiesProvider

export class TicketAutoFillPropertiesProvider {
  constructor(propertiesPanel, eventBus, injector) {
    propertiesPanel.registerProvider(this, 1000);
    this._eventBus = eventBus;
    this._injector = injector;
  }

  getGroups(element, editField) {
    return (groups) => {
      const SUPPORTED_TYPES = [
        'textfield', 'textarea', 'number', 'select',
        'radio', 'checkbox', 'checklist', 'taglist',
        'datetime', 'date', 'time', 'dropdown', 'datagrid',
        'button', 'gridfield', 'fileupload', 'newImageView'
      ];

      if (!SUPPORTED_TYPES.includes(element.type)) return groups;

      const formLogicsGroup = getOrCreateFormLogicsGroup(groups);

      // ✅ push — Ticket Auto Fill appears at the end of the group
      const ticketAutoFillEntries = createTicketAutoFillEntries(
        element,
        editField,
        this._eventBus,
        this._injector
      );
      formLogicsGroup.entries.push(...ticketAutoFillEntries);

      return groups;
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

The Resulting Panel Order

Here is the actual order entries appear in the Form Logics group for a textfield, showing which provider added each entry:

Form Logics
├── Required  fx  [●]    ← RequiredPropertiesProvider (unshift, priority 1000)
├── Read only  fx        ← ReadonlyPropertiesProvider (unshift, priority 1000)
├── Disabled  fx  [●]    ← DisabledPropertiesProvider (unshift, priority 1000)
├── Binding  fx          ← FeelExpressionPropertiesProvider (push, priority 500)
├── Show Latest Value fx ← ShowLatestValuePropertiesProvider (push, priority 1000)
├── Persistent  fx  [●]  ← PersistentPropertiesProvider (push, priority 1000)
├── Hide if  fx          ← HideIfPropertiesProvider (push, priority 500)
└── Ticket Auto Fill     ← TicketAutoFillPropertiesProvider (push, priority 1000)
Enter fullscreen mode Exit fullscreen mode

The ordering is determined by a combination of priority and unshift vs push:

  • Priority 1000 providers run before priority 500 providers (higher priority = later execution in the chain, so their entries are added to what priority 500 providers built — but wait, that's backwards)

Let me be precise about execution order and its effect on the entries array:

The middleware chain runs in ascending priority order. Lower priority providers run first, higher priority providers run later and see what lower priority providers added.

But Form-JS's built-in providers run at priority 500. Your override providers at priority 1000 run after them and see the built-in entries — which is why they can filter out 'disabled' and 'readonly'.

The three unshift providers (Required, Readonly, Disabled) all run at priority 1000. They run in the order they're registered (which depends on module registration order in additionalModules). Each one unshifts its entries to the beginning of the formLogicsGroup.entries array. The last one to run has its entries at the very beginning.

The push providers (Binding, ShowLatestValue, Persistent, HideIf, TicketAutoFill) add to the end in their execution order.

In practice, the visual order in the panel matches the order shown in the screenshot from Article 7 — Required, Read only, Disabled at the top (from unshift providers), followed by Binding, Show Latest Value, Persistent, Hide if, and Ticket Auto Fill (from push providers).


Priority Execution: Step by Step

For a textfield, here is the exact sequence when getGroups is called:

Priority 500 providers run first:
  1. Form-JS built-in provider
     → Creates 'general' group with key, label, description, disabled, readonly
     → Creates 'condition' group
     → Creates 'validation' group with required

  2. FeelExpressionPropertiesProvider (priority 500)
     → getOrCreateFormLogicsGroup → creates 'form-logics' (first time!)
     → push BindingEntry
     → groups: [...general, form-logics, condition, validation, appearance]
     → form-logics.entries: [Binding]

  3. HideIfPropertiesProvider (priority 500)
     → getOrCreateFormLogicsGroup → finds existing 'form-logics'
     → push HideIfEntry
     → form-logics.entries: [Binding, HideIf]

Priority 1000 providers run next:
  4. DisabledPropertiesProvider (priority 1000)
     → Remove 'disabled' from general
     → Remove 'logic-disabled' from form-logics (if present)
     → getOrCreateFormLogicsGroup → finds existing 'form-logics'
     → unshift DisabledEntry
     → form-logics.entries: [Disabled, Binding, HideIf]

  5. ReadonlyPropertiesProvider (priority 1000)
     → Remove 'readonly' from general
     → getOrCreateFormLogicsGroup → finds existing 'form-logics'
     → unshift ReadonlyEntry
     → form-logics.entries: [Readonly, Disabled, Binding, HideIf]

  6. RequiredPropertiesProvider (priority 1000)
     → Remove 'required' from validation
     → getOrCreateFormLogicsGroup → finds existing 'form-logics'
     → unshift RequiredEntry
     → form-logics.entries: [Required, Readonly, Disabled, Binding, HideIf]

  7. PersistentPropertiesProvider (priority 1000)
     → getOrCreateFormLogicsGroup → finds existing 'form-logics'
     → push PersistentEntry
     → form-logics.entries: [Required, Readonly, Disabled, Binding, HideIf, Persistent]

  8. ShowLatestValuePropertiesProvider (priority 1000)
     → getOrCreateFormLogicsGroup → finds existing 'form-logics'
     → push ShowLatestValueEntry
     → form-logics.entries: [Required, Readonly, Disabled, Binding, HideIf, Persistent, ShowLatestValue]

  9. TicketAutoFillPropertiesProvider (priority 1000)
     → getOrCreateFormLogicsGroup → finds existing 'form-logics'
     → push TicketAutoFillEntries
     → form-logics.entries: [Required, Readonly, Disabled, Binding, HideIf, Persistent, ShowLatestValue, TicketAutoFill]

Final panel:
  General (without disabled/readonly/required — removed by override providers)
  Form Logics (8 entries: Required, Readonly, Disabled, Binding, HideIf, Persistent, ShowLatestValue, TicketAutoFill)
  Condition
  Validation (without required)
  Appearance
Enter fullscreen mode Exit fullscreen mode

This matches the visual shown in the screenshots from Article 7. The sequence is deterministic because the DI system initializes providers in a fixed order and getOrCreateFormLogicsGroup is idempotent.


The Shared Constants File

Seven providers, all using the string 'form-logics'. If any one of them has a typo — 'form-logic', 'form_logics', 'formLogics' — that provider creates a second group silently. No error. The panel just has two sections, one with the right entries and one empty (or with the typo'd provider's entries).

The fix is a shared constants file:

// src/formjs/shared/constants.js

/**
 * Panel group IDs for the Form-JS extension system.
 *
 * RULES:
 * - All providers MUST use these constants, never string literals
 * - Add new custom group IDs here before using them in providers
 * - Never rename an ID without updating ALL providers that use it
 */
export const PANEL_GROUPS = {
  /**
   * The Form Logics group — home for all logic-related properties.
   * Appears between 'general' and 'condition'.
   * Created by whichever provider runs first (create-if-not-exists).
   *
   * Providers that contribute:
   * - DisabledPropertiesProvider
   * - ReadonlyPropertiesProvider
   * - RequiredPropertiesProvider
   * - FeelExpressionPropertiesProvider (Binding)
   * - HideIfPropertiesProvider
   * - PersistentPropertiesProvider
   * - ShowLatestValuePropertiesProvider
   * - TicketAutoFillPropertiesProvider
   */
  FORM_LOGICS: 'form-logics',

  // Built-in group IDs — for reference, not for creation
  GENERAL: 'general',
  CONDITION: 'condition',
  VALIDATION: 'validation',
  APPEARANCE: 'appearance',
} as const;

export type PanelGroupId = typeof PANEL_GROUPS[keyof typeof PANEL_GROUPS];
Enter fullscreen mode Exit fullscreen mode

And the getOrCreateFormLogicsGroup utility:

// src/formjs/shared/panelUtils.js

import { PANEL_GROUPS } from './constants';

/**
 * Get the Form Logics group from the groups array, creating it if it doesn't exist.
 *
 * This function is idempotent — calling it multiple times with the same
 * groups array always produces the same result.
 *
 * @param {Array} groups - The groups array from getGroups middleware
 * @returns {Object} The Form Logics group (existing or newly created)
 */
export function getOrCreateFormLogicsGroup(groups) {
  // ✅ Check if already exists
  let group = groups.find(g => g.id === PANEL_GROUPS.FORM_LOGICS);

  if (!group) {
    // ✅ Create with the constant — no string literals
    group = {
      id: PANEL_GROUPS.FORM_LOGICS,
      label: 'Form Logics',
      entries: []
    };

    // ✅ Insert after 'general'
    const generalIndex = groups.findIndex(g => g.id === PANEL_GROUPS.GENERAL);
    const insertIndex = generalIndex >= 0 ? generalIndex + 1 : 0;
    groups.splice(insertIndex, 0, group);
  }

  return group;
}
Enter fullscreen mode Exit fullscreen mode

Each provider imports and uses these:

// DisabledPropertiesProvider.js
import { getOrCreateFormLogicsGroup } from '@/formjs/shared/panelUtils';

// In getGroups:
const formLogicsGroup = getOrCreateFormLogicsGroup(groups);
formLogicsGroup.entries.unshift(...entries);
Enter fullscreen mode Exit fullscreen mode

With the constant and utility extracted, adding a new provider that contributes to Form Logics is straightforward:

// NewLogicPropertiesProvider.js
import { getOrCreateFormLogicsGroup } from '@/formjs/shared/panelUtils';

export class NewLogicPropertiesProvider {
  constructor(propertiesPanel, eventBus) {
    propertiesPanel.registerProvider(this, 500);
    this._eventBus = eventBus;
  }

  getGroups(element, editField) {
    return (groups) => {
      if (!SUPPORTED_TYPES.includes(element.type)) return groups;

      const formLogicsGroup = getOrCreateFormLogicsGroup(groups);
      formLogicsGroup.entries.push(...createNewLogicEntries(element, editField, this._eventBus));

      return groups;
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Three lines to participate in the cooperative group system. No knowledge of other providers. No registration in a central registry. No dependency on execution order (except for the unshift/push position choice).


When the Pattern Breaks

The create-if-not-exists pattern has one failure mode: if getGroups is called with a fresh groups array on each invocation (not the same array passed through the chain), the group created by Provider A wouldn't be visible to Provider B.

This doesn't happen in Form-JS's current implementation — the middleware chain passes the same groups array through all providers. But it's worth knowing: the pattern depends on the array being mutable and shared across provider invocations. If Form-JS ever deep-copies the groups array between provider calls, the pattern would produce duplicate groups.

The safest way to verify the pattern is working: use the debug provider from Article 5 to log the groups at the end of the chain:

// Debug provider — run at the very end (priority 9999)
export class DebugGroupsProvider {
  constructor(propertiesPanel) {
    propertiesPanel.registerProvider(this, 9999);
  }

  getGroups(field, editField) {
    return (groups) => {
      const formLogicsGroup = groups.find(g => g.id === 'form-logics');
      if (formLogicsGroup) {
        console.group('Form Logics entries:');
        formLogicsGroup.entries.forEach(e => console.log(e.id));
        console.groupEnd();
      } else {
        console.warn('Form Logics group not found!');
      }
      return groups;
    };
  }
}
DebugGroupsProvider.$inject = ['propertiesPanel'];
Enter fullscreen mode Exit fullscreen mode

Running this while developing confirms the group exists and shows its final entry order.


Extending the Pattern to Multiple Custom Groups

The same pattern works for any number of custom groups. If you needed a "Field Behavior" group for field-type-specific custom properties separate from the cross-field "Form Logics" group:

// constants.js — add the new group
export const PANEL_GROUPS = {
  FORM_LOGICS: 'form-logics',
  FIELD_BEHAVIOR: 'field-behavior', // ← new group
  // ...
};

// panelUtils.js — add a utility function
export function getOrCreateFieldBehaviorGroup(groups) {
  let group = groups.find(g => g.id === PANEL_GROUPS.FIELD_BEHAVIOR);

  if (!group) {
    group = {
      id: PANEL_GROUPS.FIELD_BEHAVIOR,
      label: 'Field Behavior',
      entries: []
    };

    // Insert after 'form-logics' if it exists, otherwise after 'general'
    const formLogicsIndex = groups.findIndex(g => g.id === PANEL_GROUPS.FORM_LOGICS);
    const generalIndex = groups.findIndex(g => g.id === PANEL_GROUPS.GENERAL);
    const insertAfter = formLogicsIndex >= 0 ? formLogicsIndex : generalIndex;
    const insertIndex = insertAfter >= 0 ? insertAfter + 1 : 0;
    groups.splice(insertIndex, 0, group);
  }

  return group;
}
Enter fullscreen mode Exit fullscreen mode

Any provider can contribute to field-behavior without knowing who else contributes. The constants file is the registry. The utility function is the interface.


The Tradeoffs

The pattern depends on mutating the shared groups array. This is how Form-JS's middleware chain works — it's not an accident. But it means your providers are stateful in a specific way: they assume the array is mutable and shared. If you ever try to use these providers in a context where the groups are immutable or copied between providers, the pattern fails silently.

Entry order within the group depends on registration and execution order. The unshift vs push choice determines relative position, but the absolute position depends on when the provider runs. If you add a new provider that unshifts its entries at priority 1000, it will appear before or after the existing unshift providers depending on module registration order in additionalModules. This is not explicitly controlled and can be surprising.

The group label is hardcoded to "Form Logics". If you want a different label — "Logic" instead of "Form Logics", or a localized string — the label is set in getOrCreateFormLogicsGroup. Whoever creates the group first sets the label. Changing it requires changing the utility function and re-testing that the first-creating provider runs before any user sees the panel. Alternatively, add a label parameter to the utility function and let the first provider that creates it set the label:

export function getOrCreateFormLogicsGroup(groups, label = 'Form Logics') {
  let group = groups.find(g => g.id === PANEL_GROUPS.FORM_LOGICS);
  if (!group) {
    group = { id: PANEL_GROUPS.FORM_LOGICS, label, entries: [] };
    // ...
  }
  return group;
}
Enter fullscreen mode Exit fullscreen mode

What Comes Next

The Form Logics group is the visible organization of the logic properties that the evaluators (from Article 8) read and enforce at runtime. The final pieces of the architecture are the classes that bring everything together: CustomForm and CustomFormEditor, which bootstrap all modules, initialize the file store, and expose the upload API.

Article 20 covers subclassing Form and FormEditor — why you need to subclass rather than wrap, what importSchema override does that you can't do in a module, and the complete module lists that make CustomForm and CustomFormEditor ready for production.


This is Part 19 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 architecture design-patterns javascript devex

Top comments (0)