惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

GbyAI
GbyAI
博客园_首页
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
阮一峰的网络日志
阮一峰的网络日志
酷 壳 – CoolShell
酷 壳 – CoolShell
博客园 - 司徒正美
V
V2EX
Cloudbric
Cloudbric
Hugging Face - Blog
Hugging Face - Blog
腾讯CDC
量子位
博客园 - 三生石上(FineUI控件)
博客园 - 叶小钗
K
Kaspersky official blog
博客园 - 【当耐特】
T
Tenable Blog
L
Lohrmann on Cybersecurity
The Cloudflare Blog
S
Schneier on Security
A
Arctic Wolf
Latest news
Latest news
C
Cyber Attacks, Cyber Crime and Cyber Security
罗磊的独立博客
T
The Exploit Database - CXSecurity.com
Cisco Talos Blog
Cisco Talos Blog
小众软件
小众软件
P
Privacy & Cybersecurity Law Blog
WordPress大学
WordPress大学
Simon Willison's Weblog
Simon Willison's Weblog
雷峰网
雷峰网
NISL@THU
NISL@THU
人人都是产品经理
人人都是产品经理
月光博客
月光博客
J
Java Code Geeks
V
Visual Studio Blog
S
Security Affairs
博客园 - Franky
T
Tailwind CSS Blog
Apple Machine Learning Research
Apple Machine Learning Research
H
Heimdal Security Blog
有赞技术团队
有赞技术团队
V2EX - 技术
V2EX - 技术
AWS News Blog
AWS News Blog
G
GRAHAM CLULEY
T
Troy Hunt's Blog
SecWiki News
SecWiki News
Spread Privacy
Spread Privacy
宝玉的分享
宝玉的分享
www.infosecurity-magazine.com
www.infosecurity-magazine.com
博客园 - 聂微东

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
The Properties Panel Provider Contract: What the Official Docs Leave Out
Sam Abaasi · 2026-04-28 · via DEV Community

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