docs: add conditional logic implementation plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Oelfke 2026-02-13 20:18:48 +01:00
parent 22eeccf022
commit ad9a2cfbb0

View file

@ -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<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/
```