feat: dynamic form fields, JSON output, branched questionnaires
This commit is contained in:
parent
0b03399c1a
commit
0d43b0e8fb
11 changed files with 2579 additions and 92 deletions
919
backend/tests/fixtures/flows/bauvorhaben.json
vendored
Normal file
919
backend/tests/fixtures/flows/bauvorhaben.json
vendored
Normal file
File diff suppressed because one or more lines are too long
14
backend/tests/fixtures/flows/intake_form.json
vendored
14
backend/tests/fixtures/flows/intake_form.json
vendored
File diff suppressed because one or more lines are too long
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue