diff --git a/docs/plans/2026-02-13-conditional-logic-implementation.md b/docs/plans/2026-02-13-conditional-logic-implementation.md new file mode 100644 index 0000000..105a88d --- /dev/null +++ b/docs/plans/2026-02-13-conditional-logic-implementation.md @@ -0,0 +1,894 @@ +# 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/ +```