diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json index f7577cdab..60c57b98b 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json @@ -101,5 +101,27 @@ "yes": "Ja", "no": "Nein" } + }, + "branching": { + "title": "Logik (Wenn → Dann)", + "addRule": "+ Regel", + "empty": "Keine Regeln. Klick \"+ Regel\" um eine Bedingung zu bauen.", + "emptyNeedFields": "Lege mindestens zwei Antwortfelder an, um Logik zu bauen.", + "if": "Wenn", + "then": "Dann", + "valuePlaceholder": "Wert ...", + "pickField": "Feld wählen ...", + "removeAria": "Regel löschen", + "op": { + "equals": "gleich", + "notEquals": "ungleich", + "contains": "enthält", + "isEmpty": "ist leer" + }, + "action": { + "show": "zeige", + "hide": "verstecke", + "skipTo": "springe zu" + } } } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json index a275da4b4..7e2eb0feb 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json @@ -101,5 +101,27 @@ "yes": "Yes", "no": "No" } + }, + "branching": { + "title": "Logic (If → Then)", + "addRule": "+ Rule", + "empty": "No rules. Click \"+ Rule\" to add a condition.", + "emptyNeedFields": "Add at least two answer fields to build logic.", + "if": "If", + "then": "Then", + "valuePlaceholder": "Value ...", + "pickField": "Pick field ...", + "removeAria": "Delete rule", + "op": { + "equals": "equals", + "notEquals": "does not equal", + "contains": "contains", + "isEmpty": "is empty" + }, + "action": { + "show": "show", + "hide": "hide", + "skipTo": "skip to" + } } } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json index 034c3104f..3b7bad6b4 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json @@ -101,5 +101,27 @@ "yes": "Sí", "no": "No" } + }, + "branching": { + "title": "Lógica (Si → Entonces)", + "addRule": "+ Regla", + "empty": "Sin reglas. Pulsa \"+ Regla\" para añadir una condición.", + "emptyNeedFields": "Añade al menos dos campos de respuesta para construir lógica.", + "if": "Si", + "then": "Entonces", + "valuePlaceholder": "Valor ...", + "pickField": "Elegir campo ...", + "removeAria": "Eliminar regla", + "op": { + "equals": "es igual a", + "notEquals": "no es igual a", + "contains": "contiene", + "isEmpty": "está vacío" + }, + "action": { + "show": "mostrar", + "hide": "ocultar", + "skipTo": "saltar a" + } } } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json index d98c75492..bae65617e 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json @@ -101,5 +101,27 @@ "yes": "Oui", "no": "Non" } + }, + "branching": { + "title": "Logique (Si → Alors)", + "addRule": "+ Règle", + "empty": "Aucune règle. Clique \"+ Règle\" pour ajouter une condition.", + "emptyNeedFields": "Ajoute au moins deux champs de réponse pour construire la logique.", + "if": "Si", + "then": "Alors", + "valuePlaceholder": "Valeur ...", + "pickField": "Choisir un champ ...", + "removeAria": "Supprimer la règle", + "op": { + "equals": "égal", + "notEquals": "différent", + "contains": "contient", + "isEmpty": "est vide" + }, + "action": { + "show": "afficher", + "hide": "masquer", + "skipTo": "sauter à" + } } } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json index a0f81a061..8ee1824be 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json @@ -101,5 +101,27 @@ "yes": "Sì", "no": "No" } + }, + "branching": { + "title": "Logica (Se → Allora)", + "addRule": "+ Regola", + "empty": "Nessuna regola. Clicca \"+ Regola\" per aggiungere una condizione.", + "emptyNeedFields": "Aggiungi almeno due campi di risposta per costruire la logica.", + "if": "Se", + "then": "Allora", + "valuePlaceholder": "Valore ...", + "pickField": "Scegli campo ...", + "removeAria": "Elimina regola", + "op": { + "equals": "uguale a", + "notEquals": "diverso da", + "contains": "contiene", + "isEmpty": "è vuoto" + }, + "action": { + "show": "mostra", + "hide": "nascondi", + "skipTo": "salta a" + } } } diff --git a/apps/mana/apps/web/src/lib/modules/forms/components/BranchingEditor.svelte b/apps/mana/apps/web/src/lib/modules/forms/components/BranchingEditor.svelte new file mode 100644 index 000000000..2970b34b9 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/forms/components/BranchingEditor.svelte @@ -0,0 +1,351 @@ + + + +
+
+

+ {$_('forms.branching.title', { default: 'Logik (Wenn → Dann)' })} +

+ +
+ + {#if branching.length === 0} +

+ {#if ANSWER_FIELDS.length < 2} + {$_('forms.branching.emptyNeedFields', { + default: 'Lege mindestens zwei Antwortfelder an, um Logik zu bauen.', + })} + {:else} + {$_('forms.branching.empty', { + default: 'Keine Regeln. Klick "+ Regel" um eine Bedingung zu bauen.', + })} + {/if} +

+ {:else} + + {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/forms/index.ts b/apps/mana/apps/web/src/lib/modules/forms/index.ts index 90b4defff..adcc3062a 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/index.ts +++ b/apps/mana/apps/web/src/lib/modules/forms/index.ts @@ -18,6 +18,8 @@ export { formTable, formResponseTable } from './collections'; // ─── Lib ───────────────────────────────────────────────── export { makeDefaultField } from './lib/field-defaults'; +export { resolveVisibleFields } from './lib/branching'; +export { buildResponsesCsv, downloadResponsesCsv } from './lib/csv'; // ─── Types ─────────────────────────────────────────────── export { diff --git a/apps/mana/apps/web/src/lib/modules/forms/lib/branching.spec.ts b/apps/mana/apps/web/src/lib/modules/forms/lib/branching.spec.ts new file mode 100644 index 000000000..6e2edddc2 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/forms/lib/branching.spec.ts @@ -0,0 +1,200 @@ +import { describe, it, expect } from 'vitest'; +import { resolveVisibleFields } from './branching'; +import type { BranchingRule, FormField } from '../types'; + +function f(id: string, type: FormField['type'] = 'short_text'): FormField { + return { id, type, label: id, required: false }; +} + +describe('resolveVisibleFields', () => { + const fields: FormField[] = [f('q1'), f('q2'), f('q3', 'long_text'), f('q4')]; + + it('returns all fields when no branching rules exist', () => { + expect(resolveVisibleFields(fields, [], {})).toEqual(fields); + }); + + it('hides a field when its hide-rule matches via equals', () => { + const rules: BranchingRule[] = [ + { + id: 'r1', + ifFieldId: 'q1', + ifOperator: 'equals', + ifValue: 'no', + thenAction: 'hide', + thenFieldIds: ['q3'], + }, + ]; + const visible = resolveVisibleFields(fields, rules, { q1: 'no' }); + expect(visible.map((f) => f.id)).toEqual(['q1', 'q2', 'q4']); + }); + + it('keeps the field when the hide-rule does not match', () => { + const rules: BranchingRule[] = [ + { + id: 'r1', + ifFieldId: 'q1', + ifOperator: 'equals', + ifValue: 'no', + thenAction: 'hide', + thenFieldIds: ['q3'], + }, + ]; + const visible = resolveVisibleFields(fields, rules, { q1: 'yes' }); + expect(visible.map((f) => f.id)).toEqual(['q1', 'q2', 'q3', 'q4']); + }); + + it('skip_to hides every field strictly between anchor and target', () => { + const rules: BranchingRule[] = [ + { + id: 'r1', + ifFieldId: 'q1', + ifOperator: 'equals', + ifValue: 'fast-forward', + thenAction: 'skip_to', + thenSkipToFieldId: 'q4', + }, + ]; + const visible = resolveVisibleFields(fields, rules, { q1: 'fast-forward' }); + expect(visible.map((f) => f.id)).toEqual(['q1', 'q4']); + }); + + it('contains-operator on multi-choice array matches when array includes the value', () => { + const fieldsWithTagsAndDetail: FormField[] = [f('tags', 'multi_choice'), f('q-detail')]; + // Default-hide q-detail unless tags includes 'urgent'. + // Two rules: r1 hides whenever tags has any value but isn't empty; + // r2 shows when 'urgent' is present. + const ruleSet: BranchingRule[] = [ + { + id: 'r1', + ifFieldId: 'tags', + ifOperator: 'not_equals', + ifValue: '__sentinel__', + thenAction: 'hide', + thenFieldIds: ['q-detail'], + }, + { + id: 'r2', + ifFieldId: 'tags', + ifOperator: 'contains', + ifValue: 'urgent', + thenAction: 'show', + thenFieldIds: ['q-detail'], + }, + ]; + // tags includes 'urgent' → r1 fires (hide), r2 fires (show) — show wins + expect( + resolveVisibleFields(fieldsWithTagsAndDetail, ruleSet, { tags: ['urgent', 'work'] }).map( + (f) => f.id + ) + ).toEqual(['tags', 'q-detail']); + // tags missing 'urgent' → r1 fires (hide), r2 does not fire — stays hidden + expect( + resolveVisibleFields(fieldsWithTagsAndDetail, ruleSet, { tags: ['work'] }).map((f) => f.id) + ).toEqual(['tags']); + }); + + it('not_equals hides when answer differs from the expected value', () => { + const rules: BranchingRule[] = [ + { + id: 'r1', + ifFieldId: 'q1', + ifOperator: 'not_equals', + ifValue: 'a', + thenAction: 'hide', + thenFieldIds: ['q2'], + }, + ]; + // q1='b' (≠ 'a') → rule fires → q2 hidden + expect(resolveVisibleFields(fields, rules, { q1: 'b' }).map((f) => f.id)).not.toContain('q2'); + // q1='a' → rule does not fire → q2 visible + expect(resolveVisibleFields(fields, rules, { q1: 'a' }).map((f) => f.id)).toContain('q2'); + }); + + it('is_empty matches null, undefined, empty string, empty array, false', () => { + const rules: BranchingRule[] = [ + { + id: 'r1', + ifFieldId: 'flag', + ifOperator: 'is_empty', + thenAction: 'hide', + thenFieldIds: ['follow-up'], + }, + ]; + const fs: FormField[] = [f('flag', 'yes_no'), f('follow-up')]; + expect(resolveVisibleFields(fs, rules, {}).map((f) => f.id)).toEqual(['flag']); + expect(resolveVisibleFields(fs, rules, { flag: null }).map((f) => f.id)).toEqual(['flag']); + expect(resolveVisibleFields(fs, rules, { flag: '' }).map((f) => f.id)).toEqual(['flag']); + expect(resolveVisibleFields(fs, rules, { flag: false }).map((f) => f.id)).toEqual(['flag']); + expect(resolveVisibleFields(fs, rules, { flag: [] }).map((f) => f.id)).toEqual(['flag']); + expect(resolveVisibleFields(fs, rules, { flag: true }).map((f) => f.id)).toEqual([ + 'flag', + 'follow-up', + ]); + }); + + it('multiple rules layer in declaration order — last write wins', () => { + const rules: BranchingRule[] = [ + { + id: 'r1', + ifFieldId: 'q1', + ifOperator: 'equals', + ifValue: 'on', + thenAction: 'hide', + thenFieldIds: ['q2'], + }, + { + id: 'r2', + ifFieldId: 'q1', + ifOperator: 'equals', + ifValue: 'on', + thenAction: 'show', + thenFieldIds: ['q2'], + }, + ]; + // Both rules fire; show comes after hide → q2 visible + expect(resolveVisibleFields(fields, rules, { q1: 'on' }).map((f) => f.id)).toContain('q2'); + }); + + it('returns empty array when fields are empty', () => { + expect(resolveVisibleFields([], [], {})).toEqual([]); + }); + + it('ignores rules pointing to unknown field ids without crashing', () => { + const rules: BranchingRule[] = [ + { + id: 'r1', + ifFieldId: 'does-not-exist', + ifOperator: 'equals', + ifValue: 'x', + thenAction: 'hide', + thenFieldIds: ['q2', 'also-missing'], + }, + ]; + // Anchor field doesn't exist, so the answer is undefined; equals + // against 'x' fails; rule does not fire → all fields visible. + expect(resolveVisibleFields(fields, rules, {}).map((f) => f.id)).toEqual([ + 'q1', + 'q2', + 'q3', + 'q4', + ]); + }); + + it('preserves the original field order in the output', () => { + const rules: BranchingRule[] = [ + { + id: 'r1', + ifFieldId: 'q1', + ifOperator: 'equals', + ifValue: 'x', + thenAction: 'hide', + thenFieldIds: ['q2'], + }, + ]; + expect(resolveVisibleFields(fields, rules, { q1: 'x' }).map((f) => f.id)).toEqual([ + 'q1', + 'q3', + 'q4', + ]); + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/forms/lib/branching.ts b/apps/mana/apps/web/src/lib/modules/forms/lib/branching.ts new file mode 100644 index 000000000..f532fd81f --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/forms/lib/branching.ts @@ -0,0 +1,161 @@ +import type { AnswerValue, BranchingRule, FormField } from '../types'; + +/** + * Pure branching resolver. + * + * Given a form's `fields`, its `branching` rules, and the current + * answer state, returns the subset of fields that should be visible + * to the respondent right now. The order of the returned array + * matches the original `fields` order. + * + * Rules are evaluated in the order they appear. Each rule has an + * IF clause (operator over a referenced field's answer) and a THEN + * action (`show` / `hide` / `skip_to`). All actions affect later + * fields only; a rule on field X cannot hide X itself (that would + * make the IF clause unreadable). The default visibility for every + * field is "show" — `hide` rules subtract, `show` rules add back. + * + * `skip_to` jumps the visibility cursor to the named field; every + * field strictly between the rule's anchor and the skip target is + * hidden. Fields after the target follow normal rules. + * + * `section` and `consent` field types are always treated as part of + * the visible flow (they don't carry answers, so branching by them + * is undefined behaviour). + * + * The function is intentionally side-effect-free + allocation-light: + * no Dexie, no Svelte runes, no DOM. Used by the public form view + * to render the next field set on every keystroke. + */ +export function resolveVisibleFields( + fields: FormField[], + branching: BranchingRule[], + answers: Record +): FormField[] { + if (fields.length === 0) return []; + if (branching.length === 0) return fields.slice(); + + // Map fieldId → index for fast jumps. + const indexOf: Record = {}; + for (let i = 0; i < fields.length; i++) { + indexOf[fields[i].id] = i; + } + + // Default-visibility array; rules toggle entries. + const visible = new Array(fields.length).fill(true); + + // Resolve rules in declaration order. + for (const rule of branching) { + if (!evaluateCondition(rule, answers)) continue; + applyAction(rule, fields, indexOf, visible); + } + + const result: FormField[] = []; + for (let i = 0; i < fields.length; i++) { + if (visible[i]) result.push(fields[i]); + } + return result; +} + +function evaluateCondition(rule: BranchingRule, answers: Record): boolean { + const value = answers[rule.ifFieldId]; + switch (rule.ifOperator) { + case 'is_empty': + return isEmpty(value); + case 'equals': + return matches(value, rule.ifValue, true); + case 'not_equals': + return !matches(value, rule.ifValue, true); + case 'contains': + return contains(value, rule.ifValue); + } +} + +function applyAction( + rule: BranchingRule, + fields: FormField[], + indexOf: Record, + visible: boolean[] +): void { + switch (rule.thenAction) { + case 'show': + for (const targetId of rule.thenFieldIds ?? []) { + const idx = indexOf[targetId]; + if (idx !== undefined) visible[idx] = true; + } + return; + case 'hide': + for (const targetId of rule.thenFieldIds ?? []) { + const idx = indexOf[targetId]; + if (idx !== undefined) visible[idx] = false; + } + return; + case 'skip_to': + if (!rule.thenSkipToFieldId) return; + const anchorIdx = indexOf[rule.ifFieldId]; + const targetIdx = indexOf[rule.thenSkipToFieldId]; + if (anchorIdx === undefined || targetIdx === undefined) return; + // Hide everything strictly between anchor (exclusive) and target + // (exclusive). The anchor field itself is the rule's source, + // so it must remain visible. The target field is the new + // destination — also visible. + for (let i = anchorIdx + 1; i < targetIdx; i++) { + visible[i] = false; + } + return; + } +} + +function isEmpty(value: AnswerValue | undefined): boolean { + if (value === null || value === undefined) return true; + if (typeof value === 'string') return value.trim().length === 0; + if (Array.isArray(value)) return value.length === 0; + if (typeof value === 'boolean') return value === false; + return false; +} + +/** + * Equals/not-equals semantics: + * - Single value vs single value: strict-eq after string coerce + * - Single value vs array: array contains the value + * - Array (multi-choice) vs single value: array contains the value + * - Array vs array: same set (order-insensitive) + */ +function matches(value: AnswerValue | undefined, expected: unknown, strict: boolean): boolean { + if (expected === undefined) { + // `equals` with no value → behave like is_empty when strict + return strict ? isEmpty(value) : false; + } + const left = value; + const right = expected; + + if (Array.isArray(left) && Array.isArray(right)) { + if (left.length !== right.length) return false; + const set = new Set(left.map(String)); + for (const r of right) { + if (!set.has(String(r))) return false; + } + return true; + } + if (Array.isArray(left)) { + return left.map(String).includes(String(right)); + } + if (Array.isArray(right)) { + return right.map(String).includes(String(left ?? '')); + } + if (left === undefined || left === null) return false; + return String(left) === String(right); +} + +function contains(value: AnswerValue | undefined, needle: unknown): boolean { + if (needle === undefined || needle === null) return false; + const needleStr = Array.isArray(needle) ? needle.map(String) : [String(needle)]; + if (Array.isArray(value)) { + const hay = value.map(String); + return needleStr.every((n) => hay.includes(n)); + } + if (typeof value === 'string') { + return needleStr.every((n) => value.toLowerCase().includes(n.toLowerCase())); + } + return false; +} diff --git a/apps/mana/apps/web/src/lib/modules/forms/stores/forms.svelte.ts b/apps/mana/apps/web/src/lib/modules/forms/stores/forms.svelte.ts index 9a616dacb..27749d522 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/stores/forms.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/forms/stores/forms.svelte.ts @@ -48,6 +48,12 @@ export const formsStore = { await formTable.update(id, { status }); }, + async updateBranching(id: string, branching: BranchingRule[]) { + const diff: Partial = { branching }; + await encryptRecord('forms', diff); + await formTable.update(id, diff); + }, + async deleteForm(id: string) { await formTable.update(id, { deletedAt: nowIso() }); }, diff --git a/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte b/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte index 5f0ff5182..f43da46ac 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte +++ b/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte @@ -13,11 +13,12 @@ import { flip } from 'svelte/animate'; import { formsStore } from '../stores/forms.svelte'; import { FORM_STATUS_LABELS } from '../types'; - import type { Form, FormField, FormSettings, FormStatus } from '../types'; + import type { BranchingRule, Form, FormField, FormSettings, FormStatus } from '../types'; import { makeDefaultField } from '../lib/field-defaults'; import FieldEditor from '../components/FieldEditor.svelte'; import FieldPalette from '../components/FieldPalette.svelte'; import SettingsPanel from '../components/SettingsPanel.svelte'; + import BranchingEditor from '../components/BranchingEditor.svelte'; let { entry }: { entry: Form } = $props(); @@ -108,6 +109,10 @@ }); } + async function patchBranching(next: BranchingRule[]) { + await formsStore.updateBranching(entry.id, next); + } + async function setStatus(status: FormStatus) { await formsStore.setStatus(entry.id, status); } @@ -221,6 +226,10 @@ +
+ +
+
@@ -362,7 +371,8 @@ } .fields-section, - .settings-section { + .settings-section, + .branching-section { display: flex; flex-direction: column; gap: 0.625rem;