# 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: ```typescript // --------------------------------------------------------------------------- // 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): ```typescript /** 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** ```bash 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: ```typescript 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, ): 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, ): 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, ): Map { const visibility = new Map(); 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 = {}; 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** ```bash 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: ```typescript 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`: ```typescript private fieldContainers: Map = 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: ```typescript // 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`: ```typescript 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 { const values: Record = {}; 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: ```typescript // Collect values (only from visible fields) const allValues = this.collectValues(); const visibility = computeVisibility(this.form.fields, allValues); const values: Record = {}; 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: ```typescript 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: ```typescript 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** ```bash 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: ```typescript import { ConditionOperator, ConditionRule, FieldConditions } from '../types'; import { getOperatorsForType } from '../utils/condition-engine'; ``` **Step 2: Add operator label helper** Add this constant after `FIELD_TYPES`: ```typescript const OPERATOR_LABELS: Record = { 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: ```typescript 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): ```typescript // 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: ```typescript 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** ```bash 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`: ```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** ```bash 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** ```bash 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** ```bash 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** ```bash git push ``` **Step 2: Copy updated build to vault** ```bash cp main.js manifest.json styles.css ~/vault/.obsidian/plugins/formfire/ ```