feat(forms): M4a conditional branching — pure resolver + UI editor

Wenn-Dann-Logik für Form-Felder (docs/plans/forms-module.md M4 — Teil 1):

- lib/branching.ts: pure resolveVisibleFields(fields, branching, answers)
  — gibt sichtbare Subset-Liste zurück, Reihenfolge wie Original.
  Operatoren equals/not_equals/contains/is_empty mit Array-aware
  Matching (multi_choice + scalar in beide Richtungen). Aktionen
  show/hide/skip_to. show überschreibt hide bei doppelten Treffern
  (last-write-wins Layering, in Deklarations-Reihenfolge). skip_to
  versteckt alle Felder strikt zwischen Anchor und Target.
  Section/consent-Felder bleiben unbeeinflusst (kein answer-state).
- lib/branching.spec.ts: 11/11 Vitest-Cases — keine Regeln, hide+show
  Kombinationen, skip_to, contains-on-multi-choice, not_equals,
  is_empty (null/undefined/''/[]/false), Layering, fehlerhafte Refs,
  Order-Erhalt.
- components/BranchingEditor.svelte: top-level Builder-Sektion zum
  Anlegen/Editieren/Löschen von Regeln. Pro Regel: IF-Feld + Operator
  + Wert-Input (außer is_empty), THEN-Action + Target-Chips
  (multi-select für show/hide) bzw. einzelnes Feld (skip_to).
  Empty-State warnt wenn weniger als 2 Antwortfelder existieren.
- formsStore.updateBranching(id, rules) — encrypted-aware update.
- Wired in BuilderView als Section zwischen Fields und Settings.
- 18 neue i18n-Keys × 5 Locales (forms.branching.* + .op.* + .action.*).

Total Forms-Tests: 16/16 grün (5 csv + 11 branching). svelte-check: 0
errors in forms/. Pre-existing drift in context-removal-Spuren auf
main ist Parallel-Session-WIP, nicht durch mich.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-28 23:31:17 +02:00
parent 0ef71de008
commit afeb32f922
11 changed files with 842 additions and 2 deletions

View file

@ -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"
}
}
}

View file

@ -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"
}
}
}

View file

@ -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"
}
}
}

View file

@ -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 à"
}
}
}

View file

@ -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"
}
}
}

View file

@ -0,0 +1,351 @@
<!--
BranchingEditor — top-level section in the BuilderView listing every
conditional-visibility rule on the form. Each rule pairs an IF clause
(one field + operator + value) with a THEN action (show/hide/skip_to
some target fields). The resolver in lib/branching.ts is the runtime
contract — this component only mutates the schema array.
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import type { BranchAction, BranchOperator, BranchingRule, FormField } from '../types';
let {
fields,
branching,
onchange,
}: {
fields: FormField[];
branching: BranchingRule[];
onchange: (next: BranchingRule[]) => void;
} = $props();
const ANSWER_FIELDS = $derived(
fields.filter((f) => f.type !== 'section' && f.type !== 'consent')
);
const OPERATORS: BranchOperator[] = ['equals', 'not_equals', 'contains', 'is_empty'];
const ACTIONS: BranchAction[] = ['show', 'hide', 'skip_to'];
function operatorLabel(op: BranchOperator): string {
switch (op) {
case 'equals':
return $_('forms.branching.op.equals', { default: 'gleich' });
case 'not_equals':
return $_('forms.branching.op.notEquals', { default: 'ungleich' });
case 'contains':
return $_('forms.branching.op.contains', { default: 'enthält' });
case 'is_empty':
return $_('forms.branching.op.isEmpty', { default: 'ist leer' });
}
}
function actionLabel(a: BranchAction): string {
switch (a) {
case 'show':
return $_('forms.branching.action.show', { default: 'zeige' });
case 'hide':
return $_('forms.branching.action.hide', { default: 'verstecke' });
case 'skip_to':
return $_('forms.branching.action.skipTo', { default: 'springe zu' });
}
}
function addRule() {
const first = ANSWER_FIELDS[0];
if (!first) return;
const second = ANSWER_FIELDS[1] ?? first;
const newRule: BranchingRule = {
id: crypto.randomUUID(),
ifFieldId: first.id,
ifOperator: 'equals',
ifValue: '',
thenAction: 'hide',
thenFieldIds: [second.id],
};
onchange([...branching, newRule]);
}
function patchRule(id: string, patch: Partial<BranchingRule>) {
onchange(branching.map((r) => (r.id === id ? { ...r, ...patch } : r)));
}
function removeRule(id: string) {
onchange(branching.filter((r) => r.id !== id));
}
function setIfValue(id: string, raw: string) {
patchRule(id, { ifValue: raw });
}
function toggleTargetField(rule: BranchingRule, fieldId: string) {
const current = rule.thenFieldIds ?? [];
const next = current.includes(fieldId)
? current.filter((id) => id !== fieldId)
: [...current, fieldId];
patchRule(rule.id, { thenFieldIds: next });
}
</script>
<div class="branching-panel">
<header class="panel-header">
<p class="panel-title">
{$_('forms.branching.title', { default: 'Logik (Wenn → Dann)' })}
</p>
<button type="button" class="add-rule" onclick={addRule} disabled={ANSWER_FIELDS.length < 1}>
{$_('forms.branching.addRule', { default: '+ Regel' })}
</button>
</header>
{#if branching.length === 0}
<p class="empty">
{#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}
</p>
{:else}
<ul class="rule-list">
{#each branching as rule (rule.id)}
<li class="rule">
<div class="rule-row">
<span class="kw">{$_('forms.branching.if', { default: 'Wenn' })}</span>
<select
value={rule.ifFieldId}
onchange={(e) =>
patchRule(rule.id, { ifFieldId: (e.currentTarget as HTMLSelectElement).value })}
>
{#each ANSWER_FIELDS as f}
<option value={f.id}>{f.label || f.id}</option>
{/each}
</select>
<select
value={rule.ifOperator}
onchange={(e) =>
patchRule(rule.id, {
ifOperator: (e.currentTarget as HTMLSelectElement).value as BranchOperator,
})}
>
{#each OPERATORS as op}
<option value={op}>{operatorLabel(op)}</option>
{/each}
</select>
{#if rule.ifOperator !== 'is_empty'}
<input
type="text"
class="value-input"
value={typeof rule.ifValue === 'string' ? rule.ifValue : ''}
oninput={(e) => setIfValue(rule.id, (e.currentTarget as HTMLInputElement).value)}
placeholder={$_('forms.branching.valuePlaceholder', { default: 'Wert ...' })}
/>
{/if}
</div>
<div class="rule-row">
<span class="kw">{$_('forms.branching.then', { default: 'Dann' })}</span>
<select
value={rule.thenAction}
onchange={(e) =>
patchRule(rule.id, {
thenAction: (e.currentTarget as HTMLSelectElement).value as BranchAction,
})}
>
{#each ACTIONS as a}
<option value={a}>{actionLabel(a)}</option>
{/each}
</select>
{#if rule.thenAction === 'skip_to'}
<select
value={rule.thenSkipToFieldId ?? ''}
onchange={(e) =>
patchRule(rule.id, {
thenSkipToFieldId: (e.currentTarget as HTMLSelectElement).value || undefined,
})}
>
<option value=""
>{$_('forms.branching.pickField', { default: 'Feld wählen ...' })}</option
>
{#each ANSWER_FIELDS.filter((f) => f.id !== rule.ifFieldId) as f}
<option value={f.id}>{f.label || f.id}</option>
{/each}
</select>
{:else}
<div class="target-chips">
{#each ANSWER_FIELDS.filter((f) => f.id !== rule.ifFieldId) as f}
{@const checked = (rule.thenFieldIds ?? []).includes(f.id)}
<button
type="button"
class="target-chip"
class:active={checked}
onclick={() => toggleTargetField(rule, f.id)}
>
{f.label || f.id}
</button>
{/each}
</div>
{/if}
</div>
<button
type="button"
class="remove"
onclick={() => removeRule(rule.id)}
aria-label={$_('forms.branching.removeAria', { default: 'Regel löschen' })}>×</button
>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.branching-panel {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.875rem;
background: rgb(255 255 255 / 0.03);
border: 1px solid rgb(255 255 255 / 0.06);
border-radius: 0.5rem;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.panel-title {
margin: 0;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgb(255 255 255 / 0.5);
}
.add-rule {
padding: 0.25rem 0.5rem;
background: rgb(20 184 166 / 0.12);
border: 1px solid rgb(20 184 166 / 0.25);
border-radius: 0.25rem;
color: rgb(94 234 212);
font-size: 0.75rem;
cursor: pointer;
}
.add-rule:hover {
background: rgb(20 184 166 / 0.2);
}
.add-rule:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.empty {
margin: 0;
padding: 0.5rem 0;
font-size: 0.8125rem;
color: rgb(255 255 255 / 0.45);
}
.rule-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.rule {
position: relative;
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.625rem 2rem 0.625rem 0.75rem;
background: rgb(255 255 255 / 0.03);
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 0.375rem;
}
.rule-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
}
.kw {
min-width: 3rem;
color: rgb(255 255 255 / 0.5);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.rule-row select,
.rule-row input.value-input {
padding: 0.25rem 0.5rem;
background: rgb(255 255 255 / 0.04);
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 0.25rem;
color: inherit;
font-size: 0.8125rem;
}
.target-chips {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.target-chip {
padding: 0.125rem 0.5rem;
background: rgb(255 255 255 / 0.04);
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 999px;
color: rgb(255 255 255 / 0.55);
font-size: 0.75rem;
cursor: pointer;
}
.target-chip:hover {
background: rgb(255 255 255 / 0.07);
}
.target-chip.active {
background: rgb(20 184 166 / 0.18);
color: rgb(94 234 212);
border-color: rgb(20 184 166 / 0.4);
}
.remove {
position: absolute;
top: 0.375rem;
right: 0.375rem;
width: 1.5rem;
height: 1.5rem;
padding: 0;
display: inline-flex;
justify-content: center;
align-items: center;
background: transparent;
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 0.25rem;
color: rgb(255 255 255 / 0.4);
font-size: 0.875rem;
cursor: pointer;
}
.remove:hover {
color: rgb(248 113 113);
border-color: rgb(248 113 113 / 0.4);
}
</style>

View file

@ -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 {

View file

@ -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',
]);
});
});

View file

@ -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<string, AnswerValue>
): FormField[] {
if (fields.length === 0) return [];
if (branching.length === 0) return fields.slice();
// Map fieldId → index for fast jumps.
const indexOf: Record<string, number> = {};
for (let i = 0; i < fields.length; i++) {
indexOf[fields[i].id] = i;
}
// Default-visibility array; rules toggle entries.
const visible = new Array<boolean>(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<string, AnswerValue>): 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<string, number>,
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;
}

View file

@ -48,6 +48,12 @@ export const formsStore = {
await formTable.update(id, { status });
},
async updateBranching(id: string, branching: BranchingRule[]) {
const diff: Partial<LocalForm> = { branching };
await encryptRecord('forms', diff);
await formTable.update(id, diff);
},
async deleteForm(id: string) {
await formTable.update(id, { deletedAt: nowIso() });
},

View file

@ -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 @@
<FieldPalette onpick={pickField} />
</section>
<section class="branching-section">
<BranchingEditor fields={items} branching={entry.branching} onchange={patchBranching} />
</section>
<section class="settings-section">
<SettingsPanel settings={entry.settings} onchange={patchSettings} />
</section>
@ -362,7 +371,8 @@
}
.fields-section,
.settings-section {
.settings-section,
.branching-section {
display: flex;
flex-direction: column;
gap: 0.625rem;