feat: add conditions editor UI to form builder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Oelfke 2026-02-13 20:40:51 +01:00
parent bfe4dc6d5e
commit 6e78497cbb

View file

@ -1,5 +1,6 @@
import { App, Modal, Notice } from 'obsidian';
import { FormDefinition, FormField, FieldType } from '../types';
import { FormDefinition, FormField, FieldType, ConditionOperator, ConditionRule, FieldConditions } from '../types';
import { getOperatorsForType } from '../utils/condition-engine';
import { renderField } from './field-renderers';
const FIELD_TYPES: FieldType[] = [
@ -18,6 +19,17 @@ const FIELD_TYPES: FieldType[] = [
'time',
];
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',
};
/**
* Modal for visually editing a FormDefinition.
* Works on a structuredClone draft and only calls onSave on confirmation.
@ -201,7 +213,15 @@ export class FormBuilderModal extends Modal {
// Render each field
const previewFields = previewContent.createDiv({ cls: 'ff-fields' });
for (const field of this.draft.fields) {
renderField(this.app, previewFields, field, field.defaultValue);
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);
}
// Disabled submit button
@ -478,6 +498,9 @@ export class FormBuilderModal extends Modal {
},
);
}
// Conditional visibility rules
this.renderConditionsEditor(bodyEl, field, i);
}
}
@ -560,4 +583,235 @@ export class FormBuilderModal extends Modal {
onChange(enabled);
});
}
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;
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;
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);
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;
}
}