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

894 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<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**
```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<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:
```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<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:
```typescript
// 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:
```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<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:
```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/
```