feat(forms): M2 builder + CRUD — drag-reorder + 11 field-types

Lieferung M2 (docs/plans/forms-module.md M2):

- BuilderView mit Title/Description-Inputs (autosave-on-blur),
  Status-Pills (draft/published/closed), Delete-Button mit confirm,
  Drag-reorder über svelte-dnd-action + flip-Animation.
- FieldEditor pro Feld: Label-Input, Pflichtfeld-Toggle, Typ-Switcher
  über alle 11 Field-Types, kollabierbare "Erweitert"-Section mit
  helpText + type-spezifischer Konfig (Optionen für choice-Felder mit
  Add/Remove, ratingScale-Toggle 5/10, min/max für number, maxLength
  für text). Type-switch räumt stale Konfig auf.
- FieldPalette mit 11 Buttons (Glyph + Label) — Klick erzeugt frisches
  Feld via makeDefaultField + dispatch an Builder.
- SettingsPanel: submitButtonLabel, successMessage, requireEmail,
  allowMultipleSubmissions, anonymous.
- field-defaults.ts: makeDefaultField(type) generiert je Typ sinnvolle
  Defaults — choice mit 2 Optionen, rating mit 5er-Skala, consent
  required + Standard-Text.
- Route /forms/[id] mit RoutePage-Wrapper.
- 38 neue i18n-Keys × 5 Locales (forms.builder.*).

Optimistic-UI: items-State wird lokal gepatcht vor store.update um
Type-Switches sofort zu rendern; field-array re-syncet bei
upstream-id-Änderung (multi-tab).

Re-applied from notes-space-context branch (cherry 7767e6761,
forms-only subset, ohne Parallel-Session context-removal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-28 23:21:27 +02:00
parent 75d9207ff2
commit f104a2bc35
12 changed files with 1414 additions and 0 deletions

View file

@ -23,5 +23,53 @@
"draft": "Entwurf",
"published": "Veröffentlicht",
"closed": "Geschlossen"
},
"builder": {
"routeTitle": "Formular bearbeiten",
"loading": "Lade ...",
"notFound": "Formular nicht gefunden.",
"backLink": "← Zurück zur Liste",
"back": "← Zurück",
"delete": "Löschen",
"deleteConfirm": "Formular \"{title}\" wirklich löschen?",
"statusGroupAria": "Status",
"titlePlaceholder": "Formular-Titel",
"titleAria": "Formular-Titel",
"descriptionPlaceholder": "Beschreibung (optional)",
"fields": {
"title": "Felder",
"count": "{n} Felder",
"empty": "Noch keine Felder. Wähle unten einen Typ."
},
"palette": {
"title": "Feld hinzufügen"
},
"field": {
"labelPlaceholder": "Feldfrage ...",
"labelAria": "Feldlabel",
"removeAria": "Feld löschen",
"required": "Pflichtfeld",
"lessOptions": " weniger",
"moreOptions": "+ mehr",
"helpPlaceholder": "Hilfetext (optional)",
"options": "Optionen",
"optionPlaceholder": "Option ...",
"optionRemoveAria": "Option löschen",
"addOption": "+ Option hinzufügen",
"ratingScale": "Skala",
"scale5": "15",
"scale10": "110",
"min": "Minimum",
"max": "Maximum",
"maxLength": "Max. Länge"
},
"settings": {
"title": "Einstellungen",
"submitLabel": "Beschriftung Absenden-Button",
"successMessage": "Bestätigungstext nach Absenden",
"requireEmail": "E-Mail-Adresse vom Absender abfragen",
"allowMultiple": "Mehrere Antworten pro Person erlauben",
"anonymous": "Anonym — Submitter-Daten nicht speichern"
}
}
}

View file

@ -23,5 +23,53 @@
"draft": "Draft",
"published": "Published",
"closed": "Closed"
},
"builder": {
"routeTitle": "Edit form",
"loading": "Loading ...",
"notFound": "Form not found.",
"backLink": "← Back to list",
"back": "← Back",
"delete": "Delete",
"deleteConfirm": "Really delete form \"{title}\"?",
"statusGroupAria": "Status",
"titlePlaceholder": "Form title",
"titleAria": "Form title",
"descriptionPlaceholder": "Description (optional)",
"fields": {
"title": "Fields",
"count": "{n} fields",
"empty": "No fields yet. Pick a type below."
},
"palette": {
"title": "Add field"
},
"field": {
"labelPlaceholder": "Field question ...",
"labelAria": "Field label",
"removeAria": "Delete field",
"required": "Required",
"lessOptions": " less",
"moreOptions": "+ more",
"helpPlaceholder": "Help text (optional)",
"options": "Options",
"optionPlaceholder": "Option ...",
"optionRemoveAria": "Delete option",
"addOption": "+ Add option",
"ratingScale": "Scale",
"scale5": "15",
"scale10": "110",
"min": "Minimum",
"max": "Maximum",
"maxLength": "Max length"
},
"settings": {
"title": "Settings",
"submitLabel": "Submit button label",
"successMessage": "Confirmation text after submit",
"requireEmail": "Ask submitter for email address",
"allowMultiple": "Allow multiple submissions per person",
"anonymous": "Anonymous — don't store submitter data"
}
}
}

View file

@ -23,5 +23,53 @@
"draft": "Borrador",
"published": "Publicado",
"closed": "Cerrado"
},
"builder": {
"routeTitle": "Editar formulario",
"loading": "Cargando ...",
"notFound": "Formulario no encontrado.",
"backLink": "← Volver a la lista",
"back": "← Atrás",
"delete": "Eliminar",
"deleteConfirm": "¿Eliminar el formulario \"{title}\"?",
"statusGroupAria": "Estado",
"titlePlaceholder": "Título del formulario",
"titleAria": "Título del formulario",
"descriptionPlaceholder": "Descripción (opcional)",
"fields": {
"title": "Campos",
"count": "{n} campos",
"empty": "Aún sin campos. Elige un tipo abajo."
},
"palette": {
"title": "Añadir campo"
},
"field": {
"labelPlaceholder": "Pregunta del campo ...",
"labelAria": "Etiqueta del campo",
"removeAria": "Eliminar campo",
"required": "Obligatorio",
"lessOptions": " menos",
"moreOptions": "+ más",
"helpPlaceholder": "Texto de ayuda (opcional)",
"options": "Opciones",
"optionPlaceholder": "Opción ...",
"optionRemoveAria": "Eliminar opción",
"addOption": "+ Añadir opción",
"ratingScale": "Escala",
"scale5": "15",
"scale10": "110",
"min": "Mínimo",
"max": "Máximo",
"maxLength": "Longitud máx."
},
"settings": {
"title": "Ajustes",
"submitLabel": "Texto del botón de envío",
"successMessage": "Mensaje de confirmación tras el envío",
"requireEmail": "Pedir correo electrónico al remitente",
"allowMultiple": "Permitir varias respuestas por persona",
"anonymous": "Anónimo — no guardar datos del remitente"
}
}
}

View file

@ -23,5 +23,53 @@
"draft": "Brouillon",
"published": "Publié",
"closed": "Clôturé"
},
"builder": {
"routeTitle": "Modifier le formulaire",
"loading": "Chargement ...",
"notFound": "Formulaire introuvable.",
"backLink": "← Retour à la liste",
"back": "← Retour",
"delete": "Supprimer",
"deleteConfirm": "Vraiment supprimer le formulaire \"{title}\" ?",
"statusGroupAria": "Statut",
"titlePlaceholder": "Titre du formulaire",
"titleAria": "Titre du formulaire",
"descriptionPlaceholder": "Description (facultatif)",
"fields": {
"title": "Champs",
"count": "{n} champs",
"empty": "Pas encore de champs. Choisis un type ci-dessous."
},
"palette": {
"title": "Ajouter un champ"
},
"field": {
"labelPlaceholder": "Question du champ ...",
"labelAria": "Libellé du champ",
"removeAria": "Supprimer le champ",
"required": "Obligatoire",
"lessOptions": " moins",
"moreOptions": "+ plus",
"helpPlaceholder": "Texte d'aide (facultatif)",
"options": "Options",
"optionPlaceholder": "Option ...",
"optionRemoveAria": "Supprimer l'option",
"addOption": "+ Ajouter une option",
"ratingScale": "Échelle",
"scale5": "15",
"scale10": "110",
"min": "Minimum",
"max": "Maximum",
"maxLength": "Longueur max."
},
"settings": {
"title": "Paramètres",
"submitLabel": "Libellé du bouton d'envoi",
"successMessage": "Message de confirmation après envoi",
"requireEmail": "Demander l'adresse e-mail au destinataire",
"allowMultiple": "Autoriser plusieurs réponses par personne",
"anonymous": "Anonyme — ne pas conserver les données du destinataire"
}
}
}

View file

@ -23,5 +23,53 @@
"draft": "Bozza",
"published": "Pubblicato",
"closed": "Chiuso"
},
"builder": {
"routeTitle": "Modifica modulo",
"loading": "Caricamento ...",
"notFound": "Modulo non trovato.",
"backLink": "← Torna alla lista",
"back": "← Indietro",
"delete": "Elimina",
"deleteConfirm": "Eliminare davvero il modulo \"{title}\"?",
"statusGroupAria": "Stato",
"titlePlaceholder": "Titolo del modulo",
"titleAria": "Titolo del modulo",
"descriptionPlaceholder": "Descrizione (facoltativa)",
"fields": {
"title": "Campi",
"count": "{n} campi",
"empty": "Ancora nessun campo. Scegli un tipo sotto."
},
"palette": {
"title": "Aggiungi campo"
},
"field": {
"labelPlaceholder": "Domanda del campo ...",
"labelAria": "Etichetta del campo",
"removeAria": "Elimina campo",
"required": "Obbligatorio",
"lessOptions": " meno",
"moreOptions": "+ altro",
"helpPlaceholder": "Testo di aiuto (facoltativo)",
"options": "Opzioni",
"optionPlaceholder": "Opzione ...",
"optionRemoveAria": "Elimina opzione",
"addOption": "+ Aggiungi opzione",
"ratingScale": "Scala",
"scale5": "15",
"scale10": "110",
"min": "Minimo",
"max": "Massimo",
"maxLength": "Lunghezza max."
},
"settings": {
"title": "Impostazioni",
"submitLabel": "Testo del pulsante di invio",
"successMessage": "Messaggio di conferma dopo l'invio",
"requireEmail": "Chiedi l'indirizzo e-mail al mittente",
"allowMultiple": "Permetti più risposte per persona",
"anonymous": "Anonimo — non memorizzare i dati del mittente"
}
}
}

View file

@ -0,0 +1,411 @@
<!--
FieldEditor — inline editor for a single FormField inside the
BuilderView. Type-specific config (options for choice fields,
min/max for number, ratingScale for rating) lives in a collapsible
"Erweitert" section to keep the default surface terse.
Save semantics: every edit calls `onchange(patch)` with a partial
patch the parent then merges into store state. The parent is
responsible for the persistence path (autosave-on-blur).
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import { FIELD_TYPE_LABELS } from '../types';
import type { FieldOption, FieldType, FormField } from '../types';
let {
field,
index,
onchange,
onremove,
}: {
field: FormField;
index: number;
onchange: (patch: Partial<FormField>) => void;
onremove: () => void;
} = $props();
let advancedOpen = $state(false);
function patchOptions(next: FieldOption[]) {
onchange({ options: next });
}
function addOption() {
const next: FieldOption[] = [...(field.options ?? []), { id: crypto.randomUUID(), label: '' }];
patchOptions(next);
}
function updateOption(optionId: string, label: string) {
const next = (field.options ?? []).map((o) => (o.id === optionId ? { ...o, label } : o));
patchOptions(next);
}
function removeOption(optionId: string) {
const next = (field.options ?? []).filter((o) => o.id !== optionId);
patchOptions(next);
}
function patchConfig<K extends keyof NonNullable<FormField['config']>>(
key: K,
value: NonNullable<FormField['config']>[K] | undefined
) {
const cfg = { ...(field.config ?? {}) };
if (value === undefined || value === null) {
delete cfg[key];
} else {
cfg[key] = value;
}
onchange({ config: cfg });
}
function setType(type: FieldType) {
// Switching type — drop type-specific config so we don't keep
// stale options/ratingScale dangling.
const patch: Partial<FormField> = { type };
if (type === 'single_choice' || type === 'multi_choice') {
if (!field.options || field.options.length === 0) {
patch.options = [
{ id: crypto.randomUUID(), label: 'Option 1' },
{ id: crypto.randomUUID(), label: 'Option 2' },
];
}
} else {
patch.options = undefined;
}
if (type !== 'rating' && type !== 'short_text' && type !== 'long_text' && type !== 'number') {
patch.config = undefined;
}
if (type === 'rating' && !field.config?.ratingScale) {
patch.config = { ratingScale: 5 };
}
onchange(patch);
}
</script>
<div class="field-editor" data-type={field.type}>
<div class="header">
<span class="index">{index + 1}.</span>
<input
type="text"
class="label-input"
value={field.label}
oninput={(e) => onchange({ label: (e.currentTarget as HTMLInputElement).value })}
placeholder={$_('forms.builder.field.labelPlaceholder', { default: 'Feldfrage ...' })}
aria-label={$_('forms.builder.field.labelAria', { default: 'Feldlabel' })}
/>
<button
type="button"
class="remove"
onclick={onremove}
aria-label={$_('forms.builder.field.removeAria', { default: 'Feld löschen' })}>×</button
>
</div>
<div class="row">
<select
class="type-select"
value={field.type}
onchange={(e) => setType((e.currentTarget as HTMLSelectElement).value as FieldType)}
>
{#each Object.entries(FIELD_TYPE_LABELS) as [type, labels]}
<option value={type}>{labels.de}</option>
{/each}
</select>
<label class="required-toggle">
<input
type="checkbox"
checked={field.required}
onchange={(e) => onchange({ required: (e.currentTarget as HTMLInputElement).checked })}
/>
<span>{$_('forms.builder.field.required', { default: 'Pflichtfeld' })}</span>
</label>
<button
type="button"
class="advanced-toggle"
onclick={() => (advancedOpen = !advancedOpen)}
aria-expanded={advancedOpen}
>
{advancedOpen
? $_('forms.builder.field.lessOptions', { default: ' weniger' })
: $_('forms.builder.field.moreOptions', { default: '+ mehr' })}
</button>
</div>
{#if advancedOpen}
<div class="advanced">
<input
type="text"
class="help-input"
value={field.helpText ?? ''}
oninput={(e) => {
const val = (e.currentTarget as HTMLInputElement).value;
onchange({ helpText: val.length > 0 ? val : undefined });
}}
placeholder={$_('forms.builder.field.helpPlaceholder', {
default: 'Hilfetext (optional)',
})}
/>
{#if field.type === 'single_choice' || field.type === 'multi_choice'}
<div class="options-block">
<p class="block-label">
{$_('forms.builder.field.options', { default: 'Optionen' })}
</p>
{#each field.options ?? [] as option (option.id)}
<div class="option-row">
<input
type="text"
value={option.label}
oninput={(e) =>
updateOption(option.id, (e.currentTarget as HTMLInputElement).value)}
placeholder={$_('forms.builder.field.optionPlaceholder', {
default: 'Option ...',
})}
/>
<button
type="button"
class="option-remove"
onclick={() => removeOption(option.id)}
aria-label={$_('forms.builder.field.optionRemoveAria', {
default: 'Option löschen',
})}>×</button
>
</div>
{/each}
<button type="button" class="option-add" onclick={addOption}>
{$_('forms.builder.field.addOption', { default: '+ Option hinzufügen' })}
</button>
</div>
{:else if field.type === 'rating'}
<label class="config-row">
<span>{$_('forms.builder.field.ratingScale', { default: 'Skala' })}</span>
<select
value={String(field.config?.ratingScale ?? 5)}
onchange={(e) =>
patchConfig(
'ratingScale',
Number((e.currentTarget as HTMLSelectElement).value) as 5 | 10
)}
>
<option value="5">{$_('forms.builder.field.scale5', { default: '15' })}</option>
<option value="10">{$_('forms.builder.field.scale10', { default: '110' })}</option>
</select>
</label>
{:else if field.type === 'number'}
<div class="config-grid">
<label class="config-row">
<span>{$_('forms.builder.field.min', { default: 'Minimum' })}</span>
<input
type="number"
value={field.config?.min ?? ''}
oninput={(e) => {
const raw = (e.currentTarget as HTMLInputElement).value;
patchConfig('min', raw === '' ? undefined : Number(raw));
}}
/>
</label>
<label class="config-row">
<span>{$_('forms.builder.field.max', { default: 'Maximum' })}</span>
<input
type="number"
value={field.config?.max ?? ''}
oninput={(e) => {
const raw = (e.currentTarget as HTMLInputElement).value;
patchConfig('max', raw === '' ? undefined : Number(raw));
}}
/>
</label>
</div>
{:else if field.type === 'short_text' || field.type === 'long_text'}
<div class="config-grid">
<label class="config-row">
<span>{$_('forms.builder.field.maxLength', { default: 'Max. Länge' })}</span>
<input
type="number"
value={field.config?.maxLength ?? ''}
min="1"
oninput={(e) => {
const raw = (e.currentTarget as HTMLInputElement).value;
patchConfig('maxLength', raw === '' ? undefined : Number(raw));
}}
/>
</label>
</div>
{/if}
</div>
{/if}
</div>
<style>
.field-editor {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: rgb(255 255 255 / 0.03);
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 0.5rem;
}
.header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.index {
min-width: 1.5rem;
font-variant-numeric: tabular-nums;
color: rgb(255 255 255 / 0.45);
font-size: 0.875rem;
}
.label-input {
flex: 1;
padding: 0.5rem 0.625rem;
background: rgb(255 255 255 / 0.04);
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 0.375rem;
color: inherit;
font-size: 0.9375rem;
font-weight: 500;
}
.label-input:focus {
outline: none;
border-color: rgb(20 184 166 / 0.5);
}
.remove,
.option-remove {
width: 1.75rem;
height: 1.75rem;
padding: 0;
display: inline-flex;
justify-content: center;
align-items: center;
background: transparent;
color: rgb(255 255 255 / 0.4);
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 0.25rem;
font-size: 1rem;
cursor: pointer;
}
.remove:hover,
.option-remove:hover {
color: rgb(248 113 113);
border-color: rgb(248 113 113 / 0.4);
}
.row {
display: flex;
align-items: center;
gap: 0.625rem;
flex-wrap: wrap;
}
.type-select,
.advanced .help-input,
.option-row input,
.config-row input,
.config-row select {
padding: 0.375rem 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;
}
.type-select {
max-width: 220px;
}
.required-toggle {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
color: rgb(255 255 255 / 0.7);
cursor: pointer;
}
.advanced-toggle {
margin-left: auto;
padding: 0.25rem 0.5rem;
background: transparent;
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 0.25rem;
color: rgb(255 255 255 / 0.55);
font-size: 0.75rem;
cursor: pointer;
}
.advanced {
display: flex;
flex-direction: column;
gap: 0.625rem;
margin-top: 0.25rem;
}
.help-input {
width: 100%;
}
.options-block .block-label {
margin: 0 0 0.375rem;
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgb(255 255 255 / 0.4);
}
.option-row {
display: flex;
align-items: center;
gap: 0.375rem;
margin-bottom: 0.25rem;
}
.option-row input {
flex: 1;
}
.option-add {
margin-top: 0.25rem;
padding: 0.375rem 0.625rem;
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;
}
.option-add:hover {
background: rgb(20 184 166 / 0.2);
}
.config-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.config-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.75rem;
color: rgb(255 255 255 / 0.55);
}
.config-row input,
.config-row select {
width: 100%;
}
</style>

View file

@ -0,0 +1,110 @@
<!--
FieldPalette — picker that emits one of the 11 field-types when the
user clicks an entry. Used inside the BuilderView between the field
list and any preview area.
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import { FIELD_TYPE_LABELS } from '../types';
import type { FieldType } from '../types';
let { onpick }: { onpick: (type: FieldType) => void } = $props();
const TYPES: FieldType[] = [
'short_text',
'long_text',
'single_choice',
'multi_choice',
'number',
'date',
'email',
'yes_no',
'rating',
'consent',
'section',
];
const ICONS: Record<FieldType, string> = {
short_text: 'Aa',
long_text: '¶',
single_choice: '○',
multi_choice: '☑',
number: '#',
date: '📅',
email: '@',
yes_no: '?',
rating: '★',
consent: '✓',
section: '—',
};
</script>
<div class="palette">
<p class="palette-title">
{$_('forms.builder.palette.title', { default: 'Feld hinzufügen' })}
</p>
<div class="palette-grid">
{#each TYPES as type}
<button type="button" class="palette-btn" onclick={() => onpick(type)}>
<span class="icon">{ICONS[type]}</span>
<span class="label">{FIELD_TYPE_LABELS[type].de}</span>
</button>
{/each}
</div>
</div>
<style>
.palette {
padding: 0.875rem;
background: rgb(255 255 255 / 0.03);
border: 1px solid rgb(255 255 255 / 0.06);
border-radius: 0.5rem;
}
.palette-title {
margin: 0 0 0.625rem;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgb(255 255 255 / 0.5);
}
.palette-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.375rem;
}
.palette-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.625rem;
background: rgb(255 255 255 / 0.03);
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 0.375rem;
color: inherit;
font-size: 0.8125rem;
cursor: pointer;
text-align: left;
}
.palette-btn:hover {
background: rgb(255 255 255 / 0.06);
border-color: rgb(255 255 255 / 0.16);
}
.icon {
display: inline-flex;
justify-content: center;
align-items: center;
width: 1.25rem;
height: 1.25rem;
font-weight: 600;
color: rgb(20 184 166 / 0.85);
}
.label {
flex: 1;
}
</style>

View file

@ -0,0 +1,147 @@
<!--
SettingsPanel — form-level settings (submit button, success message,
email-required, multiple-submissions). The visibility/share controls
land in M4 alongside the @mana/shared-privacy wiring.
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import type { FormSettings } from '../types';
let {
settings,
onchange,
}: {
settings: FormSettings;
onchange: (patch: Partial<FormSettings>) => void;
} = $props();
</script>
<div class="settings-panel">
<p class="panel-title">
{$_('forms.builder.settings.title', { default: 'Einstellungen' })}
</p>
<label class="setting-row">
<span class="setting-label">
{$_('forms.builder.settings.submitLabel', { default: 'Beschriftung Absenden-Button' })}
</span>
<input
type="text"
value={settings.submitButtonLabel}
onblur={(e) =>
onchange({
submitButtonLabel: (e.currentTarget as HTMLInputElement).value.trim() || 'Senden',
})}
/>
</label>
<label class="setting-row">
<span class="setting-label">
{$_('forms.builder.settings.successMessage', {
default: 'Bestätigungstext nach Absenden',
})}
</span>
<textarea
rows="2"
value={settings.successMessage}
onblur={(e) =>
onchange({
successMessage: (e.currentTarget as HTMLTextAreaElement).value.trim() || 'Danke!',
})}
></textarea>
</label>
<label class="setting-toggle">
<input
type="checkbox"
checked={settings.requireEmail}
onchange={(e) => onchange({ requireEmail: (e.currentTarget as HTMLInputElement).checked })}
/>
<span>
{$_('forms.builder.settings.requireEmail', {
default: 'E-Mail-Adresse vom Absender abfragen',
})}
</span>
</label>
<label class="setting-toggle">
<input
type="checkbox"
checked={settings.allowMultipleSubmissions}
onchange={(e) =>
onchange({
allowMultipleSubmissions: (e.currentTarget as HTMLInputElement).checked,
})}
/>
<span>
{$_('forms.builder.settings.allowMultiple', {
default: 'Mehrere Antworten pro Person erlauben',
})}
</span>
</label>
<label class="setting-toggle">
<input
type="checkbox"
checked={settings.anonymous}
onchange={(e) => onchange({ anonymous: (e.currentTarget as HTMLInputElement).checked })}
/>
<span>
{$_('forms.builder.settings.anonymous', {
default: 'Anonym — Submitter-Daten nicht speichern',
})}
</span>
</label>
</div>
<style>
.settings-panel {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.875rem;
background: rgb(255 255 255 / 0.03);
border: 1px solid rgb(255 255 255 / 0.06);
border-radius: 0.5rem;
}
.panel-title {
margin: 0 0 0.25rem;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgb(255 255 255 / 0.5);
}
.setting-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.setting-label {
font-size: 0.75rem;
color: rgb(255 255 255 / 0.55);
}
.setting-row input,
.setting-row textarea {
padding: 0.5rem 0.625rem;
background: rgb(255 255 255 / 0.04);
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 0.375rem;
color: inherit;
font-size: 0.875rem;
resize: vertical;
font-family: inherit;
}
.setting-toggle {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: rgb(255 255 255 / 0.8);
cursor: pointer;
}
</style>

View file

@ -16,6 +16,9 @@ export {
// ─── Collections ─────────────────────────────────────────
export { formTable, formResponseTable } from './collections';
// ─── Lib ─────────────────────────────────────────────────
export { makeDefaultField } from './lib/field-defaults';
// ─── Types ───────────────────────────────────────────────
export {
FIELD_TYPE_LABELS,

View file

@ -0,0 +1,73 @@
import type { FieldType, FormField } from '../types';
/**
* Build a fresh `FormField` for the given type with sensible defaults.
* Generates a fresh UUID and the type-specific config so the builder
* can append-and-go without touching the underlying schema engine.
*/
export function makeDefaultField(type: FieldType): FormField {
const id = crypto.randomUUID();
const base: FormField = {
id,
type,
label: defaultLabel(type),
required: false,
};
switch (type) {
case 'single_choice':
case 'multi_choice':
return {
...base,
options: [
{ id: crypto.randomUUID(), label: 'Option 1' },
{ id: crypto.randomUUID(), label: 'Option 2' },
],
};
case 'rating':
return { ...base, config: { ratingScale: 5 } };
case 'short_text':
return { ...base, config: { maxLength: 120 } };
case 'long_text':
return { ...base, config: { maxLength: 2000 } };
case 'number':
return { ...base, config: {} };
case 'consent':
return {
...base,
required: true,
label: 'Ich stimme der Datenverarbeitung zu.',
};
case 'section':
return { ...base, label: 'Abschnitt' };
default:
return base;
}
}
function defaultLabel(type: FieldType): string {
switch (type) {
case 'short_text':
return 'Kurze Frage';
case 'long_text':
return 'Längere Frage';
case 'single_choice':
return 'Wähle eine Option';
case 'multi_choice':
return 'Wähle eine oder mehrere Optionen';
case 'number':
return 'Zahl';
case 'date':
return 'Datum';
case 'email':
return 'E-Mail-Adresse';
case 'yes_no':
return 'Ja oder Nein?';
case 'rating':
return 'Wie würdest du das bewerten?';
case 'section':
return 'Abschnitt';
case 'consent':
return 'Einwilligung';
}
}

View file

@ -0,0 +1,382 @@
<!--
Forms — BuilderView (M2)
Edits the title, description, fields, and settings of a single form.
Saves on blur (text fields) or change (settings, field-edits, drag-
reorder). The store is the source of truth — local state mirrors
`entry` and re-syncs whenever a different form id loads in.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { dndzone, SOURCES } from 'svelte-dnd-action';
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 { makeDefaultField } from '../lib/field-defaults';
import FieldEditor from '../components/FieldEditor.svelte';
import FieldPalette from '../components/FieldPalette.svelte';
import SettingsPanel from '../components/SettingsPanel.svelte';
let { entry }: { entry: Form } = $props();
/* svelte-ignore state_referenced_locally */
let title = $state(entry.title);
/* svelte-ignore state_referenced_locally */
let description = $state(entry.description ?? '');
/* svelte-ignore state_referenced_locally */
let items = $state<FormField[]>(entry.fields.slice());
/* svelte-ignore state_referenced_locally */
let lastSeenId = $state(entry.id);
$effect(() => {
if (entry.id !== lastSeenId) {
lastSeenId = entry.id;
title = entry.title;
description = entry.description ?? '';
items = entry.fields.slice();
}
});
// Re-sync the field array when an upstream change rewrites it
// (server pull, autosave from another tab) — guard against
// clobbering an in-progress drag by comparing ids only.
$effect(() => {
const upstreamIds = entry.fields.map((f) => f.id).join(',');
const localIds = items.map((f) => f.id).join(',');
if (upstreamIds !== localIds) {
items = entry.fields.slice();
}
});
async function saveTitle() {
const next = title.trim();
if (next && next !== entry.title) {
await formsStore.updateForm(entry.id, { title: next });
} else if (!next) {
title = entry.title;
}
}
async function saveDescription() {
const next = description.trim();
const current = entry.description ?? '';
if (next !== current) {
await formsStore.updateForm(entry.id, { description: next.length > 0 ? next : null });
}
}
async function patchField(fieldId: string, patch: Partial<FormField>) {
// Update local state optimistically so type-changes etc. render
// without round-tripping through the store. The store update
// then lands in the next live-query tick.
items = items.map((f) => (f.id === fieldId ? { ...f, ...patch, id: f.id } : f));
await formsStore.updateField(entry.id, fieldId, patch);
}
async function removeField(fieldId: string) {
items = items.filter((f) => f.id !== fieldId);
await formsStore.removeField(entry.id, fieldId);
}
async function pickField(type: FormField['type']) {
const field = makeDefaultField(type);
items = [...items, field];
await formsStore.addField(entry.id, field);
}
function handleDndConsider(e: CustomEvent<{ items: FormField[] }>) {
items = e.detail.items;
}
async function handleDndFinalize(
e: CustomEvent<{ items: FormField[]; info: { source: string } }>
) {
items = e.detail.items;
if (e.detail.info.source === SOURCES.POINTER) {
await formsStore.reorderFields(
entry.id,
items.map((f) => f.id)
);
}
}
async function patchSettings(patch: Partial<FormSettings>) {
await formsStore.updateForm(entry.id, {
settings: { ...entry.settings, ...patch },
});
}
async function setStatus(status: FormStatus) {
await formsStore.setStatus(entry.id, status);
}
async function deleteForm() {
const ok = confirm(
$_('forms.builder.deleteConfirm', {
default: 'Formular "{title}" wirklich löschen?',
values: { title: entry.title },
})
);
if (!ok) return;
await formsStore.deleteForm(entry.id);
goto('/forms');
}
</script>
<div class="builder">
<div class="top-bar">
<button type="button" class="back" onclick={() => goto('/forms')}>
{$_('forms.builder.back', { default: '← Zurück' })}
</button>
<div
class="status-pills"
role="group"
aria-label={$_('forms.builder.statusGroupAria', { default: 'Status' })}
>
{#each Object.keys(FORM_STATUS_LABELS) as st}
<button
type="button"
class="status-pill"
class:active={entry.status === st}
data-status={st}
onclick={() => setStatus(st as FormStatus)}
>
{FORM_STATUS_LABELS[st as FormStatus].de}
</button>
{/each}
</div>
<button type="button" class="delete" onclick={deleteForm}>
{$_('forms.builder.delete', { default: 'Löschen' })}
</button>
</div>
<div class="meta">
<input
type="text"
class="title-input"
bind:value={title}
onblur={saveTitle}
placeholder={$_('forms.builder.titlePlaceholder', { default: 'Formular-Titel' })}
aria-label={$_('forms.builder.titleAria', { default: 'Formular-Titel' })}
/>
<textarea
class="description-input"
rows="2"
bind:value={description}
onblur={saveDescription}
placeholder={$_('forms.builder.descriptionPlaceholder', {
default: 'Beschreibung (optional)',
})}
></textarea>
</div>
<section class="fields-section">
<header class="section-header">
<h2>{$_('forms.builder.fields.title', { default: 'Felder' })}</h2>
<span class="count">
{$_('forms.builder.fields.count', {
default: '{n} Felder',
values: { n: items.length },
})}
</span>
</header>
{#if items.length === 0}
<p class="empty-fields">
{$_('forms.builder.fields.empty', {
default: 'Noch keine Felder. Wähle unten einen Typ.',
})}
</p>
{:else}
<div
use:dndzone={{ items, flipDurationMs: 180, dropTargetStyle: {} }}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
class="fields-list"
>
{#each items as field, i (field.id)}
<div animate:flip={{ duration: 180 }}>
<FieldEditor
{field}
index={i}
onchange={(patch) => patchField(field.id, patch)}
onremove={() => removeField(field.id)}
/>
</div>
{/each}
</div>
{/if}
<FieldPalette onpick={pickField} />
</section>
<section class="settings-section">
<SettingsPanel settings={entry.settings} onchange={patchSettings} />
</section>
</div>
<style>
.builder {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
max-width: 880px;
margin: 0 auto;
}
.top-bar {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.back {
padding: 0.375rem 0.625rem;
background: rgb(255 255 255 / 0.04);
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 0.375rem;
color: inherit;
font-size: 0.8125rem;
cursor: pointer;
}
.back:hover {
background: rgb(255 255 255 / 0.07);
}
.status-pills {
display: inline-flex;
gap: 0.25rem;
}
.status-pill {
padding: 0.375rem 0.625rem;
background: rgb(255 255 255 / 0.04);
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 0.375rem;
color: rgb(255 255 255 / 0.55);
font-size: 0.8125rem;
cursor: pointer;
}
.status-pill:hover {
background: rgb(255 255 255 / 0.07);
}
.status-pill.active[data-status='draft'] {
background: rgb(255 255 255 / 0.08);
color: rgb(255 255 255 / 0.85);
border-color: rgb(255 255 255 / 0.18);
}
.status-pill.active[data-status='published'] {
background: rgb(20 184 166 / 0.18);
color: rgb(94 234 212);
border-color: rgb(20 184 166 / 0.4);
}
.status-pill.active[data-status='closed'] {
background: rgb(255 255 255 / 0.04);
color: rgb(255 255 255 / 0.45);
border-color: rgb(255 255 255 / 0.12);
}
.delete {
margin-left: auto;
padding: 0.375rem 0.625rem;
background: transparent;
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 0.375rem;
color: rgb(255 255 255 / 0.5);
font-size: 0.8125rem;
cursor: pointer;
}
.delete:hover {
color: rgb(248 113 113);
border-color: rgb(248 113 113 / 0.4);
}
.meta {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.title-input {
padding: 0.625rem 0.875rem;
background: rgb(255 255 255 / 0.04);
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 0.5rem;
color: inherit;
font-size: 1.125rem;
font-weight: 600;
}
.title-input:focus {
outline: none;
border-color: rgb(20 184 166 / 0.5);
}
.description-input {
padding: 0.5rem 0.875rem;
background: rgb(255 255 255 / 0.04);
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 0.5rem;
color: inherit;
font-size: 0.875rem;
font-family: inherit;
resize: vertical;
}
.description-input:focus {
outline: none;
border-color: rgb(255 255 255 / 0.18);
}
.fields-section,
.settings-section {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.section-header {
display: flex;
align-items: baseline;
gap: 0.625rem;
}
.section-header h2 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
}
.count {
font-size: 0.75rem;
color: rgb(255 255 255 / 0.45);
}
.empty-fields {
padding: 1rem;
text-align: center;
color: rgb(255 255 255 / 0.45);
background: rgb(255 255 255 / 0.02);
border: 1px dashed rgb(255 255 255 / 0.1);
border-radius: 0.5rem;
font-size: 0.875rem;
}
.fields-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
</style>

View file

@ -0,0 +1,48 @@
<script lang="ts">
import { page } from '$app/state';
import { _ } from 'svelte-i18n';
import BuilderView from '$lib/modules/forms/views/BuilderView.svelte';
import { useAllForms } from '$lib/modules/forms/queries';
import { RoutePage } from '$lib/components/shell';
const forms$ = useAllForms();
const entry = $derived(forms$.value.find((f) => f.id === page.params.id));
</script>
<svelte:head>
<title
>{entry?.title ?? $_('forms.builder.routeTitle', { default: 'Formular bearbeiten' })} - Mana</title
>
</svelte:head>
<RoutePage
appId="forms"
backHref="/forms"
title={$_('forms.builder.routeTitle', { default: 'Formular bearbeiten' })}
>
{#if forms$.loading}
<p class="state">{$_('forms.builder.loading', { default: 'Lade ...' })}</p>
{:else if !entry}
<div class="state">
<p>{$_('forms.builder.notFound', { default: 'Formular nicht gefunden.' })}</p>
<a href="/forms">{$_('forms.builder.backLink', { default: '← Zurück zur Liste' })}</a>
</div>
{:else}
<BuilderView {entry} />
{/if}
</RoutePage>
<style>
.state {
text-align: center;
padding: 3rem 1rem;
color: rgb(255 255 255 / 0.5);
}
.state a {
display: inline-block;
margin-top: 0.5rem;
color: rgb(94 234 212);
text-decoration: none;
}
</style>