Compare commits
No commits in common. "main" and "1.0.0" have entirely different histories.
7 changed files with 6 additions and 1667 deletions
|
|
@ -1,136 +0,0 @@
|
||||||
# Conditional Logic Design Document
|
|
||||||
|
|
||||||
**Date**: 2026-02-13
|
|
||||||
**Status**: Approved
|
|
||||||
**Plugin**: obsidian-formfire
|
|
||||||
**Phase**: 3 — Conditional Field Visibility
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
Forms with many fields become overwhelming when not all fields are relevant in every context. A "Meeting Notes" form shouldn't show "Book Author" fields. Currently all fields are always visible — users must skip irrelevant ones manually.
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
|
|
||||||
Add per-field conditional visibility rules with AND/OR logic. Fields can reference other fields' values to determine whether they should be shown or hidden. Hidden fields are excluded from submission.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
|
|
||||||
**Rule-per-Field** — each `FormField` gets an optional `conditions` object containing rules and a logic combinator. This keeps conditions co-located with the field they control and avoids a separate rules data structure.
|
|
||||||
|
|
||||||
## Data Model
|
|
||||||
|
|
||||||
### New Types
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type ConditionOperator =
|
|
||||||
| 'equals' | 'not_equals' // all field types
|
|
||||||
| 'contains' | 'not_contains' // text, textarea, tags
|
|
||||||
| 'is_empty' | 'is_not_empty' // all field types
|
|
||||||
| 'greater_than' | 'less_than'; // number, rating, slider
|
|
||||||
|
|
||||||
interface ConditionRule {
|
|
||||||
fieldId: string; // reference to another field
|
|
||||||
operator: ConditionOperator;
|
|
||||||
value?: unknown; // not needed for is_empty/is_not_empty
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FieldConditions {
|
|
||||||
logic: 'and' | 'or'; // how rules are combined
|
|
||||||
rules: ConditionRule[]; // at least 1 rule
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### FormField Extension
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface FormField {
|
|
||||||
// ... existing fields ...
|
|
||||||
conditions?: FieldConditions; // optional, undefined = always visible
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Operator Availability by Field Type
|
|
||||||
|
|
||||||
| Operator | text/textarea | number | toggle | date/time | dropdown | tags | note-link/folder | rating | slider | color |
|
|
||||||
|----------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
|
||||||
| equals / not_equals | yes | yes | yes | yes | yes | — | yes | yes | yes | yes |
|
|
||||||
| contains / not_contains | yes | — | — | — | — | yes | — | — | — | — |
|
|
||||||
| is_empty / is_not_empty | yes | yes | — | yes | yes | yes | yes | yes | yes | yes |
|
|
||||||
| greater_than / less_than | — | yes | — | — | — | — | — | yes | yes | — |
|
|
||||||
|
|
||||||
Note: `toggle` doesn't support `is_empty` since it always has a value (true/false).
|
|
||||||
|
|
||||||
## Evaluation Engine
|
|
||||||
|
|
||||||
New module: `src/utils/condition-engine.ts`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
evaluateConditions(
|
|
||||||
conditions: FieldConditions,
|
|
||||||
values: Record<string, unknown>
|
|
||||||
): boolean
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logic
|
|
||||||
|
|
||||||
1. Each `ConditionRule` is evaluated against the current value of the referenced field
|
|
||||||
2. Results are combined: `and` = all must be true, `or` = at least one must be true
|
|
||||||
3. Return: `true` = show field, `false` = hide field
|
|
||||||
|
|
||||||
### Operator Semantics
|
|
||||||
|
|
||||||
- `equals` / `not_equals` — string coercion for comparison, strict for booleans
|
|
||||||
- `contains` / `not_contains` — substring check for strings, element check for arrays (tags)
|
|
||||||
- `is_empty` / `is_not_empty` — `null`, `undefined`, `""`, `[]` count as empty
|
|
||||||
- `greater_than` / `less_than` — numeric comparison (parseFloat)
|
|
||||||
|
|
||||||
### Cycle Prevention
|
|
||||||
|
|
||||||
Fields can only reference fields that appear **before** them in the field list. This prevents circular dependencies by design and is enforced in the builder UI (field dropdown only shows preceding fields).
|
|
||||||
|
|
||||||
## Reactivity in Form Modal
|
|
||||||
|
|
||||||
1. **Change listeners** on every field — value changes trigger `reevaluateVisibility()`
|
|
||||||
2. `reevaluateVisibility()` iterates all fields with `conditions`, evaluates them, toggles `display: none`
|
|
||||||
3. **Hidden fields excluded from submit** — values not written to frontmatter
|
|
||||||
4. **Validation skips hidden fields** — a required field that is hidden does not block submission
|
|
||||||
|
|
||||||
## Builder UI
|
|
||||||
|
|
||||||
### Per-Field Conditions Editor
|
|
||||||
|
|
||||||
Located below existing field settings, collapsible:
|
|
||||||
|
|
||||||
**Collapsed**: Shows summary like `"Show if: type = Meeting"` or `"Always visible"`
|
|
||||||
|
|
||||||
**Expanded**:
|
|
||||||
1. **Logic dropdown** at top: `ALL conditions match` (AND) / `ANY condition matches` (OR)
|
|
||||||
2. **Rule rows**, each containing:
|
|
||||||
- Field dropdown (only fields before current field)
|
|
||||||
- Operator dropdown (filtered by selected field's type)
|
|
||||||
- Value input (type-dependent: text input, dropdown with options, number input, toggle; hidden for is_empty/is_not_empty)
|
|
||||||
- Delete button (×)
|
|
||||||
3. **"+ Add condition"** button
|
|
||||||
|
|
||||||
### Live Preview Behavior
|
|
||||||
|
|
||||||
Fields with unmet conditions are shown **grayed out with a "conditional" badge** in the builder preview (not fully hidden, so the builder user can see all fields exist).
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `src/types.ts` | New types: `ConditionOperator`, `ConditionRule`, `FieldConditions`; add `conditions?` to `FormField` |
|
|
||||||
| `src/utils/condition-engine.ts` | **New** — `evaluateConditions()`, operator logic, helpers |
|
|
||||||
| `src/utils/validators.ts` | Accept visibility map, skip hidden fields |
|
|
||||||
| `src/ui/form-modal.ts` | Change listeners, `reevaluateVisibility()`, exclude hidden fields from submit |
|
|
||||||
| `src/ui/form-builder.ts` | Conditions editor UI per field (collapsed/expanded, rule editor) |
|
|
||||||
| `src/ui/field-renderers.ts` | No changes — conditions operate one level above |
|
|
||||||
| `styles.css` | Condition editor styles, conditional badge in preview, show/hide transitions |
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
- Nested condition groups (AND inside OR) — one level is sufficient
|
|
||||||
- Form-level conditions (hide entire form)
|
|
||||||
- Actions other than show/hide (e.g. set value, change required)
|
|
||||||
- Migration — `conditions` is optional, existing forms work unchanged
|
|
||||||
|
|
@ -1,894 +0,0 @@
|
||||||
# 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/
|
|
||||||
```
|
|
||||||
27
src/types.ts
27
src/types.ts
|
|
@ -17,31 +17,6 @@ export type FieldType =
|
||||||
| 'color'
|
| 'color'
|
||||||
| 'time';
|
| 'time';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FormField {
|
export interface FormField {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -59,8 +34,6 @@ export interface FormField {
|
||||||
max?: number;
|
max?: number;
|
||||||
/** For slider: step increment. */
|
/** For slider: step increment. */
|
||||||
step?: number;
|
step?: number;
|
||||||
/** Conditional visibility rules. If undefined, field is always visible. */
|
|
||||||
conditions?: FieldConditions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { App, Modal, Notice } from 'obsidian';
|
import { App, Modal, Notice } from 'obsidian';
|
||||||
import { FormDefinition, FormField, FieldType, ConditionOperator, ConditionRule, FieldConditions } from '../types';
|
import { FormDefinition, FormField, FieldType } from '../types';
|
||||||
import { getOperatorsForType } from '../utils/condition-engine';
|
|
||||||
import { renderField } from './field-renderers';
|
import { renderField } from './field-renderers';
|
||||||
|
|
||||||
const FIELD_TYPES: FieldType[] = [
|
const FIELD_TYPES: FieldType[] = [
|
||||||
|
|
@ -19,17 +18,6 @@ const FIELD_TYPES: FieldType[] = [
|
||||||
'time',
|
'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.
|
* Modal for visually editing a FormDefinition.
|
||||||
* Works on a structuredClone draft and only calls onSave on confirmation.
|
* Works on a structuredClone draft and only calls onSave on confirmation.
|
||||||
|
|
@ -61,20 +49,6 @@ export class FormBuilderModal extends Modal {
|
||||||
this.contentEl.empty();
|
this.contentEl.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
private cleanupStaleConditions(): void {
|
|
||||||
for (let i = 0; i < this.draft.fields.length; i++) {
|
|
||||||
const field = this.draft.fields[i];
|
|
||||||
if (!field.conditions) continue;
|
|
||||||
const precedingIds = new Set(this.draft.fields.slice(0, i).map((f) => f.id));
|
|
||||||
field.conditions.rules = field.conditions.rules.filter((r) =>
|
|
||||||
precedingIds.has(r.fieldId),
|
|
||||||
);
|
|
||||||
if (field.conditions.rules.length === 0) {
|
|
||||||
field.conditions = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private pushSnapshot(): void {
|
private pushSnapshot(): void {
|
||||||
// Truncate any forward history
|
// Truncate any forward history
|
||||||
this.history = this.history.slice(0, this.historyIndex + 1);
|
this.history = this.history.slice(0, this.historyIndex + 1);
|
||||||
|
|
@ -227,15 +201,7 @@ export class FormBuilderModal extends Modal {
|
||||||
// Render each field
|
// Render each field
|
||||||
const previewFields = previewContent.createDiv({ cls: 'ff-fields' });
|
const previewFields = previewContent.createDiv({ cls: 'ff-fields' });
|
||||||
for (const field of this.draft.fields) {
|
for (const field of this.draft.fields) {
|
||||||
const fieldPreviewWrapper = previewFields.createDiv({ cls: 'ff-preview-field-wrapper' });
|
renderField(this.app, previewFields, field, field.defaultValue);
|
||||||
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
|
// Disabled submit button
|
||||||
|
|
@ -286,12 +252,6 @@ export class FormBuilderModal extends Modal {
|
||||||
new Notice('Form name cannot be empty.');
|
new Notice('Form name cannot be empty.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Clean up empty conditions before saving
|
|
||||||
for (const f of this.draft.fields) {
|
|
||||||
if (f.conditions && f.conditions.rules.length === 0) {
|
|
||||||
f.conditions = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.onSave(this.draft);
|
this.onSave(this.draft);
|
||||||
this.close();
|
this.close();
|
||||||
});
|
});
|
||||||
|
|
@ -337,7 +297,6 @@ export class FormBuilderModal extends Modal {
|
||||||
this.draft.fields[i],
|
this.draft.fields[i],
|
||||||
this.draft.fields[i - 1],
|
this.draft.fields[i - 1],
|
||||||
];
|
];
|
||||||
this.cleanupStaleConditions();
|
|
||||||
this.render();
|
this.render();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -355,7 +314,6 @@ export class FormBuilderModal extends Modal {
|
||||||
this.draft.fields[i + 1],
|
this.draft.fields[i + 1],
|
||||||
this.draft.fields[i],
|
this.draft.fields[i],
|
||||||
];
|
];
|
||||||
this.cleanupStaleConditions();
|
|
||||||
this.render();
|
this.render();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -369,7 +327,6 @@ export class FormBuilderModal extends Modal {
|
||||||
deleteBtn.addEventListener('click', () => {
|
deleteBtn.addEventListener('click', () => {
|
||||||
this.pushSnapshot();
|
this.pushSnapshot();
|
||||||
this.draft.fields.splice(i, 1);
|
this.draft.fields.splice(i, 1);
|
||||||
this.cleanupStaleConditions();
|
|
||||||
this.render();
|
this.render();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -421,7 +378,6 @@ export class FormBuilderModal extends Modal {
|
||||||
if (this.dragSourceIndex < i) targetIndex--;
|
if (this.dragSourceIndex < i) targetIndex--;
|
||||||
this.draft.fields.splice(targetIndex, 0, moved);
|
this.draft.fields.splice(targetIndex, 0, moved);
|
||||||
this.dragSourceIndex = null;
|
this.dragSourceIndex = null;
|
||||||
this.cleanupStaleConditions();
|
|
||||||
this.render();
|
this.render();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -522,9 +478,6 @@ export class FormBuilderModal extends Modal {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conditional visibility rules
|
|
||||||
this.renderConditionsEditor(bodyEl, field, i);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -607,235 +560,4 @@ export class FormBuilderModal extends Modal {
|
||||||
onChange(enabled);
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { App, Modal, TFile, Notice, FuzzySuggestModal } from 'obsidian';
|
||||||
import { FormDefinition } from '../types';
|
import { FormDefinition } from '../types';
|
||||||
import { renderField, RenderedField, FieldValue } from './field-renderers';
|
import { renderField, RenderedField, FieldValue } from './field-renderers';
|
||||||
import { validateForm, ValidationError } from '../utils/validators';
|
import { validateForm, ValidationError } from '../utils/validators';
|
||||||
import { computeVisibility } from '../utils/condition-engine';
|
|
||||||
import { FormProcessor } from '../core/form-processor';
|
import { FormProcessor } from '../core/form-processor';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -38,7 +37,6 @@ export class FilePickerModal extends FuzzySuggestModal<TFile> {
|
||||||
export class FormModal extends Modal {
|
export class FormModal extends Modal {
|
||||||
private form: FormDefinition;
|
private form: FormDefinition;
|
||||||
private renderedFields: Map<string, RenderedField> = new Map();
|
private renderedFields: Map<string, RenderedField> = new Map();
|
||||||
private fieldContainers: Map<string, HTMLElement> = new Map();
|
|
||||||
private targetFile: TFile | undefined;
|
private targetFile: TFile | undefined;
|
||||||
|
|
||||||
constructor(app: App, form: FormDefinition, targetFile?: TFile) {
|
constructor(app: App, form: FormDefinition, targetFile?: TFile) {
|
||||||
|
|
@ -111,25 +109,17 @@ export class FormModal extends Modal {
|
||||||
|
|
||||||
// Render each field
|
// Render each field
|
||||||
for (const field of this.form.fields) {
|
for (const field of this.form.fields) {
|
||||||
const fieldWrapper = fieldsEl.createDiv({ cls: 'ff-field-wrapper' });
|
|
||||||
const initial = existingFrontmatter[field.id] as FieldValue | undefined;
|
const initial = existingFrontmatter[field.id] as FieldValue | undefined;
|
||||||
const defaultVal = field.defaultValue as FieldValue | undefined;
|
const defaultVal = field.defaultValue as FieldValue | undefined;
|
||||||
const rendered = renderField(
|
const rendered = renderField(
|
||||||
this.app,
|
this.app,
|
||||||
fieldWrapper,
|
fieldsEl,
|
||||||
field,
|
field,
|
||||||
initial ?? defaultVal,
|
initial ?? defaultVal,
|
||||||
);
|
);
|
||||||
this.renderedFields.set(field.id, rendered);
|
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();
|
|
||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
const footerEl = contentEl.createDiv({ cls: 'ff-form-footer' });
|
const footerEl = contentEl.createDiv({ cls: 'ff-form-footer' });
|
||||||
const submitText =
|
const submitText =
|
||||||
|
|
@ -167,20 +157,8 @@ export class FormModal extends Modal {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private reevaluateVisibility(): void {
|
private async handleSubmit(): Promise<void> {
|
||||||
const values = this.collectValues();
|
// Collect values
|
||||||
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> = {};
|
const values: Record<string, unknown> = {};
|
||||||
for (const field of this.form.fields) {
|
for (const field of this.form.fields) {
|
||||||
const rendered = this.renderedFields.get(field.id);
|
const rendered = this.renderedFields.get(field.id);
|
||||||
|
|
@ -188,29 +166,6 @@ export class FormModal extends Modal {
|
||||||
values[field.id] = rendered.getValue();
|
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());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleSubmit(): Promise<void> {
|
|
||||||
// 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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear previous errors
|
// Clear previous errors
|
||||||
for (const rendered of this.renderedFields.values()) {
|
for (const rendered of this.renderedFields.values()) {
|
||||||
|
|
@ -218,10 +173,7 @@ export class FormModal extends Modal {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate
|
// Validate
|
||||||
const visibleFields = this.form.fields.filter(
|
const errors: ValidationError[] = validateForm(this.form.fields, values);
|
||||||
(f) => visibility.get(f.id) !== false,
|
|
||||||
);
|
|
||||||
const errors: ValidationError[] = validateForm(visibleFields, values);
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
for (const err of errors) {
|
for (const err of errors) {
|
||||||
const rendered = this.renderedFields.get(err.fieldId);
|
const rendered = this.renderedFields.get(err.fieldId);
|
||||||
|
|
@ -255,6 +207,5 @@ export class FormModal extends Modal {
|
||||||
onClose(): void {
|
onClose(): void {
|
||||||
this.contentEl.empty();
|
this.contentEl.empty();
|
||||||
this.renderedFields.clear();
|
this.renderedFields.clear();
|
||||||
this.fieldContainers.clear();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
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':
|
|
||||||
if (typeof value === 'boolean') {
|
|
||||||
return value === (rule.value === 'true' || rule.value === true);
|
|
||||||
}
|
|
||||||
return String(value ?? '') === String(rule.value ?? '');
|
|
||||||
case 'not_equals':
|
|
||||||
if (typeof value === 'boolean') {
|
|
||||||
return value !== (rule.value === 'true' || rule.value === true);
|
|
||||||
}
|
|
||||||
return String(value ?? '') !== String(rule.value ?? '');
|
|
||||||
case 'contains':
|
|
||||||
return checkContains(value, rule.value);
|
|
||||||
case 'not_contains':
|
|
||||||
return !checkContains(value, rule.value);
|
|
||||||
case 'greater_than': {
|
|
||||||
const a = toNum(value), b = toNum(rule.value);
|
|
||||||
return !isNaN(a) && !isNaN(b) && a > b;
|
|
||||||
}
|
|
||||||
case 'less_than': {
|
|
||||||
const a = toNum(value), b = toNum(rule.value);
|
|
||||||
return !isNaN(a) && !isNaN(b) && a < b;
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
if (value === null || value === undefined || value === '') return NaN;
|
|
||||||
return parseFloat(String(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
146
styles.css
146
styles.css
|
|
@ -836,149 +836,3 @@
|
||||||
.ff-field-control .checkbox-container {
|
.ff-field-control .checkbox-container {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ==========================================================================
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue