mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
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:
parent
0ef71de008
commit
afeb32f922
11 changed files with 842 additions and 2 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 à"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
200
apps/mana/apps/web/src/lib/modules/forms/lib/branching.spec.ts
Normal file
200
apps/mana/apps/web/src/lib/modules/forms/lib/branching.spec.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
161
apps/mana/apps/web/src/lib/modules/forms/lib/branching.ts
Normal file
161
apps/mana/apps/web/src/lib/modules/forms/lib/branching.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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() });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue