feat: dynamic form fields, JSON output, branched questionnaires

This commit is contained in:
Jonas Miederer 2026-09-27 22:26:48 +02:00
parent 0b03399c1a
commit 0d43b0e8fb
11 changed files with 2579 additions and 92 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -39,26 +39,58 @@ def test_component_contract():
def test_collect_merges_submitted_values_over_defaults():
from lfx.schema.message import Message
from lfx.schema.data import Data
form = CustomInputForm()
default = json.loads(form.collect().text)
assert default["full_name"] == ""
assert default["department"] == "Music"
assert isinstance(form.collect(), Message)
default = form.collect()
assert isinstance(default, Data)
assert default.data["full_name"] == ""
assert default.data["department"] == "Music"
form.form_values = json.dumps({"full_name": "Maria", "age": 41})
merged = json.loads(form.collect().text)
merged = form.collect().data
assert merged["full_name"] == "Maria"
assert merged["age"] == 41
assert merged["department"] == "Music" # default kept
# Run envelope shape {"payload": {...}} unwraps like the other components.
form.form_values = json.dumps({"payload": {"notes": "hi"}})
assert json.loads(form.collect().text)["notes"] == "hi"
assert form.collect().data["notes"] == "hi"
form.form_values = "not json"
assert json.loads(form.collect().text)["full_name"] == ""
assert form.collect().data["full_name"] == ""
def test_collect_picks_up_langflow_added_field():
"""A field added in the Langflow code editor needs no collect() change."""
from lfx.io import StrInput
form = CustomInputForm()
form._inputs["room"] = StrInput(name="room", display_name="Room", info="Where", value="Chapel")
collected = form.collect().data
assert collected["room"] == "Chapel"
assert collected["full_name"] == ""
def test_collect_survives_rename():
"""Renaming `full_name` to `name` drops the old key, keeps the new one."""
from lfx.io import StrInput
form = CustomInputForm()
del form._inputs["full_name"]
form._inputs["name"] = StrInput(name="name", display_name="Name", info="", value="Maria")
collected = form.collect().data
assert "full_name" not in collected
assert collected["name"] == "Maria"
def test_collect_keeps_unknown_envelope_keys():
"""Frontend payloads with renamed keys merge even if undeclared here."""
form = CustomInputForm()
form.form_values = json.dumps({"payload": {"nickname": "Mia"}})
collected = form.collect().data
assert collected["nickname"] == "Mia"
assert collected["department"] == "Music" # default kept
def test_intake_fixture_shape():
@ -72,6 +104,8 @@ def test_intake_fixture_shape():
assert field in template, field
assert template["form_values"]["show"] is False
assert len(template["code"]["value"]) > 200
outputs = {o["name"]: o for o in form["data"]["node"]["outputs"]}
assert outputs["form_data"]["types"] == ["JSON"] # JSON object, not Message
edges = {(e["source"], e["target"]) for e in doc["data"]["edges"]}
assert ("ChatInput-entry", "CustomInputForm-intake") in edges
assert ("CustomInputForm-intake", "ChatOutput-collected") in edges
@ -99,7 +133,132 @@ async def test_intake_deploy_and_run_with_values(client, lfx_server, login):
)
assert run.status_code == 200, run.text
assert run.json()["status"] == "completed"
result = json.loads(run.json()["output"]["result"])
# collect() returns Data: the terminal ChatOutput renders it as a
# fenced ```json block, so unwrap before parsing.
result = _parse_chat_json(run.json()["output"]["result"])
assert result["full_name"] == "Maria Muster"
assert result["age"] == 41
assert result["department"] == "Music" # default preserved
def _parse_chat_json(text: str) -> dict:
"""Parse terminal ChatOutput text: raw JSON or a ```json fenced block."""
text = text.strip()
if text.startswith("```"):
lines = text.splitlines()
assert lines[0].startswith("```"), text[:60]
assert lines[-1].startswith("```"), text[-60:]
text = "\n".join(lines[1:-1])
return json.loads(text)
def test_bauvorhaben_fixture_shape():
doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8"))
assert doc["name"] == "bauvorhaben"
nodes = {n["id"]: n for n in doc["data"]["nodes"]}
form = nodes["CustomInputForm-bauvorhaben"]
assert form["data"]["type"] == "CustomInputForm"
template = form["data"]["node"]["template"]
assert set(template) >= {
"form_values",
"gebaeude",
"bestandteil",
"glockenmassnahme",
"gussjahr",
"gebaeudeart",
"denkmalschutz",
"denkmalnummer",
"code",
}
outputs = {o["name"]: o for o in form["data"]["node"]["outputs"]}
assert outputs["form_data"]["types"] == ["JSON"]
edges = {(e["source"], e["target"]) for e in doc["data"]["edges"]}
assert ("ChatInput-entry", "CustomInputForm-bauvorhaben") in edges
assert ("CustomInputForm-bauvorhaben", "ChatOutput-collected") in edges
def test_bauvorhaben_schema_branches():
from app.flows import input_schema as parser
doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8"))
out = parser.extract_form_schema(doc)
schema = out["json_schema"]
assert set(schema["properties"]) == {"gebaeude", "denkmalschutz"}
assert schema["required"] == ["gebaeude"]
assert len(schema["allOf"]) == 5
by_field = {next(iter(rule["then"]["properties"])): rule for rule in schema["allOf"]}
assert set(by_field) == {
"bestandteil",
"glockenmassnahme",
"gussjahr",
"gebaeudeart",
"denkmalnummer",
}
assert by_field["bestandteil"]["if"]["properties"] == {"gebaeude": {"const": "Kirche"}}
assert by_field["glockenmassnahme"]["if"]["properties"] == {
"bestandteil": {"contains": {"const": "Glocke"}}
}
assert by_field["gussjahr"]["if"]["properties"] == {"glockenmassnahme": {"const": "Neuguss"}}
assert by_field["gebaeudeart"]["if"]["properties"] == {"gebaeude": {"const": "Anderes Gebäude"}}
assert by_field["denkmalnummer"]["if"]["properties"] == {"denkmalschutz": {"const": True}}
assert by_field["bestandteil"]["then"]["required"] == ["bestandteil"]
assert by_field["denkmalnummer"]["then"]["required"] == ["denkmalnummer"]
assert "required" not in by_field["gussjahr"]["then"]
# Markers stripped from descriptions.
assert "visible_if" not in out["json_schema"]["allOf"][0]["then"]["properties"][
"bestandteil"
].get("description", "")
assert out["ui_schema"]["ui:order"] == [
"gebaeude",
"bestandteil",
"glockenmassnahme",
"gussjahr",
"gebaeudeart",
"denkmalschutz",
"denkmalnummer",
"*",
]
async def test_bauvorhaben_deploy_and_run_nested_values(client, lfx_server, login):
await login()
doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8"))
created = await client.post("/api/flows", json={"name": "bauvorhaben-e2e"})
assert created.status_code == 201, created.text
flow_id = created.json()["id"]
version = await client.post(
f"/api/flows/{flow_id}/versions",
json={"langflow_json": doc, "author": "test"},
)
assert version.status_code == 201, version.text
# Branch rules are cached per version and served via input-schema.
cached = version.json()["input_schema"]
assert len(cached["json_schema"]["allOf"]) == 5
deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy")
assert deployed.status_code == 200, deployed.text
schema = await client.get(f"/api/flows/{flow_id}/input-schema")
assert schema.status_code == 200, schema.text
assert set(schema.json()["json_schema"]["properties"]) == {"gebaeude", "denkmalschutz"}
run = await client.post(
f"/api/flows/{flow_id}/run",
json={
"input": {
"gebaeude": "Kirche",
"bestandteil": ["Glocke", "Orgel"],
"glockenmassnahme": "Neuguss",
"gussjahr": 1890,
"denkmalschutz": True,
"denkmalnummer": "D-123",
}
},
)
assert run.status_code == 200, run.text
assert run.json()["status"] == "completed"
result = _parse_chat_json(run.json()["output"]["result"])
assert result["gebaeude"] == "Kirche"
assert result["bestandteil"] == ["Glocke", "Orgel"]
assert result["gussjahr"] == 1890
assert result["denkmalnummer"] == "D-123"

View file

@ -38,6 +38,16 @@ def _doc(*fields, node_type="CustomInputForm", display="CustomInput Form"):
return {"data": {"nodes": [node], "edges": []}}
def _dropdown(name, options, **kw):
return _field(name, "DropdownInput", options=options, **kw)
def _branch_schema(doc):
schema = parser.extract_form_schema(doc)["json_schema"]
assert "allOf" in schema
return schema
def test_type_mapping_table():
doc = _doc(
_field("a", "StrInput", value="x"),
@ -192,3 +202,148 @@ async def test_input_schema_endpoint(client, lfx_server, login):
assert body["fallback"] is None
assert set(body["json_schema"]["properties"]) >= {"full_name", "topics"}
assert body["ui_schema"]["password"] == {"ui:widget": "password"}
def test_free_rename_keys_kept_verbatim():
doc = _doc(
_field("name", "StrInput", display_name="Name", required=True),
_field("favorite room", "StrInput"),
)
schema = parser.extract_form_schema(doc)
assert set(schema["json_schema"]["properties"]) == {"name", "favorite room"}
assert schema["json_schema"]["required"] == ["name"]
def test_empty_name_skipped_and_collision_suffixed():
a = _field("a", "StrInput")
clash = _field("other_key", "StrInput")
clash["name"] = "a" # Langflow rename collision: second field gets a suffix
empty = _field("zzz", "StrInput")
empty["name"] = " "
node = {
"data": {
"type": "CustomInputForm",
"node": {
"display_name": "CustomInput Form",
"template": {"a": a, "other_key": clash, "zzz": empty},
},
}
}
doc = {"data": {"nodes": [node], "edges": []}}
props = parser.extract_form_schema(doc)["json_schema"]["properties"]
assert set(props) == {"a", "a_2"}
def test_visible_if_single_branch():
doc = _doc(
_dropdown("gebaeude", ["Kirche", "Halle"], required=True),
_field(
"bestandteil",
"MultiselectInput",
options=["Glocke", "Orgel"],
info="Bitte wählen [visible_if gebaeude=Kirche]",
required=True,
),
)
out = parser.extract_form_schema(doc)
schema = out["json_schema"]
# Conditional field lives only in its branch, not at root.
assert set(schema["properties"]) == {"gebaeude"}
assert schema["required"] == ["gebaeude"]
assert len(schema["allOf"]) == 1
rule = schema["allOf"][0]
assert rule["if"] == {
"properties": {"gebaeude": {"const": "Kirche"}},
"required": ["gebaeude"],
}
assert rule["then"]["properties"]["bestandteil"]["type"] == "array"
assert rule["then"]["required"] == ["bestandteil"]
# Marker stripped from the shown description.
assert schema["properties"]["gebaeude"]["title"] == "Gebaeude"
assert out["ui_schema"]["ui:order"] == ["gebaeude", "bestandteil", "*"]
def test_visible_if_nested_three_levels():
doc = _doc(
_dropdown("a", ["x", "y"]),
_dropdown("b", ["p", "q"], info="[visible_if a=x]"),
_field("c", "IntInput", info="[visible_if b=p]"),
_field("d", "StrInput", info="[visible_if c=3]"),
)
schema = _branch_schema(doc)
assert set(schema["properties"]) == {"a"}
assert len(schema["allOf"]) == 3
by_field = {next(iter(rule["then"]["properties"])): rule for rule in schema["allOf"]}
assert by_field["b"]["if"] == {
"properties": {"a": {"const": "x"}},
"required": ["a"],
}
assert by_field["c"]["if"]["properties"] == {"b": {"const": "p"}}
assert by_field["d"]["if"]["properties"] == {"c": {"const": 3}}
def test_visible_if_and_quoted_bool_multiselect():
doc = _doc(
_dropdown("a", ["x", "y"]),
_field("flag", "BoolInput"),
_field(
"m",
"MultiselectInput",
options=["Glocke", "Orgel"],
info='[visible_if a="x" & flag=true]',
),
)
schema = _branch_schema(doc)
rule = schema["allOf"][0]
assert rule["if"] == {
"properties": {"a": {"const": "x"}, "flag": {"const": True}},
"required": ["a", "flag"],
}
assert rule["then"]["properties"]["m"]["type"] == "array"
def test_visible_if_multiselect_trigger_uses_contains():
doc = _doc(
_field("tags", "MultiselectInput", options=["Glocke", "Orgel"]),
_field("detail", "StrInput", info="[visible_if tags=Glocke]"),
)
schema = _branch_schema(doc)
assert schema["allOf"][0]["if"]["properties"] == {"tags": {"contains": {"const": "Glocke"}}}
def test_visible_if_dangling_unsupported_cycle_fail_open():
doc = _doc(
_dropdown("a", ["x", "y"]),
_field("ok", "StrInput", info="[visible_if a=x]"),
_field("dangling", "StrInput", info="[visible_if nope=x]"),
_field("slider_dep", "StrInput", info="[visible_if slid=x]"),
_field(
"slid",
"SliderInput",
value=3,
range_spec={"min": 1, "max": 5, "step": 1},
),
_field("c1", "StrInput", info="[visible_if c2=x]"),
_field("c2", "StrInput", info="[visible_if c1=x]"),
_field("selfish", "StrInput", info="[visible_if selfish=x]"),
)
schema = parser.extract_form_schema(doc)["json_schema"]
# Only the valid marker produces a branch; the rest stay at root.
assert len(schema["allOf"]) == 1
assert set(schema["properties"]) >= {
"a",
"dangling",
"slider_dep",
"slid",
"c1",
"c2",
"selfish",
}
assert "ok" not in schema["properties"]
def test_no_markers_no_allof():
doc = _doc(_dropdown("a", ["x", "y"]))
schema = parser.extract_form_schema(doc)["json_schema"]
assert "allOf" not in schema
assert set(schema["properties"]) == {"a"}