diff --git a/src/ui/form-builder.ts b/src/ui/form-builder.ts index cc1d16d..2607bb5 100644 --- a/src/ui/form-builder.ts +++ b/src/ui/form-builder.ts @@ -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 = { + 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; + } }