Form Builder Page
forms-leads/ui/form-builder-pagelive
| Kind | frontend |
|---|---|
| Surface | SPA |
| Maturity | prod |
| Reusable | extract |
| Design system | alchemy-ui |
| Stack | react-19, react-router-7, tailwind-3.4, alchemy-ui, axios-1 |
| Depends on | _shared/design-system-and-tokens, _shared/react-app-skeleton, _shared/error-envelope |
| Renders | forms-leads/form-templates-and-builder, forms-leads/public-submission-and-widget, forms-leads/form-sessions-and-abandonment-recovery, forms-leads/lead-capture-and-crm-sync, forms-leads/form-spam-and-abuse-protection |
| Entry | alchemy_labs/frontend/src/pages/FormBuilderPage.tsx |
| Reference implementation |
|
| Measured on | alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12 |
| Version | 1 |
Purpose
The tenant-scoped editor at /forms/builder where a workspace user builds a form template end to end: create or open one from a card grid, add and configure fields from a 14-type palette (label, placeholder, help text, required, choice lists, slider bounds), split it across multiple pages, preview exactly what a visitor will see (including the multi-page progress bar and the Zurück/Weiter/Absenden buttons), save it, and copy the embed snippet or open the hosted page for any public template. The invariant that makes it trustworthy: the field and page structure a tenant assembles here is the literal fields/settings.pages array POST/PUT /api/forms/templates persists, unchanged, so what this editor shows is exactly what the public widget it makes visible (forms-leads/public-submission-and-widget) will later render and validate against, not an approximation.
It makes visible four backend recipes it builds the input for: the form-template CRUD and field/page persistence this page directly calls (form-templates-and-builder), the public submission and embed widget that renders whatever this page saved (public-submission-and-widget), the session/abandonment tracking a live form runs once embedded (form-sessions-and-abandonment-recovery), and the lead capture and CRM sync a submission feeds into (lead-capture-and-crm-sync); it also indirectly shapes what form-spam-and-abuse-protection has to defend, though this page never calls that recipe's endpoints itself.
When to use
Trigger phrases: "the form builder", "form editor", "add a field", "multi-page form", "embed code", "form templates list", "Formular erstellen", "Feld hinzufügen". Surface: SPA, a private Labs page behind PrivateRoute, reached at /forms/builder and, for a direct deep link into the editor, /forms/builder/:formId (App.tsx lines 619 to 634, both routes lazy-load the same FormBuilderPage component); "Forms" under the "Forms" domain in the shell (nav.tsx line 90 to 94 at 62ba4fc, live).
Not this recipe: /forms/new, a separate page (TallyFormBuilder, App.tsx line 666) not covered here; the hosted page a saved template actually renders at (/api/f/{code}/page, the embed iframe's src), the session-tracked widget script (form-widget.js) and the submission endpoint themselves, all public-submission-and-widget's; the form-template CRUD routes this page calls (GET/POST/PUT/DELETE /api/forms/templates), form-templates-and-builder's own surface, not re-described here beyond the four calls this page makes (API surface); the AI form editor and redirect-rules endpoints mounted on the same router prefix (redirect_ai_router, form_editor_ai_router, app_fastapi.py 935, 942), never called from this page; the /forms and /forms/:formId list/detail pages (Forms.tsx), a different, read-mostly view this page's own "Bearbeiten" button does not route to (it opens the same page's editor in place instead).
Stack assumptions
S1 tokens and S2 skeleton assumed; pinned per the source at 62ba4fc: react 19.1.1, react-router-dom 7.8.2, tailwindcss 3.4.17, lucide-react, axios 1.11.0. Component library: @prima-modules/alchemy-ui, of which the page uses Button, Heading, Text, Input, Textarea, Select, Card, CardContent, Spinner, Badge, Checkbox, Toggle, RadioGroup and Divider. Data-fetch style: the api singleton (S2), the Bearer header from accessToken in localStorage, plus the workspace header S2's interceptor attaches (Security and tenancy); useToast() from the same alchemy-ui package for the four failure paths (Application logic). Copy language: German, de-CH, every string in the file.
Data model
Client state (FormBuilderPage.tsx lines 91 to 108), all in the page component:
interface FormField { // one row of a template's `fields` array
id?: string; field_type: string; label: string; name: string;
placeholder?: string; help_text?: string; required: boolean; order: number;
page_id?: string; // set only when the template is multi-page
options?: { choices?: { label: string; value: string }[]; min?: number; max?: number; step?: number };
validation_rules?: any;
}
interface FormPage { id: string; title: string; description?: string; order: number; }
interface FormTemplate { // GET /api/forms/templates row, unchanged from the route's JSON
id?: string; name: string; description?: string; category?: string; is_public?: boolean;
embed_code?: string;
settings?: { multi_page?: boolean; pages?: FormPage[]; progress_bar?: { style: string; position: string } };
fields: FormField[];
}
forms: FormTemplate[]; loading: boolean (initially true); saving: boolean; isEditing: boolean;
previewMode: boolean; copiedEmbed: boolean; activePageId: string ('page_1' initially);
formName, formDescription: string; formCategory: string ('lead_magnet' initially); isPublic: boolean (true initially);
isMultiPage: boolean; pages: FormPage[] ([{id:'page_1', title:'Page 1', order:0}] initially);
fields: FormField[]; editingFieldIndex: number | null; selectedFormId: string | null;
FIELD_TYPES, the 14-entry palette the left sidebar renders from and every field-list row looks its icon and label up in, is a literal in the page (lines 70 to 85), not fetched: text, textarea, email, phone, url, number, date, time, select, multiselect, radio, checkbox, slider, file, each with a lucide-react icon and a Tailwind color class.
The demo's props (demo.tsx): { viewer, workspace, status: 'loading' | 'ok', forms, isEditing?, formName?, formDescription?, formCategory?, isPublic?, isMultiPage?, pages?, fields?, activePageId?, editingFieldIndex?, previewMode?, copiedEmbed?, saving? }; every one of isEditing through saving only seeds a useState (Frontend) since the source's own add/remove/reorder/toggle logic touches no network and is ported live rather than frozen.
Browser storage: nothing of its own, relies on S2's accessToken/refreshToken and currentWorkspaceId in localStorage (Security and tenancy).
API surface
Contracts consumed, one read and two writes, all on forms-leads/form-templates-and-builder's router (prefix="/api", forms_router.py prefix="/forms", mounted app_fastapi.py 928):
| Call (line) | Route | Auth / tenancy | Parsed | Rendered as |
|---|---|---|---|---|
api.get('/api/forms/templates') (126) | GET /api/forms/templates | get_current_user + WorkspaceID (api/dependencies.py, header/state-derived, Security and tenancy) | the whole array into forms, each row TemplateService.to_dict() plus a grouped submission_count (forms_router.py 170 to 190) | the list-view card grid; a failure is not caught into a user-visible state (Gotchas) |
api.post('/api/forms/templates', formData) | api.put('/api/forms/templates/{id}', formData) (200, 202) | POST /api/forms/templates (201) | PUT /api/forms/templates/{id} (200, 404 unauthorized/not found) | same | on success, fetchForms() re-runs and the editor closes (setIsEditing(false)); no body is parsed beyond that | nothing new on screen beyond the refreshed list; on failure, a fully-parsed error string (below) reaches toast.error |
api.delete('/api/forms/templates/{id}') (244) | DELETE /api/forms/templates/{id} (204, 404 unauthorized/not found) | same | nothing (204); fetchForms() re-runs | the card disappears from the refetched grid; on failure, the fixed sentence "Formular konnte nicht gelöscht werden" |
Error bodies as this page reads them: unlike billing-credits/ui/subscription-manager's four catch blocks (which all read err.response?.data?.detail alone against an envelope keyed error, losing every server message), saveForm's catch (208 to 234) checks data.detail first (array-aware: Array.isArray(detail) joins each .msg/.message, an object falls back to the same, a string is used as-is), then data.error, then data.message, then stringifies the whole body; deleteForm's catch (246 to 249) does not, it always shows the fixed "Formular konnte nicht gelöscht werden" regardless of what the server actually said. Both are toast.error(...) calls (useToast(), alchemy-ui/molecules/Toast.tsx), not inline cards. No WebSocket, no postMessage.
Application logic
FormBuilderPagemounts withloading: true,forms: []; oneuseEffectfiresfetchForms()(110 to 112, 123 to 133). Whileloading, the page returns<Navigation><Spinner lg/>"Formulare werden geladen..."</Navigation>(367 to 378).- A second
useEffect(114 to 121), keyed on[formId, forms], opens the editor on a matching template whenever the page is reached at/forms/builder/:formIdand the list has loaded, by callingeditFormwith the found row. fetchFormscallsGET /api/forms/templates, setsformson success; itscatchonlyconsole.errors (129), setting nothing else, so a failed read leavesformsat[]and looks identical to a genuinely empty workspace (Gotchas).loadinggoes false either way (finally, 131).startNewForm(135 to 147) andeditForm(149 to 161) both reset every editor field from scratch or from the clicked template respectively, thensetIsEditing(true),setPreviewMode(false); neither touches the network.- Adding a field (
addField, 252 to 276): pushes a newFormFieldwithorder: fields.lengthand, when multi-page,page_id: activePageId;select/multiselect/radiostart with two placeholder choices;sliderstarts with{min:0, max:10, step:1}; the new field is immediately expanded (setEditingFieldIndex(fields.length)). - Reordering (
moveField, 289 to 305) swaps two entries within the current page's field list, not the whole array, by mapping the local page-scoped index back to its global index infields: a field on another page is never touched by an up/down click on this page. - Removing a page (
removePage, 313 to 321) is a no-op below one page (pages.length <= 1); otherwise it drops the page, drops every field whosepage_idmatches it, and if the removed page was active, switchesactivePageIdto the new first page. saveForm(163 to 238): first a client-side check rejects any field with a blank/whitespace-onlylabelwithtoast.error('Alle Felder brauchen eine Beschriftung...'), no request sent; otherwise it derives anamefor every field from its label (toLowerCase().replace(/[^a-z0-9]+/g, '_'), trimmed of leading/trailing underscores, falling back tofield_${index+1}) when the field's ownnameis blank, setspage_idonly whenisMultiPage, stripsorder/page_idimplicitly by overwriting them, thenPOSTs (new) orPUTs (selectedFormIdset) the whole template; on success it refetches and closes the editor.deleteForm(240 to 250): a nativeconfirm()first; on confirm,DELETEs and refetches.copyEmbedCode(357 to 361): writesgetEmbedCode(form)(an<iframe>snippet,https://opus.alchemy.zuerich/api/f/{code}/page) to the clipboard and flipscopiedEmbedtrue for two seconds (setTimeout).copiedEmbedis one boolean for the whole page, not keyed by form id (Gotchas).- Preview mode (
previewMode, toggled by the top-bar "Vorschau" button) mounts a live read-only rendering ofcurrentPageFieldsin the right sidebar, including the multi-page progress bar and Zurück/Weiter/Absenden buttons; none of the nine field-type renderers there is wired to anything (noonChange, no submit).
Invariants
- Nothing the editor assembles reaches the server piecemeal:
saveFormis the only write that persists field or page structure, and it sends the entirefields/settings.pagesarrays every time, so a save always fully overwrites, never patches, a template's structure. - A field's
nameis never silently blank:saveFormguarantees one, generated from the label when the tenant left it empty, before the request leaves (step 8). - A page's fields disappear with the page:
removePagefiltersfieldsbypage_idin the same call that filterspages, so no field is ever left pointing at a page that no longer exists. - The preview is exactly what a visitor will see, never an approximation of it: the right sidebar renders the same
currentPageFieldsarray the canvas edits, live, with no separate preview-fetch or preview-specific transform. - A destructive action confirms first: only
deleteFormcallswindow.confirm; adding, removing and reordering fields or pages never do, because none of them is a write.
Frontend
The component, rebuilt from the demo in the kit with the design system the recipe names, one frame per state. What is in the frames is what the recipe describes and what Primacode builds from; it is not a picture of the running application.
loading server rendered, hydrated in view list-empty server rendered, hydrated in view list-populated server rendered, hydrated in view embed-code-copied server rendered, hydrated in view editor-new-form-no-fields server rendered, hydrated in view editor-form-with-fields server rendered, hydrated in view editor-multi-page server rendered, hydrated in view preview-mode server rendered, hydrated in view forms-leads/ui/form-builder-page/demo.tsx
/**
* Live demo for forms-leads/ui/form-builder-page.
*
* Labs' FormBuilderPage.tsx (main, 62ba4fc, 983 lines): one read
* (GET /api/forms/templates) and two writes (create/update via saveForm,
* delete via deleteForm) replaced by props for the list's fetch status and
* rows; everything else in the source is already 100% client-side (adding,
* removing, reordering and expanding fields, adding/removing/renaming pages,
* toggling public/multi-page/preview, typing into every input) and is ported
* live here exactly as it runs in the app, seeded from props rather than
* from editForm()/startNewForm(). Rendered inside the shell demo's component
* (Navigation.tsx is a thin delegator to AppShell, same as every other
* private page), on `/forms/builder` ("Forms" domain, nav.tsx line 90 at
* 62ba4fc; the page is also reachable at `/forms/builder/:formId` for a
* direct deep link, App.tsx lines 619 to 634, the same component either way).
*
* What is kept from the source: the loading spinner, the list view's header,
* empty state and card grid (name, description, the "Öffentlich" badge, the
* field/page counts, the four row actions), the full editor (the 14-item
* field-type sidebar, the top bar with the name input and the Vorschau/
* Speichern buttons, the multi-page tabs, the settings card, the page-title
* input, the empty-canvas placeholder, the field list with expand/collapse
* and the choice/slider sub-editors) and the live-preview sidebar (the
* progress bar, all nine field-type renderers, the Zurück/Weiter/Absenden
* buttons). All copy is the source's, German.
*
* What is different, and why: `fetchForms` (the one read), `saveForm`'s
* POST/PUT and `deleteForm`'s DELETE are not wired, since a demo reaches no
* network (AUTHORING.md, "The exports"); the Speichern button's disabled
* and busy look is real (the same `!formName || fields.length === 0 ||
* saving` the source computes, `saving` a prop since no request is ever in
* flight) but clicking it does nothing, and a list card's Löschen button
* (native `confirm()` first in the source, line 241) is inert for the same
* reason, the convention identity-auth/ui/admin-users-console's demo already
* uses for its own destructive row actions. `copyEmbedCode`'s
* `navigator.clipboard.writeText` is dropped (a demo frame has no clipboard
* permission to assume); the Check/Code icon swap it drives is kept, timed
* with the source's own two-second `setTimeout` (line 360), since that part
* touches no browser API. `toast.error(...)` (the field-label validation at
* line 170 and the three catch blocks) is not wired: no state here ever
* reaches a live write to fail against, and no named state needs the
* `ToastProvider` the packages carry (unlike ui-design-system's own
* component-library showcase, which mounts one to demonstrate the atom
* itself). Every other interaction, listed above, is the real local state
* and the real handler, unchanged.
*/
import React, { useState } from 'react';
import {
Plus, Trash2, Edit2, Save, Eye, ChevronUp, ChevronDown, ChevronRight,
FileText, Type, Hash, Calendar, CheckSquare, Circle, GripVertical,
Upload, List, Sliders, Code, ExternalLink, Check, X, Layers,
Mail, Phone, Link2, Clock,
} from 'lucide-react';
import { defineStates } from '@prima-modules/demo';
import {
Button, Heading, Text, Input, Textarea, Select, Card, CardContent,
Spinner, Badge, Checkbox, Toggle, RadioGroup, Divider,
} from '@prima-modules/alchemy-ui';
import { Shell, type ShellUser, type ShellWorkspace } from '../../../ui-design-system/ui/appshell-navigation/demo';
import { DOMAINS } from '../../../ui-design-system/ui/appshell-navigation/nav';
/* ---- types (FormBuilderPage.tsx lines 29 to 68) ------------------------- */
export interface FormField {
id?: string;
field_type: string;
label: string;
name: string;
placeholder?: string;
help_text?: string;
required: boolean;
order: number;
page_id?: string;
options?: {
choices?: { label: string; value: string }[];
min?: number;
max?: number;
step?: number;
};
validation_rules?: any;
}
export interface FormPage {
id: string;
title: string;
description?: string;
order: number;
}
export interface FormTemplate {
id?: string;
name: string;
description?: string;
category?: string;
is_public?: boolean;
embed_code?: string;
settings?: {
multi_page?: boolean;
pages?: FormPage[];
progress_bar?: { style: string; position: string };
};
fields: FormField[];
}
/* ---- props --------------------------------------------------------------- */
export interface Props {
/** The signed-in workspace user, for the shell's account row. */
viewer: ShellUser;
workspace: ShellWorkspace;
/** `loading` is the first fetch (line 367 to 378); `ok` is the settled list. GET /api/forms/templates
* has no error branch in the source, `fetchForms`'s catch only `console.error`s (Gotchas), so a failed
* read renders identically to an empty list, not modelled as a third status here for that reason. */
status: 'loading' | 'ok';
forms: FormTemplate[];
/** Everything from here down seeds the same local state `editForm`/`startNewForm` seed (lines 135 to
* 161); the field/page editing below then runs entirely client-side from this starting point, exactly
* as it does live, since none of it reaches the network (see the header comment). */
isEditing?: boolean;
formName?: string;
formDescription?: string;
formCategory?: string;
isPublic?: boolean;
isMultiPage?: boolean;
pages?: FormPage[];
fields?: FormField[];
activePageId?: string;
/** Which field's settings panel is open (line 667); null collapses every row. */
editingFieldIndex?: number | null;
previewMode?: boolean;
/** Copy-embed icon swap; shared by every public card in the source (Gotchas), reproduced as measured. */
copiedEmbed?: boolean;
/** The Speichern button's busy flag (line 550 to 561); saveForm's own request never fires here. */
saving?: boolean;
}
/* ---- sample data ---------------------------------------------------------- */
const viewer: ShellUser = { name: 'Nina Vogel', email: 'nina@example.com', isAdmin: false };
const workspace: ShellWorkspace = { id: 'ws_1', name: 'Atelier Nordwind', slug: 'atelier-nordwind' };
const listForms: FormTemplate[] = [
{
id: 'form_1',
name: 'Newsletter Anmeldung',
description: 'Lead-Magnet für den Blog',
category: 'lead_magnet',
is_public: true,
embed_code: 'nwsl-2026',
settings: { multi_page: false },
fields: [
{ field_type: 'text', label: 'Vorname', name: 'vorname', required: true, order: 0 },
{ field_type: 'email', label: 'E-Mail', name: 'email', required: true, order: 1 },
],
},
{
id: 'form_2',
name: 'Erstberatung anfragen',
description: 'Kontaktformular für neue Kundenanfragen',
category: 'contact',
is_public: true,
embed_code: 'erstber-2026',
settings: {
multi_page: true,
pages: [
{ id: 'page_1', title: 'Kontakt', order: 0 },
{ id: 'page_2', title: 'Projekt', order: 1 },
],
},
fields: [
{ field_type: 'text', label: 'Name', name: 'name', required: true, order: 0, page_id: 'page_1' },
{ field_type: 'email', label: 'E-Mail', name: 'email', required: true, order: 1, page_id: 'page_1' },
{ field_type: 'phone', label: 'Telefon', name: 'telefon', required: false, order: 2, page_id: 'page_1' },
{ field_type: 'select', label: 'Budget', name: 'budget', required: false, order: 3, page_id: 'page_2', options: { choices: [{ label: 'Unter CHF 5000', value: 'unter_5000' }, { label: 'CHF 5000 bis 20000', value: '5000_20000' }] } },
{ field_type: 'textarea', label: 'Projektbeschreibung', name: 'beschreibung', required: false, order: 4, page_id: 'page_2' },
],
},
{
id: 'form_3',
name: 'Kundenzufriedenheit Q3',
description: 'Interne Umfrage, nicht öffentlich',
category: 'survey',
is_public: false,
settings: { multi_page: false },
fields: [
{ field_type: 'slider', label: 'Zufriedenheit', name: 'zufriedenheit', required: false, order: 0, options: { min: 0, max: 10, step: 1 } },
{ field_type: 'textarea', label: 'Was können wir besser machen?', name: 'feedback', required: false, order: 1 },
],
},
];
/** Single page, five field types including an expanded select field, for editor-form-with-fields. */
const singlePageFields: FormField[] = [
{ id: 'f1', field_type: 'text', label: 'Vorname', name: 'vorname', placeholder: 'Anna', required: true, order: 0 },
{ id: 'f2', field_type: 'email', label: 'E-Mail', name: 'email', placeholder: 'anna@example.com', help_text: 'Wir melden uns hier zurück', required: true, order: 1 },
{
id: 'f3', field_type: 'select', label: 'Wie bist du auf uns aufmerksam geworden?', name: 'quelle', required: false, order: 2,
options: { choices: [{ label: 'Google', value: 'google' }, { label: 'Empfehlung', value: 'empfehlung' }, { label: 'Social Media', value: 'social_media' }] },
},
{ id: 'f4', field_type: 'slider', label: 'Wie zufrieden bist du aktuell mit deiner Website?', name: 'zufriedenheit', required: false, order: 3, options: { min: 0, max: 10, step: 1 } },
{ id: 'f5', field_type: 'checkbox', label: 'Ich stimme der Datenschutzerklärung zu', name: 'datenschutz', placeholder: 'Ich stimme zu', required: true, order: 4 },
];
/** Three pages, fields spread across them, for editor-multi-page. */
const multiPagePages: FormPage[] = [
{ id: 'page_1', title: 'Kontakt', order: 0 },
{ id: 'page_2', title: 'Projekt-Details', order: 1 },
{ id: 'page_3', title: 'Abschluss', order: 2 },
];
const multiPageFields: FormField[] = [
{ id: 'm1', field_type: 'text', label: 'Name', name: 'name', required: true, order: 0, page_id: 'page_1' },
{ id: 'm2', field_type: 'email', label: 'E-Mail', name: 'email', required: true, order: 1, page_id: 'page_1' },
{ id: 'm3', field_type: 'radio', label: 'Budget-Rahmen', name: 'budget', required: true, order: 0, page_id: 'page_2', options: { choices: [{ label: 'Unter CHF 5000', value: 'unter_5000' }, { label: 'CHF 5000 bis 20000', value: '5000_20000' }, { label: 'Über CHF 20000', value: 'ueber_20000' }] } },
{ id: 'm4', field_type: 'textarea', label: 'Projektbeschreibung', name: 'beschreibung', required: false, order: 1, page_id: 'page_2' },
{ id: 'm5', field_type: 'checkbox', label: 'Newsletter abonnieren', name: 'newsletter', required: false, order: 0, page_id: 'page_3' },
];
/** Two pages, three fields on the first, for preview-mode's progress bar and nav buttons. */
const previewPages: FormPage[] = [
{ id: 'page_1', title: 'Kontakt', order: 0 },
{ id: 'page_2', title: 'Details', order: 1 },
];
const previewFields: FormField[] = [
{ id: 'p1', field_type: 'text', label: 'Vorname', name: 'vorname', placeholder: 'Anna', required: true, order: 0, page_id: 'page_1' },
{ id: 'p2', field_type: 'email', label: 'E-Mail', name: 'email', placeholder: 'anna@example.com', required: true, order: 1, page_id: 'page_1' },
{ id: 'p3', field_type: 'radio', label: 'Wie können wir dir helfen?', name: 'anliegen', required: false, order: 2, page_id: 'page_1', options: { choices: [{ label: 'Neuprojekt', value: 'neu' }, { label: 'Bestehendes Projekt', value: 'bestehend' }] } },
{ id: 'p4', field_type: 'textarea', label: 'Details', name: 'details', required: false, order: 0, page_id: 'page_2' },
];
const base: Props = {
viewer,
workspace,
status: 'ok',
forms: listForms,
isEditing: false,
formName: '',
formDescription: '',
formCategory: 'lead_magnet',
isPublic: true,
isMultiPage: false,
pages: [{ id: 'page_1', title: 'Page 1', order: 0 }],
fields: [],
activePageId: 'page_1',
editingFieldIndex: null,
previewMode: false,
copiedEmbed: false,
saving: false,
};
export const states = defineStates<Props>({
loading: { label: 'First fetch: the spinner inside the shell, before any form is known (line 367 to 378)', props: { ...base, status: 'loading', forms: [] }, height: 560, viewport: 1440 },
'list-empty': { label: 'Fresh workspace, no templates yet: the empty-state card and its one CTA (line 399 to 411)', props: { ...base, forms: [] }, height: 640, viewport: 1440 },
'list-populated': { label: 'Three templates: one public single-page, one public multi-page, one private survey', props: base, height: 900, viewport: 1440 },
'embed-code-copied': { label: 'The embed button just fired: Check in place of Code, on every public card at once (Gotchas)', props: { ...base, copiedEmbed: true }, height: 900, viewport: 1440 },
'editor-new-form-no-fields': { label: 'Neues Formular: no name, no fields, the dashed canvas placeholder, Speichern disabled', props: { ...base, isEditing: true }, height: 900, viewport: 1440 },
'editor-form-with-fields': { label: 'Five fields on one page, the select field expanded with its choice editor open', props: { ...base, isEditing: true, formName: 'Newsletter Anmeldung', formDescription: 'Lead-Magnet für den Blog', fields: singlePageFields, editingFieldIndex: 2 }, height: 1500, viewport: 1440 },
'editor-multi-page': { label: 'Three-page tabs, the second page active, its own page-title input and fields', props: { ...base, isEditing: true, formName: 'Erstberatung anfragen', isMultiPage: true, pages: multiPagePages, fields: multiPageFields, activePageId: 'page_2' }, height: 1200, viewport: 1440 },
'preview-mode': { label: 'Live-Vorschau open: the progress bar, all fields on page 1, the Zurück/Weiter pair', props: { ...base, isEditing: true, formName: 'Erstberatung anfragen', isMultiPage: true, pages: previewPages, fields: previewFields, activePageId: 'page_1', previewMode: true }, height: 1200, viewport: 1600 },
});
/* ---- helpers (FormBuilderPage.tsx lines 70 to 85) ------------------------ */
const FIELD_TYPES = [
{ value: 'text', label: 'Text', icon: Type, color: 'text-primary-500' },
{ value: 'textarea', label: 'Langer Text', icon: FileText, color: 'text-primary-500' },
{ value: 'email', label: 'Email', icon: Mail, color: 'text-primary-600' },
{ value: 'phone', label: 'Telefon', icon: Phone, color: 'text-primary-600' },
{ value: 'url', label: 'URL', icon: Link2, color: 'text-primary-600' },
{ value: 'number', label: 'Zahl', icon: Hash, color: 'text-orange-500' },
{ value: 'date', label: 'Datum', icon: Calendar, color: 'text-orange-500' },
{ value: 'time', label: 'Uhrzeit', icon: Clock, color: 'text-orange-500' },
{ value: 'select', label: 'Dropdown', icon: List, color: 'text-emerald-500' },
{ value: 'multiselect', label: 'Mehrfachauswahl', icon: List, color: 'text-emerald-500' },
{ value: 'radio', label: 'Einfachauswahl', icon: Circle, color: 'text-emerald-500' },
{ value: 'checkbox', label: 'Checkbox', icon: CheckSquare, color: 'text-emerald-500' },
{ value: 'slider', label: 'Bewertungsskala', icon: Sliders, color: 'text-amber-500' },
{ value: 'file', label: 'Datei-Upload', icon: Upload, color: 'text-amber-500' },
];
/** FormBuilderPage.tsx lines 347 to 355: real copy, a fictional code never resolves against a live embed. */
function getEmbedCode(form: FormTemplate): string {
const code = form.embed_code || form.id;
return `<iframe src="https://opus.alchemy.zuerich/api/f/${code}/page" width="100%" height="600" style="border:none;" allowtransparency="true" allow="clipboard-write"></iframe>`;
}
function getEmbedUrl(form: FormTemplate): string {
const code = form.embed_code || form.id;
return `https://opus.alchemy.zuerich/api/f/${code}/page`;
}
/* ---- the page -------------------------------------------------------------- */
function FormBuilder({
status,
forms: formsInitial,
isEditing: isEditingInitial = false,
formName: formNameInitial = '',
formDescription: formDescriptionInitial = '',
formCategory: formCategoryInitial = 'lead_magnet',
isPublic: isPublicInitial = true,
isMultiPage: isMultiPageInitial = false,
pages: pagesInitial = [{ id: 'page_1', title: 'Page 1', order: 0 }],
fields: fieldsInitial = [],
activePageId: activePageIdInitial = 'page_1',
editingFieldIndex: editingFieldIndexInitial = null,
previewMode: previewModeInitial = false,
copiedEmbed: copiedEmbedInitial = false,
saving = false,
}: Omit<Props, 'viewer' | 'workspace'>) {
const [forms, setForms] = useState(formsInitial);
const [isEditing, setIsEditing] = useState(isEditingInitial);
const [previewMode, setPreviewMode] = useState(previewModeInitial);
const [copiedEmbed, setCopiedEmbed] = useState(copiedEmbedInitial);
const [activePageId, setActivePageId] = useState(activePageIdInitial);
const [formName, setFormName] = useState(formNameInitial);
const [formDescription, setFormDescription] = useState(formDescriptionInitial);
const [formCategory, setFormCategory] = useState(formCategoryInitial);
const [isPublic, setIsPublic] = useState(isPublicInitial);
const [isMultiPage, setIsMultiPage] = useState(isMultiPageInitial);
const [pages, setPages] = useState<FormPage[]>(pagesInitial);
const [fields, setFields] = useState<FormField[]>(fieldsInitial);
const [editingFieldIndex, setEditingFieldIndex] = useState<number | null>(editingFieldIndexInitial);
const startNewForm = () => {
setFormName('');
setFormDescription('');
setFormCategory('lead_magnet');
setIsPublic(true);
setIsMultiPage(false);
setPages([{ id: 'page_1', title: 'Page 1', order: 0 }]);
setActivePageId('page_1');
setFields([]);
setIsEditing(true);
setPreviewMode(false);
};
const editForm = (form: FormTemplate) => {
setFormName(form.name);
setFormDescription(form.description || '');
setFormCategory(form.category || 'lead_magnet');
setIsPublic(form.is_public || false);
setIsMultiPage(form.settings?.multi_page || false);
setPages(form.settings?.pages || [{ id: 'page_1', title: 'Page 1', order: 0 }]);
setActivePageId(form.settings?.pages?.[0]?.id || 'page_1');
setFields(form.fields || []);
setIsEditing(true);
setPreviewMode(false);
};
const addField = (fieldType: string) => {
const newField: FormField = {
field_type: fieldType,
label: '',
name: '',
placeholder: '',
help_text: '',
required: false,
order: fields.length,
page_id: isMultiPage ? activePageId : undefined,
options: fieldType === 'slider' ? { min: 0, max: 10, step: 1 } : {},
};
if (['select', 'multiselect', 'radio'].includes(fieldType)) {
newField.options = { choices: [{ label: 'Option 1', value: 'option1' }, { label: 'Option 2', value: 'option2' }] };
}
setFields([...fields, newField]);
setEditingFieldIndex(fields.length);
};
const updateField = (index: number, updates: Partial<FormField>) => {
const updated = [...fields];
updated[index] = { ...updated[index], ...updates };
setFields(updated);
};
const removeField = (index: number) => {
setFields(fields.filter((_, i) => i !== index));
setEditingFieldIndex(null);
};
const moveField = (index: number, direction: 'up' | 'down') => {
const currentPageFields = fields.filter((f) => !isMultiPage || f.page_id === activePageId);
const otherFields = fields.filter((f) => isMultiPage && f.page_id !== activePageId);
const fieldIndex = currentPageFields.findIndex((_, i) => {
const globalIndex = fields.indexOf(currentPageFields[i]);
return globalIndex === index;
});
const targetIndex = direction === 'up' ? fieldIndex - 1 : fieldIndex + 1;
if (targetIndex >= 0 && targetIndex < currentPageFields.length) {
const newPageFields = [...currentPageFields];
[newPageFields[fieldIndex], newPageFields[targetIndex]] = [newPageFields[targetIndex], newPageFields[fieldIndex]];
setFields([...otherFields, ...newPageFields]);
}
};
const addPage = () => {
const newPageId = `page_${pages.length + 1}`;
setPages([...pages, { id: newPageId, title: `Page ${pages.length + 1}`, order: pages.length }]);
setActivePageId(newPageId);
};
const removePage = (pageId: string) => {
if (pages.length <= 1) return;
const newPages = pages.filter((p) => p.id !== pageId);
setPages(newPages);
setFields(fields.filter((f) => f.page_id !== pageId));
if (activePageId === pageId) setActivePageId(newPages[0].id);
};
const updatePageTitle = (pageId: string, title: string) => {
setPages(pages.map((p) => (p.id === pageId ? { ...p, title } : p)));
};
const addChoice = (fieldIndex: number) => {
const field = fields[fieldIndex];
const choices = field.options?.choices || [];
const newChoice = { label: `Option ${choices.length + 1}`, value: `option${choices.length + 1}` };
updateField(fieldIndex, { options: { ...field.options, choices: [...choices, newChoice] } });
};
const updateChoice = (fieldIndex: number, choiceIndex: number, updates: { label?: string; value?: string }) => {
const field = fields[fieldIndex];
const choices = [...(field.options?.choices || [])];
choices[choiceIndex] = { ...choices[choiceIndex], ...updates };
updateField(fieldIndex, { options: { ...field.options, choices } });
};
const removeChoice = (fieldIndex: number, choiceIndex: number) => {
const field = fields[fieldIndex];
const choices = (field.options?.choices || []).filter((_, i) => i !== choiceIndex);
updateField(fieldIndex, { options: { ...field.options, choices } });
};
/** Icon swap only: the source's navigator.clipboard.writeText is dropped (header comment). */
const copyEmbedCode = () => {
setCopiedEmbed(true);
setTimeout(() => setCopiedEmbed(false), 2000);
};
const currentPageFields = isMultiPage ? fields.filter((f) => f.page_id === activePageId) : fields;
if (status === 'loading') {
return (
<div className="flex items-center justify-center min-h-[360px]">
<div className="text-center">
<Spinner size="lg" />
<Text className="mt-4" muted>Formulare werden geladen...</Text>
</div>
</div>
);
}
return (
<div className="min-h-screen">
{!isEditing ? (
/* Form List View */
<div className="max-w-6xl mx-auto px-6 py-8">
<div className="flex items-center justify-between mb-8">
<div>
<Heading level={1}>Form Builder</Heading>
<Text muted className="mt-1">Erstelle Lead-Magnete und Erfassungsformulare</Text>
</div>
<Button variant="primary" onClick={startNewForm}>
<Plus className="w-5 h-5 mr-2" />
Neues Formular
</Button>
</div>
{forms.length === 0 ? (
<Card variant="glass">
<CardContent className="py-16 text-center">
<div className="w-20 h-20 rounded-full bg-primary-100 flex items-center justify-center mx-auto mb-6">
<FileText className="w-10 h-10 text-primary-500" />
</div>
<Heading level={3} className="mb-3">Noch keine Formulare</Heading>
<Text muted className="mb-6">Erstelle dein erstes Formular, um Leads zu erfassen</Text>
<Button variant="primary" onClick={startNewForm}>Erstes Formular erstellen</Button>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{forms.map((form) => (
<Card key={form.id} className="hover:border-primary-500 transition-all group">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<Heading level={4} className="group-hover:text-primary-500 transition-colors truncate">{form.name}</Heading>
<Text size="sm" muted className="mt-1 line-clamp-2">{form.description || 'Keine Beschreibung'}</Text>
</div>
{form.is_public && (
<Badge variant="default" className="ml-2 bg-emerald-100 text-emerald-700 shrink-0">Öffentlich</Badge>
)}
</div>
<div className="flex items-center gap-4 mb-4">
<Text size="sm" muted className="flex items-center gap-1.5">
<Layers className="w-4 h-4" />
{form.fields?.length || 0} Felder
</Text>
{form.settings?.multi_page && (
<Text size="sm" muted className="flex items-center gap-1.5">
<FileText className="w-4 h-4" />
{form.settings.pages?.length || 1} Seiten
</Text>
)}
</div>
<Divider className="my-4" />
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => editForm(form)} className="flex-1">
<Edit2 className="w-4 h-4 mr-1.5" />
Bearbeiten
</Button>
{form.is_public && (
<>
<Button variant="ghost" size="sm" onClick={copyEmbedCode} title="Embed-Code kopieren">
{copiedEmbed ? <Check className="w-4 h-4 text-emerald-500" /> : <Code className="w-4 h-4" />}
</Button>
<a href={getEmbedUrl(form)} target="_blank" rel="noopener noreferrer">
<Button variant="ghost" size="sm" title="Vorschau">
<ExternalLink className="w-4 h-4" />
</Button>
</a>
</>
)}
{/* Löschen: native confirm() then a DELETE in the source (line 241, 244); a demo reaches
neither, so this row action is present but inert, the convention
identity-auth/ui/admin-users-console's own destructive row actions already use. */}
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-600 hover:bg-red-50" title="Löschen">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
) : (
/* Form Editor View */
<div className="flex h-[calc(100vh-64px)]">
<aside className="w-64 bg-white border-r border-line flex flex-col overflow-hidden">
<div className="p-4 border-b border-line">
<Text weight="semibold" className="uppercase tracking-wider text-xs text-ink-60">Felder hinzufügen</Text>
</div>
<div className="flex-1 overflow-y-auto p-3">
<div className="grid grid-cols-2 gap-2">
{FIELD_TYPES.map((fieldType) => {
const Icon = fieldType.icon;
return (
<button
key={fieldType.value}
onClick={() => addField(fieldType.value)}
className="flex flex-col items-center gap-1.5 p-3 bg-stone-50 border border-line rounded-xl hover:border-primary-500 hover:bg-primary-50 transition-all group"
>
<Icon className={`w-5 h-5 ${fieldType.color}`} />
<Text size="xs" className="text-center text-ink-60 group-hover:text-ink">{fieldType.label}</Text>
</button>
);
})}
</div>
</div>
</aside>
<main className="flex-1 flex flex-col overflow-hidden">
<div className="bg-white border-b border-line px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => setIsEditing(false)}>
<X className="w-5 h-5" />
</Button>
<Input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="Unbenanntes Formular"
inputSize="lg"
className="text-xl font-semibold bg-transparent border-none focus:ring-0 w-64"
/>
</div>
<div className="flex items-center gap-3">
<Button variant={previewMode ? 'primary' : 'outline'} size="sm" onClick={() => setPreviewMode(!previewMode)}>
<Eye className="w-4 h-4 mr-2" />
Vorschau
</Button>
{/* Speichern: the disabled condition and the busy look are real; the POST/PUT itself is not wired. */}
<Button variant="primary" disabled={!formName || fields.length === 0 || saving}>
{saving ? (
<>
<Spinner size="sm" className="mr-2" />
Wird gespeichert...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Speichern
</>
)}
</Button>
</div>
</div>
{isMultiPage && (
<div className="bg-white border-b border-line px-6 py-2 flex items-center gap-2 overflow-x-auto">
{pages.map((page) => (
<div
key={page.id}
className={`flex items-center gap-2 px-4 py-2 rounded-lg cursor-pointer transition-all ${
activePageId === page.id ? 'bg-primary-100 text-primary-600' : 'text-ink-60 hover:text-ink hover:bg-stone-100'
}`}
onClick={() => setActivePageId(page.id)}
>
<Text size="sm" weight="medium">{page.title}</Text>
{pages.length > 1 && (
<button onClick={(e) => { e.stopPropagation(); removePage(page.id); }} className="opacity-60 hover:opacity-100 hover:text-red-500 transition-opacity">
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
))}
<Button variant="ghost" size="sm" onClick={addPage}>
<Plus className="w-4 h-4 mr-1.5" />
Seite hinzufügen
</Button>
</div>
)}
<div className="flex-1 overflow-y-auto p-6 bg-transparent">
<div className="max-w-2xl mx-auto">
<Card variant="glass" className="mb-6">
<CardContent className="p-5">
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<Text size="sm" weight="medium" className="mb-1.5 text-ink-60">Kategorie</Text>
<Select
value={formCategory}
onChange={(value) => setFormCategory(value)}
options={[
{ value: 'lead_magnet', label: 'Lead-Magnet' },
{ value: 'contact', label: 'Kontaktformular' },
{ value: 'survey', label: 'Umfrage' },
{ value: 'registration', label: 'Registrierung' },
{ value: 'feedback', label: 'Feedback' },
]}
/>
</div>
<div className="flex items-end gap-6">
<Toggle checked={isPublic} onChange={setIsPublic} label="Öffentlich" />
<Toggle checked={isMultiPage} onChange={setIsMultiPage} label="Mehrseitig" />
</div>
</div>
<Textarea
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
placeholder="Formularbeschreibung (optional)"
rows={2}
/>
</CardContent>
</Card>
{isMultiPage && (
<div className="mb-4">
<Input
type="text"
value={pages.find((p) => p.id === activePageId)?.title || ''}
onChange={(e) => updatePageTitle(activePageId, e.target.value)}
placeholder="Seitentitel"
inputSize="lg"
className="text-lg font-semibold bg-transparent border-none"
/>
</div>
)}
{currentPageFields.length === 0 ? (
<div className="bg-white border-2 border-dashed border-stone-300 rounded-2xl p-16 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-stone-200 flex items-center justify-center">
<Layers className="w-8 h-8 text-ink-40" />
</div>
<Text muted>Klicke links auf einen Feldtyp, um ihn hinzuzufügen</Text>
</div>
) : (
<div className="space-y-3">
{currentPageFields.map((field, localIndex) => {
const globalIndex = fields.indexOf(field);
const fieldType = FIELD_TYPES.find((t) => t.value === field.field_type);
const Icon = fieldType?.icon || Type;
const isExpanded = editingFieldIndex === globalIndex;
return (
<Card key={globalIndex} className={`transition-all ${isExpanded ? 'border-primary-500 shadow-lg' : ''}`}>
<div className="flex items-center gap-3 p-4 cursor-pointer" onClick={() => setEditingFieldIndex(isExpanded ? null : globalIndex)}>
<GripVertical className="w-4 h-4 text-ink-40 cursor-grab" />
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-stone-100">
<Icon className={`w-5 h-5 ${fieldType?.color || 'text-stone-500'}`} />
</div>
<div className="flex-1 min-w-0">
<Text weight="medium" className="truncate">
{field.label || <span className="text-ink-40 italic">Unbenanntes Feld</span>}
</Text>
<Text size="xs" muted>{fieldType?.label}</Text>
</div>
{field.required && <Badge variant="secondary" className="bg-red-100 text-red-700">Pflicht</Badge>}
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); moveField(globalIndex, 'up'); }} disabled={localIndex === 0} className="p-1.5">
<ChevronUp className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); moveField(globalIndex, 'down'); }} disabled={localIndex === currentPageFields.length - 1} className="p-1.5">
<ChevronDown className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); removeField(globalIndex); }} className="p-1.5 text-red-500 hover:text-red-600 hover:bg-red-50">
<Trash2 className="w-4 h-4" />
</Button>
</div>
<ChevronRight className={`w-4 h-4 text-ink-40 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
</div>
{isExpanded && (
<CardContent className="px-4 pb-4 pt-2 border-t border-line">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Text size="sm" weight="medium" className="mb-1.5 text-ink-60">Beschriftung</Text>
<Input type="text" value={field.label} onChange={(e) => updateField(globalIndex, { label: e.target.value })} placeholder="Feldbeschriftung eingeben" inputSize="md" />
</div>
<div>
<Text size="sm" weight="medium" className="mb-1.5 text-ink-60">Platzhalter</Text>
<Input type="text" value={field.placeholder} onChange={(e) => updateField(globalIndex, { placeholder: e.target.value })} placeholder="Platzhaltertext" inputSize="md" />
</div>
</div>
<div>
<Text size="sm" weight="medium" className="mb-1.5 text-ink-60">Hilfetext</Text>
<Input type="text" value={field.help_text} onChange={(e) => updateField(globalIndex, { help_text: e.target.value })} placeholder="Zusätzliche Hinweise für dieses Feld" inputSize="md" />
</div>
<Toggle checked={field.required} onChange={(checked) => updateField(globalIndex, { required: checked })} label="Pflichtfeld" />
{['select', 'multiselect', 'radio'].includes(field.field_type) && (
<div>
<div className="flex items-center justify-between mb-3">
<Text size="sm" weight="medium" className="text-ink-60">Optionen</Text>
<Button variant="ghost" size="sm" onClick={() => addChoice(globalIndex)}>
<Plus className="w-3.5 h-3.5 mr-1" />
Option hinzufügen
</Button>
</div>
<div className="space-y-2">
{field.options?.choices?.map((choice, choiceIndex) => (
<div key={choiceIndex} className="flex items-center gap-2">
<Input
type="text"
value={choice.label}
onChange={(e) => updateChoice(globalIndex, choiceIndex, { label: e.target.value, value: e.target.value.toLowerCase().replace(/\s+/g, '_') })}
placeholder="Optionsbeschriftung"
inputSize="md"
className="flex-1"
/>
<Button variant="ghost" size="sm" onClick={() => removeChoice(globalIndex, choiceIndex)} className="p-2 text-red-500 hover:text-red-600 hover:bg-red-50">
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
</div>
)}
{field.field_type === 'slider' && (
<div className="grid grid-cols-3 gap-3">
<div>
<Text size="sm" weight="medium" className="mb-1.5 text-ink-60">Min</Text>
<Input type="number" value={field.options?.min || 0} onChange={(e) => updateField(globalIndex, { options: { ...field.options, min: parseInt(e.target.value) } })} inputSize="md" />
</div>
<div>
<Text size="sm" weight="medium" className="mb-1.5 text-ink-60">Max</Text>
<Input type="number" value={field.options?.max || 10} onChange={(e) => updateField(globalIndex, { options: { ...field.options, max: parseInt(e.target.value) } })} inputSize="md" />
</div>
<div>
<Text size="sm" weight="medium" className="mb-1.5 text-ink-60">Step</Text>
<Input type="number" value={field.options?.step || 1} onChange={(e) => updateField(globalIndex, { options: { ...field.options, step: parseInt(e.target.value) } })} inputSize="md" />
</div>
</div>
)}
</div>
</CardContent>
)}
</Card>
);
})}
</div>
)}
</div>
</div>
</main>
{previewMode && (
<aside className="w-96 bg-white border-l border-line flex flex-col overflow-hidden">
<div className="p-4 border-b border-line">
<Heading level={5}>Live-Vorschau</Heading>
</div>
<div className="flex-1 overflow-y-auto p-6">
<Card variant="glass">
<CardContent className="p-6">
<Heading level={3} className="mb-2">{formName || 'Unbenanntes Formular'}</Heading>
{formDescription && <Text muted className="mb-6">{formDescription}</Text>}
{isMultiPage && pages.length > 1 && (
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<Text size="xs" muted>Schritt {pages.findIndex((p) => p.id === activePageId) + 1} von {pages.length}</Text>
<Text size="xs" muted>{Math.round(((pages.findIndex((p) => p.id === activePageId) + 1) / pages.length) * 100)}%</Text>
</div>
<div className="h-1.5 bg-stone-200 rounded-full overflow-hidden">
<div className="h-full bg-primary-500 rounded-full transition-all" style={{ width: `${((pages.findIndex((p) => p.id === activePageId) + 1) / pages.length) * 100}%` }} />
</div>
</div>
)}
<div className="space-y-5">
{currentPageFields.map((field, index) => (
<div key={index}>
<Text weight="medium" className="block mb-1.5">
{field.label || 'Unbenannt'}
{field.required && <span className="text-red-500 ml-1">*</span>}
</Text>
{field.help_text && <Text size="xs" muted className="mb-1.5">{field.help_text}</Text>}
{['text', 'email', 'phone', 'url', 'number'].includes(field.field_type) && (
<Input type={field.field_type === 'phone' ? 'tel' : (field.field_type as 'text' | 'email' | 'number')} placeholder={field.placeholder} inputSize="md" />
)}
{field.field_type === 'textarea' && <Textarea placeholder={field.placeholder} rows={3} />}
{field.field_type === 'select' && (
<Select options={[{ value: '', label: 'Auswählen...' }, ...(field.options?.choices?.map((c) => ({ value: c.value, label: c.label })) || [])]} />
)}
{field.field_type === 'radio' && (
<RadioGroup name={`preview-radio-${index}`} options={field.options?.choices?.map((c) => ({ value: c.value, label: c.label })) || []} />
)}
{field.field_type === 'checkbox' && <Checkbox id={`preview-checkbox-${index}`} label={field.placeholder || 'Ich stimme zu'} />}
{field.field_type === 'slider' && (
<div className="pt-2">
<input type="range" min={field.options?.min || 0} max={field.options?.max || 10} className="w-full accent-primary-500" />
<div className="flex justify-between mt-1">
<Text size="xs" muted>{field.options?.min || 0}</Text>
<Text size="xs" muted>{field.options?.max || 10}</Text>
</div>
</div>
)}
{(field.field_type === 'date' || field.field_type === 'time') && <Input type={field.field_type} inputSize="md" />}
{field.field_type === 'file' && (
<div className="border-2 border-dashed border-stone-300 rounded-xl p-6 text-center">
<Upload className="w-6 h-6 text-ink-40 mx-auto mb-2" />
<Text size="sm" muted>Zum Hochladen klicken</Text>
</div>
)}
</div>
))}
</div>
{isMultiPage && pages.length > 1 ? (
<div className="flex items-center justify-between mt-8">
<Button variant="outline" disabled={pages.findIndex((p) => p.id === activePageId) === 0}>Zurück</Button>
<Button variant="primary">{pages.findIndex((p) => p.id === activePageId) === pages.length - 1 ? 'Absenden' : 'Weiter'}</Button>
</div>
) : (
<Button variant="primary" className="w-full mt-8">Absenden</Button>
)}
</CardContent>
</Card>
</div>
</aside>
)}
</div>
)}
</div>
);
}
export default function Demo({ viewer, workspace: ws, galleryMode, ...page }: Props & { galleryMode?: boolean }) {
return (
<Shell domains={DOMAINS} user={viewer} workspaces={[ws]} currentWorkspaceId={ws.id} activePath="/forms/builder" hasTour={false} hideChrome={galleryMode}>
<FormBuilder {...page} />
</Shell>
);
}
Component tree, from the recipe
<Route /forms/builder, /forms/builder/:formId> PrivateRoute (children)
Navigation = AppShell
FormBuilderPage
[loading] div.min-h-screen > Spinner lg + "Formulare werden geladen..." (before the list, inside the shell, line 367 to 378)
[!isEditing] List View (383 to 487)
header Heading "Form Builder" + Button primary "Neues Formular" (386 to 396)
[forms.length === 0] Card glass: FileText icon + Heading "Noch keine Formulare" + Button primary CTA (399 to 411)
[else] grid: forms.map Card
Heading name + Text description + [is_public] Badge "Öffentlich" (420 to 434)
Text Felder count + [multi_page] Text Seiten count (436 to 447)
Divider
Button ghost "Bearbeiten" + [is_public] (Button ghost embed-copy [copiedEmbed] Check|Code + a[target=_blank] Button ghost ExternalLink) + Button ghost Trash2 (Löschen) (451 to 481)
[isEditing] Editor View, flex h-[calc(100vh-64px)] (490 to 975)
aside left "Felder hinzufügen" + FIELD_TYPES.map button (14 field types, icon + label) (491 to 517)
main
top bar Button ghost X (close) + Input formName + Button outline|primary "Vorschau" (previewMode) + Button primary "Speichern" (disabled, saving) (522 to 563)
[isMultiPage] page tabs: pages.map div (active|inactive, [pages.length>1] X remove) + Button ghost "Seite hinzufügen" (566 to 594)
canvas
settings Card: Select Kategorie + Toggle Öffentlich + Toggle Mehrseitig + Textarea Beschreibung (600 to 637)
[isMultiPage] Input Seitentitel (640 to 651)
[currentPageFields.length === 0] dashed placeholder "Klicke links..." (654 to 660)
[else] currentPageFields.map Card
header row: GripVertical + type icon + label|"Unbenanntes Feld" + type label + [required] Badge "Pflicht" + ChevronUp/Down + Trash2 + ChevronRight (675 to 723)
[isExpanded] CardContent: Input Beschriftung + Input Platzhalter + Input Hilfetext + Toggle Pflichtfeld
[select|multiselect|radio] Optionen: Input label per choice + Button remove + Button "Option hinzufügen"
[slider] Input Min + Input Max + Input Step (726 to 836)
[previewMode] aside right "Live-Vorschau"
[isMultiPage && pages.length>1] progress bar "Schritt n von N" (861 to 878)
currentPageFields.map: label + [required] * + [help_text] +
one of: Input (text|email|phone|url|number) | Textarea | Select | RadioGroup | Checkbox | range input (slider) | Input date|time | dashed file dropzone (891 to 950)
[isMultiPage && pages.length>1] Button Zurück + Button Weiter|Absenden | [else] Button Absenden full-width (955 to 969) The rendering is the live rebuild form-builder-page/demo.tsx, built from @prima-modules/alchemy-ui and rendered by the docs host as one frame per state, rendered inside the shell demo's component (ui-design-system/ui/appshell-navigation/demo.tsx, imported relatively) with a member viewer on /forms/builder, so "Forms" is the active rail entry. The demo's props are the page's one read (status, forms) plus every local editor field as a seed (Data model); unlike a page whose interactions are mostly network writes, almost everything FormBuilderPage.tsx does (adding, removing, reordering, expanding a field, adding/removing/renaming a page, toggling public/multi-page/preview, typing into every input) is already 100% client-side in the source, so the demo ports that logic live rather than freezing it: each named state is a different starting seed, not a dead screenshot, and a viewer can genuinely build a form inside the rendered frame.
States (rendering.states, in order): loading (the spinner inside the shell, before any template is known); list-empty (a fresh workspace, the empty-state card and its single CTA); list-populated (three templates: a public single-page lead magnet, a public multi-page contact form, a private survey, exercising the "Öffentlich" badge, the field/page counts and all four row actions); embed-code-copied (copiedEmbed: true, the Check icon in place of Code on every public card at once, the real, shared-boolean quirk Gotchas documents, not filled with a per-card invented state); editor-new-form-no-fields (a blank name, zero fields, the dashed canvas placeholder, "Speichern" disabled); editor-form-with-fields (five fields on one page: text, email, an expanded select with its choice editor open, a slider, a checkbox, to show both the collapsed row and the expanded settings panel, including a choice sub-editor, in one frame); editor-multi-page (three page tabs, the second active, its own page-title input and its own two fields, to show the tabs and the per-page field filtering together); preview-mode (the right sidebar open over a two-page form's first page: the progress bar, three field types, the Zurück/Weiter pair). What none of the eight states shows: a third fetch-status for the list (the source has none, Gotchas); the expanded settings panel for a slider or file field at the same time as a select's choice editor (mutually exclusive branches, both individually covered across the two field-heavy states); the create/duplicate dialog (there is none, this page has no modal at all, unlike identity-auth/ui/admin-users-console's create-user dialog).
Tokens and classes as the source uses them: Card default (bare rows in the field list, the settings card) and Card glass (the list-view empty state, the preview's inner card); the field-type buttons and empty-canvas placeholder use raw stone-*/ink-*/line tokens the same way subscription-manager's breakdown card uses raw amber-*; Badge default tinted emerald-100/emerald-700 for "Öffentlich" (not one of Badge's own variant colors, a one-off override, the same pattern admin-users-console's stat-card icons use per that recipe's Gotchas) and secondary tinted red-100/red-700 for "Pflicht"; the field palette's 14 icon colors (text-primary-500/600, text-orange-500, text-emerald-500, text-amber-500) are fixed per type, not tokens. dark: variants: none present in this file at 62ba4fc, unlike subscription-manager's page (Gotchas there); nothing to drop here.
Responsive: the editor is a fixed three-column layout (w-64 sidebar, flexible canvas, w-96 preview) that does not reflow below md; the list view's card grid does (grid-cols-1 md:grid-cols-2 lg:grid-cols-3); the settings card's two-column row (grid-cols-2) also does not collapse on narrow widths in the source. A recipe rebuild on a phone-width viewport would need its own pass; this recipe does not claim one (verify.route is captured at 1440, per house rule). Empty state: list-empty and editor-new-form-no-fields, both real, distinct branches. Error state: none exists for the list (Gotchas); the two write failures are toasts, not part of any state's static markup (they are ephemeral by construction, so no state name is given to one).
What the demo deliberately does not render: window.confirm before delete (native, the Löschen button is inert instead, Application logic step 9); navigator.clipboard.writeText (a demo frame assumes no clipboard permission, the Check/Code icon swap is kept, header comment); toast.error(...) itself (the packages carry ToastProvider, but no named state here ever needs one mounted, since no write in the demo can fail); the actual POST/PUT/DELETE requests saveForm/deleteForm would send. All are documented in the demo's own header comment.
Security & tenancy
What the browser may hold: S2's two tokens only, never a payment or API credential; the fetched templates (names, descriptions, field/page structure, is_public) are the tenant's own workspace's and stay in memory, not persisted beyond the render. import.meta.env.VITE_API_URL is the only baked value. Origin and CSRF: the api singleton sends the Bearer header, the same as S2. Surface gating: forms_router.router is mounted under LABS_SURFACES (app_fastapi.py 928), so a prima_* session gets the surface-mismatch 403 before any handler runs (B4), the same gate subscription-manager's three routes and admin-users-console's admin routes sit behind.
Tenancy here is per-workspace: every one of the three calls takes WorkspaceID (api/dependencies.py _get_workspace_id, resolved from request.state.workspace_id when a JWT session already set it, otherwise the X-Workspace-ID header re-validated against the caller, otherwise a single-workspace fallback, otherwise a 4xx asking the client to bootstrap a workspace), unlike subscription-manager's per-user-only routes; switching workspaces in the shell therefore does change what this page shows on the next load, the opposite of that sibling recipe's Gotcha. Hidden by CSS versus enforced: nothing; every row action is visible to any signed-in workspace member regardless of role, and delete_form_template's own admin-vs-owner check (forms_router.py 291, 295 to 296: an admin may delete any template, a non-admin only their own) is enforced server-side, not reflected in the UI (no row is disabled or hidden for a non-owner before the click). Attacker-controlled input the page sends: every editor field (label, placeholder, help text, choice labels/values, slider bounds, page titles) round-trips through TemplateService and back into fields/settings verbatim; this page applies no client-side sanitization of its own, relying entirely on form-templates-and-builder's server-side handling (out of scope here) and on React's default escaping for what it renders back. What the page renders from the server unescaped: nothing; no dangerouslySetInnerHTML.
Tests
- Colocated with the demo:
form-builder-page/demo.test.tsx(vitest, jsdom,@testing-library/react,afterEach(cleanup)per test-layout'sglobals: false):list-emptyrenders the empty-state heading and no card;list-populatedrenders three cards and the "Öffentlich" badge on exactly the two public ones;embed-code-copiedrenders the Check icon on both public cards, not one;editor-new-form-no-fieldsrenders the dashed placeholder and a disabled Speichern button;editor-form-with-fieldsrenders five field rows and the expanded field's choice inputs;editor-multi-pagerenders three page tabs with the second marked active and exactly its two fields;preview-moderenders the progress text "Schritt 1 von 2" and a Weiter button; clicking a field-type button in any editor state adds a row (an interaction test, not just a snapshot, since the demo is genuinely live); no state's markup contains an access- or refresh-token shape. - The kit's render smoke:
node tooling/verify/render-smoke.mjs forms-leads/ui/form-builder-pagerenders all eight states withrenderToStringandfetchstubbed (Acceptance checks). - Labs at 62ba4fc has no unit test of
FormBuilderPage.tsx(grep -rl FormBuilderPage frontend/src --include='*.test.*'is empty) and no Playwright spec for/forms/builder. Backend coverage of the three routes it calls belongs toform-templates-and-builder's own suite, not to this page. - What a new app adds: a jsdom test with
apimocked assertingsaveForm's generated-name fallback (a field saved with a blanknamebut a filledlabelis sent with a slugifiedname); a test thatremovePagealso drops that page's fields; a Playwright spec that builds a two-field, two-page form end to end, saves it, and asserts the embed code it copies resolves to the same template.
Deploy & ops
Build target: the Labs Vite bundle (S2), a lazy chunk (App.tsx line 70, lazy(() => import('./pages/FormBuilderPage'))). Env baked at build: VITE_API_URL, S2's own. CSP and CORS: nothing of its own; the three routes answer on the API origin under the SPA's existing allowance, gated by LABS_SURFACES as above. Deploy path: S13's frontend rsync with the bundle; rollback is the bundle's. Nothing to migrate here directly; the schema the three routes read and write (form_templates, its fields/settings JSONB columns) belongs to form-templates-and-builder. Argus: a synthetic tenant session calling GET /api/forms/templates returning 200 is this page's liveness; the page has no health signal of its own.
Gotchas
- 2026-09-12 (measured at 62ba4fc):
fetchForms'scatch(128 to 130) onlyconsole.errors; nothing sets anerrorstring or an error UI. A failed list read leavesformsat[], so it renders pixel-identical to a genuinely empty workspace (list-empty); a tenant whose first load failed sees "Noch keine Formulare" and has no way to tell the difference from the page itself. Not modelled as a thirdstatusvalue in the demo for that reason (Frontend). - 2026-09-12 (measured at 62ba4fc):
copiedEmbed(96) is one boolean for the whole component, not keyed by form id. Clicking the embed-copy button on any public card flips the Check/Code icon on every public card in the grid simultaneously, for the same two seconds. Reproduced as measured in theembed-code-copiedstate rather than invented per-card. - 2026-09-12 (measured at 62ba4fc):
Copyis imported fromlucide-react(line 9) and never referenced anywhere in the file, a dead import; the embed-copy button actually rendersCheck/Code. Left out of the demo's icon imports as a result (the demo imports only the icons it draws). - 2026-09-12 (measured at 62ba4fc):
const navigate = useNavigate();(line 89) is declared and never called, a dead reference; nothing on this page navigates programmatically (the "Vorschau" open in a new tab is a plain<a target="_blank">, notnavigate()). - 2026-09-12 (measured at 62ba4fc):
deleteForm's parameter is itself namedformId(line 240), shadowing the outerconst { formId } = useParams()(line 90) for the length of that function; harmless (the outerformIdis not read inside), but worth knowing before adding a third thing namedformIdto this file. - 2026-09-12 (measured at 62ba4fc):
saveForm's error parsing (data.detailarray/object/string-aware, thendata.error, thendata.message, 208 to 234) is strictly better thansubscription-manager's four catch blocks, which readdata.detailalone against the same envelope shape (that sibling recipe's own Gotchas) and lose every server message as a result;deleteForm's catch (246 to 249) does not get the same treatment and always shows the fixed "Formular konnte nicht gelöscht werden" regardless of whatdelete_form_templateactually raised (the 404 "Template not found or unauthorized" included). - 2026-09-12 (kit): the demo genuinely runs the editor's local CRUD logic (add/remove/reorder fields and pages, toggle public/multi-page/preview) rather than freezing it, since none of that logic touches the network in the source; this is more interactive than most sibling frontend demos in this kit and is a deliberate choice, not scope creep, documented in the demo's own header comment.
Reference implementation
Branch: alchemy_labs main at 62ba4fc41009b454e1c033e5036c7e8577db2061 (2026-09-12). Paths relative to the repo root:
frontend/src/pages/FormBuilderPage.tsx(983 lines): imports and types (1 to 68),FIELD_TYPES(70 to 85), the component and its state (87 to 108), the two effects (110 to 121),fetchForms(123 to 133),startNewForm(135 to 147),editForm(149 to 161),saveForm(163 to 238),deleteForm(240 to 250),addField(252 to 276),updateField/removeField/moveField(278 to 305),addPage/removePage/updatePageTitle(307 to 325),addChoice/updateChoice/removeChoice(327 to 345),getEmbedCode/getEmbedUrl/copyEmbedCode(347 to 361), the loading return (367 to 378), the list view (383 to 487), the editor view (488 to 975): left sidebar (491 to 517), top bar (520 to 563), multi-page tabs (565 to 594), settings card (599 to 637), field list (653 to 843), preview sidebar (848 to 974).frontend/src/components/Navigation.tsx: the delegator toAppShell.frontend/src/components/nav.tsx: theformsdomain (line 90).frontend/src/App.tsx: the lazy import (70), the two routes (619 to 634).frontend/src/services/api.ts: the singleton and its interceptors, unchanged from S2.frontend/src/components/alchemy-ui/molecules/Toast.tsx:useToast, unchanged from the package here.backend/api/routers/forms_router.py:create_form_template(96 to 138),list_form_templates(141 to 190),update_form_template(234 to 273),delete_form_template(276 to 303).backend/app_fastapi.py928: the router'sLABS_SURFACESmount.- The demo:
modules/forms-leads/ui/form-builder-page/demo.tsx; the shell it renders in:modules/ui-design-system/ui/appshell-navigation/demo.tsxandnav.tsx.
Deviations of the reference from this recipe, all dated 2026-09-12: the list's silent fetch-failure swallow; the shared, non-keyed copiedEmbed; the dead Copy import; the dead navigate reference; the inner formId shadowing; deleteForm's unparsed error fallback.
Acceptance checks
From alchemy_labs/frontend at 62ba4fc with the harness up (tooling/verify/labs-local.sh up, a tenant session), or from a new app built to this recipe:
grep -n "api\.\(get\|post\|put\|delete\)(" src/pages/FormBuilderPage.tsxprints exactly four lines:GET /api/forms/templates,POST /api/forms/templates,PUT /api/forms/templates/${selectedFormId},DELETE /api/forms/templates/${formId}.grep -c "console.error" src/pages/FormBuilderPage.tsxprints2at 62ba4fc (fetchForms,deleteForm), neither paired with asetError/state-setting call for the list, confirming the silent-failure Gotcha.- Browser walk-through, signed in as a tenant: (a) open
/forms/builder, observe the shell paint first and the spinner where the list will be, then the card grid; (b) click "Neues Formular", add a text field and an email field, toggle "Mehrseitig" on, add a second page, drag the email field's "down" arrow (it stays on the first page, the second page starts empty); (c) click "Vorschau", observe the right sidebar mirrors the fields on the currently active page only; (d) click "Speichern" with a name and at least one field filled, observe the editor closes and the new card appears in the grid; (e) on a public template's card, click the embed-copy button, observe the Check icon appear on it and on every other public card at the same time, and disappear after two seconds; (f) unset the API base URL to force a list-fetch failure, reload, observe the page render "Noch keine Formulare" exactly as it would for a truly empty workspace, no error message anywhere. - Demo gate:
node tooling/verify/render-smoke.mjs forms-leads/ui/form-builder-pagerenders all eight states, 0 failures. - Verifier:
node tooling/verify/compare.mjs forms-leads/ui/form-builder-page --state list-populated --docs http://127.0.0.1:4321 --app http://127.0.0.1:3020 --session-file tooling/verify/.labs-local/session.json(a tenant session with at least three seeded templates matching the demo's mix of public/private and single/multi-page) writes a report withpage_errors: [];mismatch_ratiois read for the header and the card grid only, since the app's seeded templates' exact names/descriptions differ from the demo's sample data.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.