DEV Community

Cover image for The Conditional Options Algorithm: Priority Mode vs Merge Mode
Sam Abaasi
Sam Abaasi

Posted on

The Conditional Options Algorithm: Priority Mode vs Merge Mode

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


A form has a "Resolution" dropdown with 20 options. When the category is "Hardware", only 5 of those options are relevant. When the category is "Software", 8 different options apply. When the category is "Network", yet another 6 options.

The naive solution is three separate dropdowns, one per category, and a hide/show condition on each. This works but it's a maintenance problem — each dropdown needs its own configuration, validation, and schema key. Changing the options for Hardware means finding and editing the Hardware-specific dropdown. If you add a new category, you add another dropdown.

The better solution is one dropdown with conditional rules: a set of FEEL expressions that filter its options at runtime based on the form's current data. The form designer defines 20 options once and configures rules that say "when category is Hardware, show options 1, 3, 7, 12, 15."

This article documents the algorithm that makes this work — how FEEL conditions are evaluated against form data, how matching options are collected, and how two distinct modes (priority and merge) handle different filtering scenarios.


The Problem

A conditional options system needs to answer one question at runtime:

Given the current form data and a set of rules, which options should be visible?

Each rule has:

  • A FEEL condition (= category = "Hardware")
  • A list of option values to show when the condition is true (["option_1", "option_3", "option_7"])

The algorithm evaluates each rule's condition. The rules that evaluate to true determine which options to show.

But there's an immediate design question: what happens when multiple rules match?

If category is "Hardware" and priority is "High", you might have:

  • Rule 1: = category = "Hardware" → show options 1, 3, 7
  • Rule 2: = priority = "High" → show options 7, 12, 15

Both rules match. The user selected both Hardware and High priority. What should the dropdown show?

Two semantics are possible:

Priority mode: The first rule in the list wins. Show options 1, 3, 7. Ignore Rule 2. Use this when rules are mutually exclusive — when only one rule should ever apply at a time.

Merge mode: Combine all matching rules' options. Show options 1, 3, 7, 12, 15 (the union). Use this when rules are additive — when a user with multiple matching conditions should see the superset of all applicable options.

Both are valid. Which is correct depends on what the form is modeling. A role-based access system where a user might have multiple roles needs merge mode. A category-based classifier where only one category applies at a time needs priority mode. I built support for both and let the form designer choose via a toggle in the properties panel.


The Algorithm in Pseudocode

Before the code, the algorithm in plain language:

FUNCTION applyConditionalRules(allOptions, rules, formData, fieldKey, priorityMode):

  IF rules is empty OR rules is null:
    RETURN allOptions  // No rules → show everything

  matchingRules ← []
  visibleOptionsSet ← empty set

  FOR EACH rule IN rules:
    IF rule.condition is empty:
      CONTINUE  // Skip rules with no condition

    result ← evaluateFEEL(rule.condition, formData)

    IF result is truthy:
      matchingRules.ADD(rule)

      IF priorityMode:
        // First match wins — collect this rule's options and stop
        FOR EACH optionValue IN rule.visible_options:
          visibleOptionsSet.ADD(optionValue)
        BREAK  // Stop evaluating further rules

      ELSE:
        // Merge mode — collect all matching rules' options
        FOR EACH optionValue IN rule.visible_options:
          visibleOptionsSet.ADD(optionValue)
        // Continue evaluating remaining rules

  IF matchingRules is empty:
    // No rules matched → fallback: show all options
    RETURN allOptions

  IF visibleOptionsSet is empty:
    // Rules matched but specified no options → show nothing
    RETURN []

  // Filter allOptions to only those in visibleOptionsSet
  RETURN allOptions.FILTER(option => visibleOptionsSet.CONTAINS(option.value))
Enter fullscreen mode Exit fullscreen mode

The fallback behavior — "no rules match → show all options" — is deliberate. A form that hasn't had its condition field filled in yet shouldn't show an empty dropdown. It shows everything until a condition narrows it down. This matches user expectations: the dropdown starts fully populated and filters as the form is filled.


What I Tried First

My first attempt was to filter options inside the dropdown renderer component's render cycle:

// ❌ Attempt 1: Filter in the renderer
export function FormStaticSelectDropdown({ options, formData, conditionalRules, fieldKey }) {
  // Filter on every render
  const visibleOptions = conditionalRules?.length > 0
    ? applyConditionalRules(options, conditionalRules, formData)
    : options;

  return <Select options={visibleOptions} />;
}
Enter fullscreen mode Exit fullscreen mode

This worked visually — the dropdown showed the right options. The problem appeared when I tried to validate the field's value. After filtering, the currently selected value might not be in the visible options. I needed to clear it. But clearing it inside the renderer causes a state update during render — React's cardinal sin. The component tried to call onChange during render, which triggered a re-render, which triggered the condition evaluation again, which tried to clear again, and so on.

The second problem: other evaluators couldn't see the filtered state. The RequiredEvaluator needed to know whether the dropdown currently had valid options. If all options were filtered out, the field was effectively unusable. But RequiredEvaluator had no way to read what FormStaticSelectDropdown was internally filtering to.

The correct approach: filter results must live in the form data, not in component state. Other evaluators can read form data. The BindingEvaluator (which runs on every changed event) is the right place to evaluate conditional rules and write results into form data.


The Storage Strategy: Writing to Form Data

The algorithm runs in BindingEvaluator and writes two values into form data for each field that has conditional rules:

// Written by BindingEvaluator for each field with conditional rules:
formData[`${fieldKey}_conditional_visible_options`] = ['option_1', 'option_3', 'option_7'];
// The array of option VALUES that should be visible

formData[`${fieldKey}_conditional_matched`] = true;
// Whether any rule matched at all
Enter fullscreen mode Exit fullscreen mode

The dropdown renderer reads these values from formData:

// Read by FormStaticSelectDropdown:
const conditionalVisibleOptions = formData[`${fieldKey}_conditional_visible_options`];
const conditionalMatched = formData[`${fieldKey}_conditional_matched`];

const visibleOptions = (conditionalMatched && conditionalVisibleOptions)
  ? allOptions.filter(opt => conditionalVisibleOptions.includes(opt.value))
  : allOptions; // No rules matched → show all
Enter fullscreen mode Exit fullscreen mode

Why form data, not component state?

Form data is the shared state bus of Form-JS. Every evaluator, every renderer, every validator reads from and writes to the same form._state.data object. Writing conditional visible options to form data makes them available to:

  • RequiredEvaluator — check if the current value is still valid after filtering
  • BindingEvaluator — use in other field's FEEL expressions (= category_conditional_matched = true)
  • Any custom evaluator you build — they all read from form data
  • The form's validate() — errors can reference form data values

Component state is isolated. Only the component that owns it can read it. For a feature that affects validation, other fields, and evaluators, component state is the wrong storage.


The BindingEvaluator Integration

BindingEvaluator already iterates all form components on every changed event. Conditional rules evaluation is integrated into this existing cycle rather than adding a new evaluator:

// In BindingEvaluator.evaluateAll():

evaluateAll() {
  if (!this._form?._state?.schema) return;

  this._evaluating = true;

  try {
    const schema = this._form._state.schema;
    const data = this._form._state.data || {};
    const components = this._getAllComponents(schema.components || []);

    const updates = {};
    let hasChanges = false;

    // ✅ Regular field bindings (feelExpression)
    const componentsWithExpressions = components.filter(c => c.feelExpression && c.key);

    // ✅ Components with conditional rules (dropdown, radio, checklist)
    const dropdownsWithConditionalRules = components.filter(c =>
      c.type === 'dropdown' &&
      c.dropdown_config?.conditional_rules &&
      Array.isArray(c.dropdown_config.conditional_rules) &&
      c.dropdown_config.conditional_rules.length > 0
    );

    const radiosWithConditionalRules = components.filter(c =>
      c.type === 'radio' &&
      c.radio_config?.conditional_rules &&
      Array.isArray(c.radio_config.conditional_rules) &&
      c.radio_config.conditional_rules.length > 0
    );

    const checklistsWithConditionalRules = components.filter(c =>
      c.type === 'checklist' &&
      c.checklist_config?.conditional_rules &&
      Array.isArray(c.checklist_config.conditional_rules) &&
      c.checklist_config.conditional_rules.length > 0
    );

    // Evaluate regular bindings
    componentsWithExpressions.forEach(component => {
      try {
        const result = this.evaluateFeel(component.feelExpression, data);
        if (result !== undefined && data[component.key] !== result) {
          updates[component.key] = result;
          hasChanges = true;
        }
      } catch (err) {
        console.error(`[BindingEvaluator] Error evaluating ${component.key}:`, err.message);
      }
    });

    // ✅ Evaluate conditional rules for each field type
    dropdownsWithConditionalRules.forEach(component => {
      this._evaluateConditionalRulesForComponent(
        component,
        component.dropdown_config,
        component.dropdown_config.static_values || [],
        data,
        updates
      );
      hasChanges = true;
    });

    radiosWithConditionalRules.forEach(component => {
      this._evaluateConditionalRulesForComponent(
        component,
        component.radio_config,
        component.values || [],
        data,
        updates
      );
      hasChanges = true;
    });

    checklistsWithConditionalRules.forEach(component => {
      this._evaluateConditionalRulesForComponent(
        component,
        component.checklist_config,
        component.values || [],
        data,
        updates
      );
      hasChanges = true;
    });

    if (hasChanges) {
      this._form._setState({ data: { ...data, ...updates } });
    }
  } finally {
    this._evaluating = false;
  }
}
Enter fullscreen mode Exit fullscreen mode

The _evaluateConditionalRulesForComponent method is generic — it works for dropdown, radio, and checklist because they all have the same conditional rules structure. The only difference is where the config lives (dropdown_config vs radio_config vs checklist_config) and where the static options live (static_values vs values). Both are passed as parameters:

_evaluateConditionalRulesForComponent(component, config, staticOptions, data, updates) {
  const componentKey = component.key;
  const conditionalRules = config.conditional_rules || [];
  const priorityMode = config.priority_mode !== false; // Default: priority mode

  // ============================================================
  // The Algorithm — matches the pseudocode above
  // ============================================================
  const matchingRules = [];
  const visibleOptionsSet = new Set();

  for (let index = 0; index < conditionalRules.length; index++) {
    const rule = conditionalRules[index];

    // Skip rules without conditions
    if (!rule.condition || !rule.condition.trim()) {
      continue;
    }

    try {
      // Evaluate the FEEL condition
      const result = this.evaluateFeel(rule.condition, data);
      const matches = !!result; // Coerce to boolean

      if (matches) {
        matchingRules.push(rule);

        // Add this rule's visible options to the set
        (rule.visible_options || []).forEach(opt => visibleOptionsSet.add(opt));

        // ✅ Priority mode: first match wins — break immediately
        if (priorityMode) {
          break;
        }
        // ✅ Merge mode: continue to collect all matching rules
      }
    } catch (err) {
      console.error(
        `[BindingEvaluator] Error evaluating rule ${index + 1} for ${componentKey}:`,
        err.message
      );
    }
  }

  // ============================================================
  // Store results in form data
  // ============================================================
  const conditionalVisibleOptionsKey = `${componentKey}_conditional_visible_options`;
  const conditionalMatchedKey = `${componentKey}_conditional_matched`;

  // Convert Set to Array for storage
  const visibleOptionsArray = Array.from(visibleOptionsSet);
  const hasMatches = matchingRules.length > 0;

  // ✅ Only update if values changed — prevent unnecessary re-renders
  const currentVisibleOptions = data[conditionalVisibleOptionsKey];
  const currentMatched = data[conditionalMatchedKey];

  const visibleOptionsChanged =
    JSON.stringify(currentVisibleOptions) !== JSON.stringify(visibleOptionsArray);
  const matchedChanged = currentMatched !== hasMatches;

  if (visibleOptionsChanged || matchedChanged) {
    updates[conditionalVisibleOptionsKey] = visibleOptionsArray;
    updates[conditionalMatchedKey] = hasMatches;
  }
}
Enter fullscreen mode Exit fullscreen mode

The change detection — comparing current and new values before writing — is essential. Without it, every changed event would write to form data even when the conditional visible options haven't changed. Writing to form data fires another changed event. Without change detection, you get the same infinite loop problem from Article 10.


The Runtime Renderer Integration

FormStaticSelectDropdown reads the conditional options from formData and applies them before rendering:

// FormStaticSelectDropdown.tsx

interface FormStaticSelectDropdownProps {
  formData: Record<string, any>;
  options: Array<{ label: string; value: string }>;
  filterOptions?: any[]; // conditional_rules from schema (for reference)
  priorityMode?: boolean;
  mode: 'single' | 'multi';
  defaults: string | string[] | null;
  onChangeValue: (value: any) => void;
  inputId: string;
  disabled?: boolean;
  readOnly?: boolean;
  searchable?: boolean;
}

export function FormStaticSelectDropdown({
  formData,
  options,
  filterOptions,
  priorityMode = true,
  mode,
  defaults,
  onChangeValue,
  inputId,
  disabled,
  readOnly,
  searchable
}: FormStaticSelectDropdownProps) {

  // ✅ Extract field key from inputId
  // inputId format: "fjs-form-{formId}-{fieldId}"
  // We need the field's key to look up conditional options in formData
  // The component receives formData and the full options list
  // The conditional filtering has already been computed by BindingEvaluator

  // ✅ Read conditional results from formData
  // These were written by BindingEvaluator._evaluateConditionalRulesForComponent
  // We don't know the field key directly, but the inputId gives us the field ID
  // In practice, the field's key is passed separately or derived from props
  const [fieldKey, setFieldKey] = useState<string | null>(null);

  // Alternative: pass fieldKey as a prop (cleaner)
  // For this example, we show the formData lookup pattern

  const conditionalVisibleOptions = fieldKey
    ? formData[`${fieldKey}_conditional_visible_options`]
    : null;
  const conditionalMatched = fieldKey
    ? formData[`${fieldKey}_conditional_matched`]
    : false;

  // ✅ Compute the effective options
  const effectiveOptions = useMemo(() => {
    // No conditional rules — show all options
    if (!conditionalMatched || !conditionalVisibleOptions) {
      return options;
    }

    // Rules matched — filter to visible options only
    const visibleSet = new Set(conditionalVisibleOptions);
    return options.filter(opt => visibleSet.has(opt.value));
  }, [options, conditionalVisibleOptions, conditionalMatched]);

  // ✅ Handle currently selected value that's no longer in visible options
  useEffect(() => {
    if (!conditionalMatched) return; // No filtering active

    if (mode === 'single' && defaults) {
      const isCurrentValueVisible = effectiveOptions.some(
        opt => opt.value === defaults
      );
      if (!isCurrentValueVisible) {
        // ✅ Clear the selection — selected value is no longer available
        onChangeValue(null);
      }
    } else if (mode === 'multi' && Array.isArray(defaults) && defaults.length > 0) {
      const visibleSet = new Set(effectiveOptions.map(opt => opt.value));
      const filteredDefaults = defaults.filter(v => visibleSet.has(v));

      if (filteredDefaults.length !== defaults.length) {
        // ✅ Some selected values are no longer visible — remove them
        onChangeValue(filteredDefaults);
      }
    }
  }, [effectiveOptions, conditionalMatched]);

  // Render with effectiveOptions instead of options
  return (
    <div className="form-static-select-dropdown">
      {/* ... dropdown UI using effectiveOptions ... */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The value clearing in useEffect is the part that's tricky to get right. When category changes from "Hardware" to "Software", the conditional options update. If the user had selected an option that was visible under "Hardware" but not under "Software", that value must be cleared. Doing this in useEffect (not in the render body) is safe — effects run after render, not during it.


The applyConditionalRules Utility Function

The algorithm is also exposed as a standalone utility function used by the custom radio and checklist renderers directly, rather than going through form data:

// utils/conditionalRules.js

/**
 * Apply conditional rules to filter options.
 *
 * @param {Array} allOptions - Full options array [{label, value}]
 * @param {Array} rules - Conditional rules from schema
 * @param {Object} formData - Current form data
 * @param {string} fieldKey - Field's schema key
 * @param {boolean} priorityMode - true = first match wins, false = merge all
 * @returns {Array} Filtered options array
 */
export function applyConditionalRules(
  allOptions,
  rules,
  formData,
  fieldKey,
  priorityMode = true
) {
  // No rules — return everything
  if (!rules || !Array.isArray(rules) || rules.length === 0) {
    return allOptions;
  }

  const matchingRules = [];
  const visibleOptionsSet = new Set();

  for (const rule of rules) {
    // Skip rules without conditions
    if (!rule.condition || !rule.condition.trim()) {
      continue;
    }

    try {
      // Evaluate the condition
      // Note: this requires a synchronous evaluator
      // The FEEL evaluation pipeline from Article 3 handles this
      const result = evaluateFEELExpression(rule.condition, formData);
      const matches = !!result;

      if (matches) {
        matchingRules.push(rule);
        (rule.visible_options || []).forEach(opt => visibleOptionsSet.add(opt));

        // Priority mode: stop at first match
        if (priorityMode) break;
      }
    } catch (err) {
      console.error(`[applyConditionalRules] Error evaluating rule for ${fieldKey}:`, err);
    }
  }

  // No rules matched → fallback: show all options
  if (matchingRules.length === 0) {
    return allOptions;
  }

  // Rules matched but no visible options specified → show nothing
  if (visibleOptionsSet.size === 0) {
    return [];
  }

  // Filter to visible options, preserving original order
  return allOptions.filter(opt => visibleOptionsSet.has(opt.value));
}

/**
 * Clean a selected value after options change.
 * Removes values that are no longer in the visible options.
 *
 * @param {string|string[]} currentValue
 * @param {Array} visibleOptions
 * @param {boolean} isMulti
 * @returns {string|string[]|null} Cleaned value
 */
export function cleanSelectedValues(currentValue, visibleOptions, isMulti) {
  if (!currentValue) return currentValue;

  const visibleValues = new Set(visibleOptions.map(opt => opt.value));

  if (isMulti) {
    if (!Array.isArray(currentValue)) return [];
    const cleaned = currentValue.filter(v => visibleValues.has(v));
    return cleaned.length === currentValue.length ? currentValue : cleaned;
  } else {
    return visibleValues.has(currentValue) ? currentValue : null;
  }
}
Enter fullscreen mode Exit fullscreen mode

The custom radio and checklist renderers use this function directly. Because these renderers are Preact components (not React, unlike the dropdown), they don't need the React bridge from Article 9. They can call applyConditionalRules synchronously in their render function:

// In the custom radio renderer
const RadioWrapper = (props) => {
  const [formData, setFormData] = useState(this._formData);

  // Listen to form data changes
  useEffect(() => {
    const handleChange = (event) => {
      const newData = event.data || {};
      if (Object.keys(newData).length > 0) setFormData(newData);
    };
    this._eventBus.on('changed', handleChange);
    return () => this._eventBus.off('changed', handleChange);
  }, []);

  // ✅ Apply conditional rules synchronously in render
  let filteredValues = props.field.values || [];
  const config = props.field.radio_config || {};

  if (config.conditional_rules && config.conditional_rules.length > 0) {
    const priorityMode = config.priority_mode !== false;

    filteredValues = applyConditionalRules(
      props.field.values || [],
      config.conditional_rules,
      formData,
      props.field.key,
      priorityMode
    );

    // ✅ Clear value if it's no longer in visible options
    if (props.value && filteredValues.length > 0) {
      const cleanedValue = cleanSelectedValues(props.value, filteredValues, false);
      if (cleanedValue !== props.value) {
        setTimeout(() => {
          props.onChange({ field: props.field, value: undefined });
        }, 0);
      }
    }
  }

  const modifiedField = { ...props.field, values: filteredValues };
  return Radio({ ...props, field: modifiedField, formData });
};
Enter fullscreen mode Exit fullscreen mode

The setTimeout(() => onChange(...), 0) pattern for clearing the value is necessary because you cannot call onChange during render — it would update state synchronously and trigger a re-render in the middle of the current render. The zero-timeout defers the call to after the current render completes.


Priority Mode vs Merge Mode: When to Use Each

Priority mode is for mutually exclusive conditions. Examples:

  • A dropdown of "Resolution Actions" that changes completely based on ticket category. Hardware tickets get hardware resolutions, software tickets get software resolutions. A ticket can only have one category.
  • A dropdown of "Approval Levels" that changes based on amount ranges. Amounts < 1000 get one set of levels, amounts 1000-10000 get another. A ticket has exactly one amount.
  • A dropdown of "Assignable Teams" filtered by region. A ticket belongs to one region.

In all these cases, at most one rule should match at any time. If multiple rules somehow match (misconfiguration), you want predictable behavior: the first rule wins. The form designer controls rule order through the drag-up/drag-down controls in the properties panel.

Merge mode is for additive conditions. Examples:

  • A dropdown of permissions available to the current user. The user might have role "Manager" AND department "Finance". Manager role unlocks some permissions. Finance department unlocks others. The user should see the union.
  • A dropdown of workflows available for a ticket. A ticket might match multiple routing criteria. Each matching criterion adds its applicable workflows.
  • A dropdown of templates the user can apply. Different templates are available based on different ticket properties, and multiple properties might apply simultaneously.

In all these cases, multiple rules matching simultaneously is expected and desired. The merged result is the correct answer.

The toggle in the properties panel (the Priority Mode checkbox in ConditionalRulesGroup) lets the form designer choose which semantic applies to their use case.


A Concrete Example: Walking Through the Algorithm

Setup:

  • Dropdown: "Resolution Action" with 6 options: quick_fix, patch, upgrade, rollback, escalate, close
  • Rule 1 (priority): = category = "Hardware" → visible: [quick_fix, upgrade, escalate]
  • Rule 2 (priority): = category = "Software" → visible: [patch, upgrade, rollback, escalate]
  • Rule 3 (priority): = severity = "Critical" → visible: [escalate, close]
  • Mode: Priority (first match wins)

Scenario A: category = "Hardware", severity = "Normal"

Rule 1: = category = "Hardware" → true ✅ → add quick_fix, upgrade, escalate
  BREAK (priority mode)
Rule 2: not evaluated
Rule 3: not evaluated

matchingRules = [Rule 1]
visibleOptionsSet = {quick_fix, upgrade, escalate}
Result: [quick_fix, upgrade, escalate]
Enter fullscreen mode Exit fullscreen mode

Scenario B: category = "Software", severity = "Critical"

Rule 1: = category = "Hardware" → false ❌
Rule 2: = category = "Software" → true ✅ → add patch, upgrade, rollback, escalate
  BREAK (priority mode)
Rule 3: not evaluated

matchingRules = [Rule 2]
visibleOptionsSet = {patch, upgrade, rollback, escalate}
Result: [patch, upgrade, rollback, escalate]
Enter fullscreen mode Exit fullscreen mode

Note: severity = "Critical" doesn't matter in priority mode — Rule 2 matched first and stopped evaluation. The designer intended "when it's Software, show these options regardless of severity." Rule 3 never fires.

Scenario C: category = "" (not filled in yet), severity = "Normal"

Rule 1: = category = "Hardware" → false ❌
Rule 2: = category = "Software" → false ❌
Rule 3: = severity = "Critical" → false ❌

matchingRules = []
Result: all 6 options (fallback — no rules matched)
Enter fullscreen mode Exit fullscreen mode

The dropdown shows everything until the user selects a category. This is the expected behavior — don't filter until there's something to filter by.

Now with Merge mode and the same rules:

Scenario D: category = "Hardware", severity = "Critical"

Rule 1: = category = "Hardware" → true ✅ → add quick_fix, upgrade, escalate
  Continue (merge mode)
Rule 2: = category = "Software" → false ❌
Rule 3: = severity = "Critical" → true ✅ → add escalate, close
  Continue (merge mode)

matchingRules = [Rule 1, Rule 3]
visibleOptionsSet = {quick_fix, upgrade, escalate, close}
Result: [quick_fix, upgrade, escalate, close]
Enter fullscreen mode Exit fullscreen mode

Hardware options plus Critical-severity options, merged. escalate appears only once (it's a Set). This is the correct behavior for a permission system where "Hardware Admin" + "Critical Ticket" unlocks a combined set of actions.


The Properties Panel Toggle

The mode is stored in the field's config and toggled in the ConditionalRulesGroup component:

// In ConditionalRulesGroup
const priorityMode = config.priority_mode !== false; // Default: true (priority)

// The toggle
jsx("input", {
  type: "checkbox",
  checked: priorityMode,
  onChange: (e) => {
    saveConfig({ priority_mode: e.target.checked });
    notify.success(
      e.target.checked
        ? 'Priority mode: first matching rule wins'
        : 'Merge mode: combine all matching rules',
      3000
    );
  },
  class: styles.toggleCheckbox
})
Enter fullscreen mode Exit fullscreen mode

The config.priority_mode !== false default ensures that existing fields without the priority_mode key behave as if priority mode is on. Falsy-checking would make undefined (no value set) behave as merge mode, which is the less safe default. An existing field with no priority_mode key gets priority mode behavior — the more predictable of the two.


The Tradeoffs

Form data keys are generated from field keys. The keys ${fieldKey}_conditional_visible_options and ${fieldKey}_conditional_matched are written to form data. If a field's key is resolution_action, the form data will contain resolution_action_conditional_visible_options. This pollutes the form data with implementation details. If the form's backend system reads all form data fields, it will see these synthetic keys and need to filter them out.

The applyConditionalRules utility requires a synchronous evaluator. For the radio/checklist renderers that call it directly in render, the FEEL evaluation must be synchronous. Async FEEL evaluation (which could support remote context lookups) cannot be used in render-time filtering. The BindingEvaluator approach (pre-computing and storing results) is the async-safe alternative, but the direct renderer approach is simpler for Preact components.

Priority mode's "first match" is order-dependent. The order of rules in the conditional_rules array determines which rule wins in priority mode. The form designer controls this through the drag-up/drag-down controls, but the dependency on order is non-obvious. A form designer who doesn't understand priority mode might be confused about why changing rule order changes which options appear.

The change detection uses JSON.stringify. For large option arrays (20+ options), JSON.stringify on every changed event adds up. A more performant approach would be a custom array equality check that short-circuits on length difference. I used JSON.stringify because it handles nested objects correctly without additional code.


What Comes Next

Conditional options filtering is built on top of the FEEL evaluation pipeline from Articles 3 and 4. The same pipeline handles binding expressions, hide conditions, required conditions, and now option filtering. Building a sixth use case — a ShowLatestValueEvaluator that controls whether a field shows its historical value from a previous form submission — follows the exact same evaluator pattern.

Article 14 covers the SearchableSelect component that makes the properties panel usable when option lists are large — the component used in every level of the cascading configuration UI from Article 12 and in the conditional rules editor itself.


This is Part 13 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 feel conditional-rendering algorithms form-builder javascript devex

Top comments (0)