obsidian-formfire/docs/plans/2026-02-13-conditional-logic-implementation.md
tolvitty ad9a2cfbb0 docs: add conditional logic implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 20:18:48 +01:00

25 KiB
Raw Blame History

Conditional Logic Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add per-field conditional visibility with AND/OR logic so fields can be dynamically shown/hidden based on other fields' values.

Architecture: Rule-per-field approach — each FormField gets an optional conditions object with a logic combinator and an array of rules. A new condition engine evaluates visibility reactively. Hidden fields are excluded from validation and submission.

Tech Stack: TypeScript, Obsidian API, HTML5 DOM


Task 1: Add Condition Types to types.ts

Files:

  • Modify: src/types.ts:1-37

Step 1: Add the new types after the FieldType definition (line 18)

After the FieldType type alias and before the FormField interface, add:

// ---------------------------------------------------------------------------
// Conditional Logic
// ---------------------------------------------------------------------------

export type ConditionOperator =
  | 'equals'
  | 'not_equals'
  | 'contains'
  | 'not_contains'
  | 'is_empty'
  | 'is_not_empty'
  | 'greater_than'
  | 'less_than';

export interface ConditionRule {
  fieldId: string;
  operator: ConditionOperator;
  value?: unknown;
}

export interface FieldConditions {
  logic: 'and' | 'or';
  rules: ConditionRule[];
}

Step 2: Extend FormField interface

Add to FormField (after the step?: number; line):

  /** Conditional visibility rules. If undefined, field is always visible. */
  conditions?: FieldConditions;

Step 3: Build to verify no type errors

Run: npm run build Expected: Clean build, no errors.

Step 4: Commit

git add src/types.ts
git commit -m "feat: add conditional logic types (ConditionOperator, ConditionRule, FieldConditions)"

Task 2: Create the Condition Engine

Files:

  • Create: src/utils/condition-engine.ts

Step 1: Create the condition engine module

Create src/utils/condition-engine.ts with the following content:

import { FieldConditions, ConditionRule, ConditionOperator, FieldType } from '../types';

/**
 * Evaluate whether a field should be visible based on its conditions
 * and the current form values.
 * Returns true if the field should be shown, false if hidden.
 */
export function evaluateConditions(
  conditions: FieldConditions,
  values: Record<string, unknown>,
): boolean {
  if (conditions.rules.length === 0) return true;

  const results = conditions.rules.map((rule) => evaluateRule(rule, values));

  return conditions.logic === 'and'
    ? results.every(Boolean)
    : results.some(Boolean);
}

function evaluateRule(
  rule: ConditionRule,
  values: Record<string, unknown>,
): boolean {
  const value = values[rule.fieldId];

  switch (rule.operator) {
    case 'is_empty':
      return isEmpty(value);
    case 'is_not_empty':
      return !isEmpty(value);
    case 'equals':
      return String(value ?? '') === String(rule.value ?? '');
    case 'not_equals':
      return String(value ?? '') !== String(rule.value ?? '');
    case 'contains':
      return checkContains(value, rule.value);
    case 'not_contains':
      return !checkContains(value, rule.value);
    case 'greater_than':
      return toNum(value) > toNum(rule.value);
    case 'less_than':
      return toNum(value) < toNum(rule.value);
    default:
      return true;
  }
}

function isEmpty(value: unknown): boolean {
  if (value === null || value === undefined || value === '') return true;
  if (Array.isArray(value) && value.length === 0) return true;
  return false;
}

function checkContains(value: unknown, search: unknown): boolean {
  const searchStr = String(search ?? '');
  if (Array.isArray(value)) {
    return value.some((item) => String(item) === searchStr);
  }
  return String(value ?? '').includes(searchStr);
}

function toNum(value: unknown): number {
  return parseFloat(String(value ?? '')) || 0;
}

/**
 * Returns the set of operators that are valid for a given field type.
 */
export function getOperatorsForType(type: FieldType): ConditionOperator[] {
  const base: ConditionOperator[] = ['equals', 'not_equals', 'is_empty', 'is_not_empty'];

  switch (type) {
    case 'text':
    case 'textarea':
      return [...base, 'contains', 'not_contains'];
    case 'tags':
      return [...base, 'contains', 'not_contains'];
    case 'number':
    case 'rating':
    case 'slider':
      return [...base, 'greater_than', 'less_than'];
    case 'toggle':
      // Toggle always has a value (true/false), so is_empty/is_not_empty don't apply
      return ['equals', 'not_equals'];
    default:
      return base;
  }
}

/**
 * Compute visibility for all fields in a form.
 * Fields can only depend on fields before them (prevents cycles).
 * Returns a Map of fieldId -> isVisible.
 */
export function computeVisibility(
  fields: { id: string; conditions?: FieldConditions }[],
  values: Record<string, unknown>,
): Map<string, boolean> {
  const visibility = new Map<string, boolean>();

  for (const field of fields) {
    if (!field.conditions || field.conditions.rules.length === 0) {
      visibility.set(field.id, true);
      continue;
    }

    // Only evaluate against fields that are themselves visible
    const visibleValues: Record<string, unknown> = {};
    for (const [id, val] of Object.entries(values)) {
      if (visibility.get(id) !== false) {
        visibleValues[id] = val;
      }
    }

    visibility.set(field.id, evaluateConditions(field.conditions, visibleValues));
  }

  return visibility;
}

Step 2: Build to verify

Run: npm run build Expected: Clean build.

Step 3: Commit

git add src/utils/condition-engine.ts
git commit -m "feat: add condition evaluation engine with operator logic and visibility computation"

Task 3: Wire Reactivity into FormModal

Files:

  • Modify: src/ui/form-modal.ts

Step 1: Add imports

At the top of form-modal.ts, add to imports:

import { computeVisibility } from '../utils/condition-engine';

Step 2: Add a fieldContainers map to the class

In the FormModal class, add a new property alongside renderedFields:

private fieldContainers: Map<string, HTMLElement> = new Map();

Step 3: Modify renderForm to track field containers and add change listeners

Replace the field rendering loop (the for (const field of this.form.fields) block, lines 111-121) with:

    // Render each field
    for (const field of this.form.fields) {
      const fieldWrapper = fieldsEl.createDiv({ cls: 'ff-field-wrapper' });
      const initial = existingFrontmatter[field.id] as FieldValue | undefined;
      const defaultVal = field.defaultValue as FieldValue | undefined;
      const rendered = renderField(
        this.app,
        fieldWrapper,
        field,
        initial ?? defaultVal,
      );
      this.renderedFields.set(field.id, rendered);
      this.fieldContainers.set(field.id, fieldWrapper);
    }

    // Attach change listeners for reactivity
    this.attachChangeListeners(fieldsEl);

    // Initial visibility evaluation
    this.reevaluateVisibility();

Step 4: Add the reevaluateVisibility and attachChangeListeners methods

Add these methods to the FormModal class, before handleSubmit:

  private reevaluateVisibility(): void {
    const values = this.collectValues();
    const visibility = computeVisibility(this.form.fields, values);

    for (const field of this.form.fields) {
      const container = this.fieldContainers.get(field.id);
      if (!container) continue;

      const isVisible = visibility.get(field.id) !== false;
      container.style.display = isVisible ? '' : 'none';
    }
  }

  private collectValues(): Record<string, unknown> {
    const values: Record<string, unknown> = {};
    for (const field of this.form.fields) {
      const rendered = this.renderedFields.get(field.id);
      if (rendered) {
        values[field.id] = rendered.getValue();
      }
    }
    return values;
  }

  private attachChangeListeners(container: HTMLElement): void {
    container.addEventListener('input', () => this.reevaluateVisibility());
    container.addEventListener('change', () => this.reevaluateVisibility());
    container.addEventListener('click', () => {
      // Defer to let toggle/rating state update first
      requestAnimationFrame(() => this.reevaluateVisibility());
    });
  }

Step 5: Modify handleSubmit to exclude hidden fields

In the handleSubmit method, replace the value collection loop (lines 162-168) with:

    // Collect values (only from visible fields)
    const allValues = this.collectValues();
    const visibility = computeVisibility(this.form.fields, allValues);

    const values: Record<string, unknown> = {};
    for (const field of this.form.fields) {
      if (visibility.get(field.id) !== false) {
        values[field.id] = allValues[field.id];
      }
    }

Step 6: Modify validation to only validate visible fields

Replace the validation call (line 176) with:

    const visibleFields = this.form.fields.filter(
      (f) => visibility.get(f.id) !== false,
    );
    const errors: ValidationError[] = validateForm(visibleFields, values);

Step 7: Clear fieldContainers in onClose

In the onClose method, add:

    this.fieldContainers.clear();

Step 8: Build and manually test

Run: npm run build Expected: Clean build. Copy to vault and test that forms still open and submit correctly (no conditions set yet, so all fields should remain visible).

Step 9: Commit

git add src/ui/form-modal.ts
git commit -m "feat: add reactive conditional visibility to form modal"

Task 4: Add Conditions Editor to Form Builder

Files:

  • Modify: src/ui/form-builder.ts

Step 1: Add imports

At the top of form-builder.ts, add:

import { ConditionOperator, ConditionRule, FieldConditions } from '../types';
import { getOperatorsForType } from '../utils/condition-engine';

Step 2: Add operator label helper

Add this constant after FIELD_TYPES:

const OPERATOR_LABELS: Record<ConditionOperator, string> = {
  equals: 'equals',
  not_equals: 'does not equal',
  contains: 'contains',
  not_contains: 'does not contain',
  is_empty: 'is empty',
  is_not_empty: 'is not empty',
  greater_than: 'is greater than',
  less_than: 'is less than',
};

Step 3: Add renderConditionsEditor method

Add this method to FormBuilderModal, after the addToggleSetting method:

  private renderConditionsEditor(
    container: HTMLElement,
    field: FormField,
    fieldIndex: number,
  ): void {
    const condEl = container.createDiv({ cls: 'ff-builder-conditions' });

    // Fields that come before this one (valid targets for conditions)
    const precedingFields = this.draft.fields.slice(0, fieldIndex);

    // If no preceding fields, conditions aren't possible
    if (precedingFields.length === 0) {
      condEl.createDiv({
        cls: 'ff-builder-conditions-empty',
        text: 'No preceding fields to add conditions.',
      });
      return;
    }

    // Summary / toggle header
    const headerEl = condEl.createDiv({ cls: 'ff-builder-conditions-header' });
    const hasConditions = field.conditions && field.conditions.rules.length > 0;

    const summaryText = hasConditions
      ? `Conditional (${field.conditions!.rules.length} rule${field.conditions!.rules.length > 1 ? 's' : ''})`
      : 'Always visible';

    const toggleBtn = headerEl.createEl('button', {
      cls: `ff-builder-conditions-toggle ${hasConditions ? 'ff-has-conditions' : ''}`,
      text: `⚡ ${summaryText}`,
    });

    // Collapsible body
    const bodyEl = condEl.createDiv({ cls: 'ff-builder-conditions-body' });
    bodyEl.style.display = 'none';

    toggleBtn.addEventListener('click', () => {
      const isOpen = bodyEl.style.display !== 'none';
      bodyEl.style.display = isOpen ? 'none' : '';
      toggleBtn.toggleClass('ff-conditions-open', !isOpen);
    });

    // Logic selector
    if (!field.conditions) {
      field.conditions = { logic: 'and', rules: [] };
    }
    const conditions = field.conditions;

    const logicRow = bodyEl.createDiv({ cls: 'ff-builder-setting' });
    logicRow.createEl('label', {
      cls: 'ff-builder-setting-label',
      text: 'Logic',
    });
    const logicSelect = logicRow.createEl('select', {
      cls: 'dropdown ff-dropdown',
    });
    logicSelect.createEl('option', { value: 'and', text: 'ALL conditions match' });
    logicSelect.createEl('option', { value: 'or', text: 'ANY condition matches' });
    logicSelect.value = conditions.logic;
    logicSelect.addEventListener('change', () => {
      this.pushSnapshot();
      conditions.logic = logicSelect.value as 'and' | 'or';
    });

    // Rules list
    const rulesEl = bodyEl.createDiv({ cls: 'ff-builder-rules' });
    this.renderConditionRules(rulesEl, conditions, precedingFields, field);

    // Add rule button
    const addRuleBtn = bodyEl.createEl('button', {
      cls: 'ff-builder-add-rule-btn',
      text: '+ Add condition',
    });
    addRuleBtn.addEventListener('click', () => {
      this.pushSnapshot();
      const firstField = precedingFields[0];
      const operators = getOperatorsForType(firstField.type);
      conditions.rules.push({
        fieldId: firstField.id,
        operator: operators[0],
        value: '',
      });
      this.render();
    });
  }

  private renderConditionRules(
    container: HTMLElement,
    conditions: FieldConditions,
    precedingFields: FormField[],
    currentField: FormField,
  ): void {
    for (let r = 0; r < conditions.rules.length; r++) {
      const rule = conditions.rules[r];
      const ruleEl = container.createDiv({ cls: 'ff-builder-rule' });

      // Field selector
      const fieldSelect = ruleEl.createEl('select', {
        cls: 'dropdown ff-dropdown ff-rule-field',
      });
      for (const pf of precedingFields) {
        fieldSelect.createEl('option', { value: pf.id, text: pf.label || pf.id });
      }
      fieldSelect.value = rule.fieldId;

      // Get the referenced field's type for operator filtering
      const referencedField = precedingFields.find((f) => f.id === rule.fieldId);
      const refType = referencedField?.type ?? 'text';
      const operators = getOperatorsForType(refType);

      // Operator selector
      const opSelect = ruleEl.createEl('select', {
        cls: 'dropdown ff-dropdown ff-rule-operator',
      });
      for (const op of operators) {
        opSelect.createEl('option', { value: op, text: OPERATOR_LABELS[op] });
      }
      opSelect.value = operators.includes(rule.operator) ? rule.operator : operators[0];

      // Value input (hidden for is_empty/is_not_empty)
      const needsValue = !['is_empty', 'is_not_empty'].includes(rule.operator);
      const valueInput = this.createRuleValueInput(ruleEl, rule, referencedField);
      if (!needsValue && valueInput) {
        valueInput.style.display = 'none';
      }

      // Delete button
      const deleteBtn = ruleEl.createEl('button', {
        cls: 'ff-builder-action-btn ff-builder-delete ff-rule-delete',
        text: '×',
      });
      deleteBtn.setAttribute('aria-label', 'Remove condition');

      // Event listeners
      fieldSelect.addEventListener('change', () => {
        this.pushSnapshot();
        rule.fieldId = fieldSelect.value;
        // Reset operator to first valid one for new field type
        const newField = precedingFields.find((f) => f.id === fieldSelect.value);
        const newOps = getOperatorsForType(newField?.type ?? 'text');
        rule.operator = newOps[0];
        rule.value = '';
        this.render();
      });

      opSelect.addEventListener('change', () => {
        this.pushSnapshot();
        rule.operator = opSelect.value as ConditionOperator;
        // Show/hide value input
        if (valueInput) {
          const needsVal = !['is_empty', 'is_not_empty'].includes(rule.operator);
          valueInput.style.display = needsVal ? '' : 'none';
        }
      });

      deleteBtn.addEventListener('click', () => {
        this.pushSnapshot();
        conditions.rules.splice(r, 1);
        // Clean up empty conditions
        if (conditions.rules.length === 0) {
          currentField.conditions = undefined;
        }
        this.render();
      });
    }
  }

  private createRuleValueInput(
    container: HTMLElement,
    rule: ConditionRule,
    referencedField?: FormField,
  ): HTMLElement | null {
    const type = referencedField?.type ?? 'text';

    // For dropdown fields: show a select with the dropdown's options
    if (type === 'dropdown' && referencedField?.options?.length) {
      const select = container.createEl('select', {
        cls: 'dropdown ff-dropdown ff-rule-value',
      });
      select.createEl('option', { value: '', text: '(select)' });
      for (const opt of referencedField.options) {
        select.createEl('option', { value: opt, text: opt });
      }
      select.value = String(rule.value ?? '');
      select.addEventListener('change', () => {
        this.pushSnapshot();
        rule.value = select.value;
      });
      return select;
    }

    // For toggle: show true/false dropdown
    if (type === 'toggle') {
      const select = container.createEl('select', {
        cls: 'dropdown ff-dropdown ff-rule-value',
      });
      select.createEl('option', { value: 'true', text: 'true' });
      select.createEl('option', { value: 'false', text: 'false' });
      select.value = String(rule.value ?? 'true');
      select.addEventListener('change', () => {
        this.pushSnapshot();
        rule.value = select.value;
      });
      return select;
    }

    // For number/rating/slider: number input
    if (['number', 'rating', 'slider'].includes(type)) {
      const input = container.createEl('input', {
        cls: 'ff-input ff-rule-value',
        type: 'number',
        value: String(rule.value ?? ''),
      });
      input.addEventListener('change', () => {
        this.pushSnapshot();
        rule.value = input.value;
      });
      return input;
    }

    // Default: text input
    const input = container.createEl('input', {
      cls: 'ff-input ff-rule-value',
      type: 'text',
      value: String(rule.value ?? ''),
    });
    input.placeholder = 'Value...';
    input.addEventListener('change', () => {
      this.pushSnapshot();
      rule.value = input.value;
    });
    return input;
  }

Step 4: Call renderConditionsEditor in renderFields

In the renderFields method, add a call to renderConditionsEditor at the end of the per-field loop body, right after the slider min/max/step block (after line 480, before the closing } of the for loop):

      // Conditional visibility rules
      this.renderConditionsEditor(bodyEl, field, i);

Step 5: Update the preview to show conditional badges

In the render method, replace the preview field rendering loop (the for (const field of this.draft.fields) block inside the preview, around lines 203-204) with:

      for (const field of this.draft.fields) {
        const fieldPreviewWrapper = previewFields.createDiv({ cls: 'ff-preview-field-wrapper' });
        if (field.conditions && field.conditions.rules.length > 0) {
          fieldPreviewWrapper.addClass('ff-preview-conditional');
          fieldPreviewWrapper.createDiv({
            cls: 'ff-preview-conditional-badge',
            text: '⚡ Conditional',
          });
        }
        renderField(this.app, fieldPreviewWrapper, field, field.defaultValue);
      }

Step 6: Build to verify

Run: npm run build Expected: Clean build.

Step 7: Commit

git add src/ui/form-builder.ts
git commit -m "feat: add conditions editor UI to form builder"

Task 5: Add Styles for Conditional Logic

Files:

  • Modify: styles.css

Step 1: Add condition editor styles

Append the following at the end of styles.css:

/* ==========================================================================
   Conditional Logic
   ========================================================================== */

/* --- Conditions editor in builder --- */

.ff-builder-conditions {
  margin-top: 8px;
  padding-top: 8px;
  border-top: 1px dashed var(--background-modifier-border);
}

.ff-builder-conditions-empty {
  font-size: 0.8rem;
  color: var(--text-faint);
  font-style: italic;
  padding: 4px 0;
}

.ff-builder-conditions-header {
  margin-bottom: 6px;
}

.ff-builder-conditions-toggle {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 4px 10px;
  border: 1px solid var(--background-modifier-border);
  border-radius: 4px;
  background: transparent;
  color: var(--text-muted);
  font-size: 0.8rem;
  cursor: pointer;
  transition: all 0.15s ease;
}

.ff-builder-conditions-toggle:hover {
  border-color: var(--interactive-accent);
  color: var(--text-normal);
}

.ff-builder-conditions-toggle.ff-has-conditions {
  border-color: var(--interactive-accent);
  color: var(--interactive-accent);
  background: color-mix(in srgb, var(--interactive-accent) 8%, transparent);
}

.ff-builder-conditions-toggle.ff-conditions-open {
  border-bottom-left-radius: 0;
  border-bottom-right-radius: 0;
}

.ff-builder-conditions-body {
  padding: 8px;
  border: 1px solid var(--background-modifier-border);
  border-top: none;
  border-radius: 0 0 4px 4px;
  background: color-mix(in srgb, var(--background-primary) 50%, var(--background-secondary));
}

/* --- Rule rows --- */

.ff-builder-rules {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-bottom: 8px;
}

.ff-builder-rule {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
}

.ff-rule-field {
  flex: 1 1 120px;
  min-width: 100px;
}

.ff-rule-operator {
  flex: 1 1 130px;
  min-width: 110px;
}

.ff-rule-value {
  flex: 1 1 100px;
  min-width: 80px;
}

.ff-rule-delete {
  flex-shrink: 0;
}

.ff-builder-add-rule-btn {
  display: block;
  width: 100%;
  padding: 6px;
  border: 1px dashed var(--background-modifier-border);
  border-radius: 4px;
  background: transparent;
  color: var(--text-muted);
  font-size: 0.8rem;
  cursor: pointer;
  text-align: center;
  transition: all 0.15s ease;
}

.ff-builder-add-rule-btn:hover {
  border-color: var(--interactive-accent);
  color: var(--interactive-accent);
}

/* --- Conditional badge in preview --- */

.ff-preview-field-wrapper {
  position: relative;
}

.ff-preview-conditional {
  opacity: 0.5;
  transition: opacity 0.2s ease;
}

.ff-preview-conditional:hover {
  opacity: 0.8;
}

.ff-preview-conditional-badge {
  font-size: 0.7rem;
  color: var(--interactive-accent);
  font-weight: 600;
  letter-spacing: 0.02em;
  margin-bottom: 2px;
}

/* --- Show/hide transition in form modal --- */

.ff-field-wrapper {
  transition: opacity 0.2s ease, max-height 0.2s ease;
  overflow: hidden;
}

Step 2: Build to verify

Run: npm run build Expected: Clean build.

Step 3: Commit

git add styles.css
git commit -m "feat: add styles for conditional logic editor and preview badges"

Task 6: Integration Testing and Cleanup

Files:

  • All modified files

Step 1: Build the plugin

Run: npm run build Expected: Clean build, no errors or warnings.

Step 2: Install to test vault

cp main.js manifest.json styles.css ~/vault/.obsidian/plugins/formfire/

Step 3: Manual integration test checklist

  1. Existing forms work unchanged — Open an existing form (no conditions). All fields visible, submission works.
  2. Builder shows conditions editor — Edit a form. Each field (except the first) should have a " Always visible" button below its settings.
  3. First field has no conditions — The first field should show "No preceding fields to add conditions."
  4. Add a condition — On the second field, click " Always visible", click "+ Add condition". A rule row appears with the first field selected.
  5. Operator filtering — If the first field is a dropdown, operators should include equals/not_equals/is_empty/is_not_empty but not contains/greater_than. If it's a text field, contains/not_contains should appear.
  6. Value input adapts — For dropdown fields, value shows a select with their options. For toggle, shows true/false. For number, shows number input. For is_empty/is_not_empty, value input is hidden.
  7. Delete a rule — Click × on a rule. It should be removed. If last rule is deleted, conditions are cleared.
  8. AND/OR logic — Switch between ALL/ANY. Add multiple rules.
  9. Form modal reactivity — Open the form. Change the field that a condition references. The conditional field should appear/disappear.
  10. Hidden fields excluded from submit — Set a condition that hides a required field. Submit should succeed without that field's value.
  11. Preview badges — In the builder, fields with conditions show " Conditional" badge and are grayed out in the preview.
  12. Undo/redo — Adding/removing conditions should be undoable.
  13. Drag & drop — Reordering fields should not break conditions (fieldIds are stable).

Step 4: Fix any issues found

Address bugs discovered during testing.

Step 5: Final commit

git add -A
git commit -m "feat: conditional field visibility with AND/OR logic"

Task 7: Push and Update Vault

Step 1: Push to remote

git push

Step 2: Copy updated build to vault

cp main.js manifest.json styles.css ~/vault/.obsidian/plugins/formfire/