# Authoring web forms (CustomInputForm) Flows that should render a web form start with a `CustomInputForm` node (this package — the `kirche` category in the Langflow palette). The API derives a JSON Schema + uiSchema from the node's template (`app/flows/input_schema.py`) and the webapp renders it with RJSF. ## Adding a new form field 1. Open the Langflow authoring UI (http://localhost:7860) and load the flow from `LANGFLOW_LOAD_FLOWS_PATH` (canonical files are preloaded). 2. Edit the `CustomInputForm` node **in its code editor**: every entry in its `inputs` list is one web-form field. Add an `lfx.io` input, e.g. ```python from lfx.io import DropdownInput DropdownInput( name="room", # JSON-Schema property key (freely renamable) display_name="Room", # form label (title) info="Where should it take place", # help text (description) options=["Chapel", "Hall"], # dropdown choices (enum) value="Chapel", # prefill (default) required=True, # marks the field required ) ``` Supported input types → widgets: `StrInput`/`MessageTextInput` → text, `MultilineInput` → textarea, `SecretStrInput` → password, `IntInput` → integer, `FloatInput` → number, `BoolInput` → checkbox, `DropdownInput` → select, `MultiselectInput` → multi-select, `SliderInput` → slider (needs `range_spec`), `FileInput` → text stub (real upload later). 3. Nothing else to change in code: `collect()` is dynamic — it emits every input except the plumbing ones, so renames, additions, and removals just work. (The inspector panel can tweak `display_name`, `info`, `value`, and `options` of existing fields, but `name` and new fields need the code editor.) 4. **Wire the envelope**: keep the `ChatInput → form_values` edge (hidden input, `show=False`). Submitted web-form values arrive there as `{"payload": {...}}` and are merged over the defaults — including keys the node code does not declare (forward-compatible with renames). Never make form fields required *with* an incoming edge — that's the envelope's job. 5. Export the flow (JSON) into `backend/flows/.json` and commit. The API watcher syncs + deploys it automatically; the form schema is regenerated per version. ## Structured `visible_if` (preferred) + markers (fallback) A field can appear only when other fields have certain values — nested to any depth. Preferred: structured `visible_if` key (no quoting mini-language, rename-refactorable, validated at sync time with warnings instead of silent fail-open): ```python ( DropdownInput( name="bestandteil", display_name="Welcher Bestandteil der Kirche?", options=["Glocke", "Orgel"], value=[], info="Mehrfachauswahl möglich", visible_if=[{"field": "gebaeude", "value": "Kirche"}], required=True, ), ) ``` Legacy `[visible_if ...]` markers in `info` still work as deprecated fallback (same syntax, stripped from help text). The parser emits `allOf`/`if`/`then` branches either way (see below). ```python ( DropdownInput( name="gebaeude", display_name="Welches Gebäude soll verändert werden?", options=["Kirche", "Anderes Gebäude"], value="Kirche", required=True, ), ) ( MultiselectInput( name="bestandteil", display_name="Welcher Bestandteil der Kirche?", options=["Glocke", "Orgel", "Innenraum", "Fenster", "Fassade"], value=[], info="Mehrfachauswahl möglich [visible_if gebaeude=Kirche]", required=True, ), ) ( DropdownInput( name="glockenmassnahme", display_name="Welche Maßnahme an der Glocke?", options=["Reinigung", "Reparatur", "Neuguss"], value="Reinigung", info="[visible_if bestandteil=Glocke]", ), ) ``` Syntax: `[visible_if =]`, `&` for AND (`[visible_if a=X & b=Y]`), double quotes for values with spaces (`[visible_if gebaeude="Anderes Gebäude"]`). Trigger matching by input type: Dropdown/Text `const`, Bool `const` (`true`/`false`), Multiselect `contains`, Int/Float exact number. Nesting is automatic: a field whose trigger is itself conditional renders one level deeper, without limit. What the parser does with markers: - The marker is stripped from the shown help text; the field leaves the top-level schema and lives only in its `allOf`/`if`/`then` branch, so RJSF hides it until the condition holds. `ui:order` keeps template order, so new questions appear directly below their trigger. - `required` on a branched field applies only while visible. - Values are always kept: hidden fields still validate and are submitted (stale values can keep deeper branches satisfied until changed). - Safety is fail-open: unknown triggers (renamed?), unsupported trigger types, self-references, and trigger cycles keep the field always visible instead of hiding data. Renaming a trigger means updating its markers. See `backend/flows/bauvorhaben.json` for a full 4-level example (Gebäude → Bestandteil → Maßnahme → Gussjahr, plus sibling branch and bool trigger). ## User-built form components (no kirche base needed) A plain `CustomComponent` whose display name ends with `"Form"` is also parsed into a web form — same template contract, no base class. To make submitted values reach it at runtime, add an envelope input and merge it in the output method (pattern): ```python from lfx.io import MessageTextInput # add to imports import json # add to inputs: MessageTextInput(name="form_values", display_name="Form values", value="", show=False) # at the top of the output method: try: raw = self.form_values.text if hasattr(self.form_values, "text") else self.form_values submitted = json.loads(raw or "{}") except Exception: submitted = {} if isinstance(submitted.get("payload"), dict): submitted = submitted["payload"] def val(name, default): return submitted.get(name, default) ``` Then use `val("rolle", self.rolle)` etc., and wire a `ChatInput` node into `form_values` on the canvas. Branch markers (`[visible_if ...]` in `info`) work here too — the parser reads the template only. ## Asked fields only (run envelope `fields`) The API sends the asked field names (from the version's form schema) in the run envelope's `fields` list. `collect()` then emits defaults only for asked AND currently visible fields (branch conditions are evaluated against submitted values over code defaults, so hidden fields stay out exactly as the frontend never submits them): the template may declare fields unknown to this code (they arrive via envelope and merge normally), and unasked code defaults stay out of the output. Without the list — raw dicts, Langflow playground — all visible declared defaults apply as before. ## Variant flows (own component per flow, shared core) A flow whose fields differ from `CustomInputForm` gets its own thin component (see `bauvorhaben_form.py` → "Bauvorhaben Form" in the palette): same class pattern, own `inputs` list, shared runtime from `kirche._form_core` (`from kirche._form_core import ...` plus the `# <-- _form_core inlined ... -->` marker). The LFX runtime executes the embedded module source without our `kirche` package, so `scripts/make_flow_fixtures.py:kirche_node()` inlines the core into the exported `code` — one truth in the repo, self-contained JSON. Copy the file as the starting point for future forms. Rule: code-declared inputs must equal template fields (`test_embedded_code_matches_template_fields` enforces it, plus no-sibling-import + core-present asserts; required Multiselect uses the `[" "]` sentinel). Generate node + flow with `kirche_node(...)` (which composes thin source + `_inline_core()` automatically) and `build_bauvorhaben()` in `backend/scripts/make_flow_fixtures.py` rather than hand-editing JSON. Restart Langflow afterwards so the palette picks up the new component, then build/upload the flow. ## Output type: Data (JSON), not Message `collect()` returns `Data(data={...})` — a real JSON object. Consequences: - A terminal `ChatOutput` renders it as a fenced ```json block; the run result text is that block, not raw JSON (unwrap before `json.loads`). - Components consuming `form_data` should read `.data` (or `get_text()`), not `.text` like with `Message`. ## Conventions the pipeline relies on - `required=True` on a template field: `lfx validate --level 4` demands a value or an edge, so the canonical-flow generator (`backend/scripts/make_flow_fixtures.py`) writes a single-space sentinel value for such fields (`[" "]` for Multiselect, which rejects a plain string at runtime). The form parser treats whitespace-only values as "no default" — don't ship real whitespace defaults. - Field key = `name` (stable, freely renamable but must stay unique and non-empty; collisions get a `_2`/`_3` suffix in the form schema); label = `display_name`; help = `info`. Renames propagate to downstream components, which read the JSON keys as-is. - Unknown future input types degrade to a plain text field in the web form (parser fallback), so adding exotic lfx inputs never breaks runs. ## Regenerating the bundled fixtures After editing the component source (`form_inputs.py`), refresh the canonical + test-fixture exports: docker compose exec api /opt/venv/bin/python scripts/make_flow_fixtures.py docker compose exec -T api uv run pytest tests/test_lfx_validate.py Notes: the generator writes UTF-8/LF explicitly (Windows-safe), and `backend/flows/*.json` may carry Langflow round-trip state (key order) the generator does not reproduce — if a regen rewrites canonical files beyond the component's own node, revert them and splice only the changed node fields (code, description, output types, edge handles) instead.