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

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"}