feat: add conditions editor UI to form builder
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bfe4dc6d5e
commit
6e78497cbb
1 changed files with 256 additions and 2 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue