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
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -6,6 +6,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### Added
|
||||||
|
- Branched questionnaires: `[visible_if feld=Wert]` markers in a
|
||||||
|
`CustomInputForm` field's `info` (code editor) show the field only when
|
||||||
|
the condition holds — nested to any depth, with `&` for AND conditions
|
||||||
|
and quotes for values with spaces. The parser emits `allOf`/`if`/`then`
|
||||||
|
branches (branch-scoped `required`, template-order placement); unknown
|
||||||
|
triggers, cycles, and unsupported trigger types fail open (always
|
||||||
|
visible). New demo flow `bauvorhaben.json` (4 levels + sibling branch +
|
||||||
|
bool trigger). Values of hidden fields are kept and submitted.
|
||||||
|
### Changed
|
||||||
|
- `CustomInputForm.collect()` is now dynamic and returns `Data` (JSON)
|
||||||
|
instead of `Message`: fields can be renamed, added, or removed in the
|
||||||
|
Langflow code editor with no `collect()` change, and values travel as a
|
||||||
|
real JSON object. The form parser keeps `name` keys verbatim (empty
|
||||||
|
names skipped, collisions suffixed) and the run envelope merges unknown
|
||||||
|
keys. Note: a terminal `ChatOutput` renders `Data` as a fenced ```json
|
||||||
|
block, and downstream components should read `.data`, not `.text`.
|
||||||
|
### Added
|
||||||
- Flow-Sync ist jetzt ein manueller Full-Pass: `POST /api/flows/sync`
|
- Flow-Sync ist jetzt ein manueller Full-Pass: `POST /api/flows/sync`
|
||||||
(Button "Aus Langflow synchronisieren" in der Verwaltung) holt erst alle
|
(Button "Aus Langflow synchronisieren" in der Verwaltung) holt erst alle
|
||||||
Flows aus Langflow nach `backend/flows/*.json` und deployed sie danach
|
Flows aus Langflow nach `backend/flows/*.json` und deployed sie danach
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@ Looks for ``CustomInput*`` nodes (``data.type`` or the inner
|
||||||
``backend/components/kirche/form_inputs.py``) and maps their ``template``
|
``backend/components/kirche/form_inputs.py``) and maps their ``template``
|
||||||
(minus ``code``) to ``{json_schema, ui_schema}`` for RJSF:
|
(minus ``code``) to ``{json_schema, ui_schema}`` for RJSF:
|
||||||
|
|
||||||
* ``name`` → property key, ``display_name`` → ``title``,
|
* ``name`` → property key (freely renamable in the Langflow code editor;
|
||||||
|
kept verbatim so frontend payload keys match the runtime output —
|
||||||
|
only empty names are skipped and collisions get a ``_2``/``_3`` suffix),
|
||||||
|
``display_name`` → ``title``,
|
||||||
``info`` → ``description``, ``value`` → ``default``,
|
``info`` → ``description``, ``value`` → ``default``,
|
||||||
``options`` → ``enum``, ``required`` → required array
|
``options`` → ``enum``, ``required`` → required array
|
||||||
* ``show: false`` fields are plumbing (e.g. the run envelope input), not
|
* ``show: false`` fields are plumbing (e.g. the run envelope input), not
|
||||||
|
|
@ -13,6 +16,10 @@ Looks for ``CustomInput*`` nodes (``data.type`` or the inner
|
||||||
* ``advanced`` fields render last via ``ui:order``
|
* ``advanced`` fields render last via ``ui:order``
|
||||||
* ``_input_type`` → JSON type + widget (unknown types fall back to string
|
* ``_input_type`` → JSON type + widget (unknown types fall back to string
|
||||||
so the parser never crashes on new fields)
|
so the parser never crashes on new fields)
|
||||||
|
* ``[visible_if ...]`` markers in ``info`` → conditional branches
|
||||||
|
(``allOf`` with ``if``/``then`` per dependent field, arbitrarily nested;
|
||||||
|
see ``_extract_visibility``). The marker is stripped from the shown
|
||||||
|
description.
|
||||||
|
|
||||||
A flow without such a node gets ``{fallback: "textarea"}``: the frontend
|
A flow without such a node gets ``{fallback: "textarea"}``: the frontend
|
||||||
renders one free-text box instead of a generated form.
|
renders one free-text box instead of a generated form.
|
||||||
|
|
@ -20,6 +27,16 @@ renders one free-text box instead of a generated form.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
#: ``[visible_if gebaeude=Kirche & bestandteil="Glocke, Orgel"]`` in a
|
||||||
|
#: field's ``info``. Authored in the Langflow code editor; the parser strips
|
||||||
|
#: the marker from the shown description and turns the conditions into
|
||||||
|
#: ``allOf``/``if``/``then`` branches (draft-07 — the ``dependencies``/
|
||||||
|
#: ``if`` keywords this stack's RJSF + ajv build honors; ``dependentSchemas``
|
||||||
|
#: is ignored there, so it is never emitted).
|
||||||
|
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
|
||||||
|
|
||||||
|
|
||||||
def _form_templates(langflow_json: dict) -> list[dict]:
|
def _form_templates(langflow_json: dict) -> list[dict]:
|
||||||
"""Inner node dicts (``data.node``) of all CustomInput* nodes."""
|
"""Inner node dicts (``data.node``) of all CustomInput* nodes."""
|
||||||
|
|
@ -112,6 +129,106 @@ def _field_schema(name: str, field: dict) -> tuple[dict, dict]:
|
||||||
return prop, ui
|
return prop, ui
|
||||||
|
|
||||||
|
|
||||||
|
def _split_conditions(text: str) -> list[str]:
|
||||||
|
"""Split ``a=X & b="Y Z"`` on ``&`` outside double quotes."""
|
||||||
|
parts: list[str] = []
|
||||||
|
buf: list[str] = []
|
||||||
|
quoted = False
|
||||||
|
for char in text:
|
||||||
|
if char == '"':
|
||||||
|
quoted = not quoted
|
||||||
|
buf.append(char)
|
||||||
|
elif char == "&" and not quoted:
|
||||||
|
parts.append("".join(buf))
|
||||||
|
buf = []
|
||||||
|
else:
|
||||||
|
buf.append(char)
|
||||||
|
parts.append("".join(buf))
|
||||||
|
return [part.strip() for part in parts if part.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_condition(token: str) -> tuple[str, object] | None:
|
||||||
|
"""``feld=Wert`` → ``(feld, wert)`` (bool/number coerced, quotes stripped)."""
|
||||||
|
field, sep, value = token.partition("=")
|
||||||
|
field, value = field.strip(), value.strip()
|
||||||
|
if not sep or not field or not value:
|
||||||
|
return None
|
||||||
|
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
|
||||||
|
value = value[1:-1]
|
||||||
|
lowered = value.lower()
|
||||||
|
if lowered == "true":
|
||||||
|
return field, True
|
||||||
|
if lowered == "false":
|
||||||
|
return field, False
|
||||||
|
try:
|
||||||
|
return field, int(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return field, float(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return field, value
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_visibility(info: str) -> tuple[str, list[tuple[str, object]]]:
|
||||||
|
"""(marker-free description, conditions) for a field's ``info`` text."""
|
||||||
|
conditions: list[tuple[str, object]] = []
|
||||||
|
|
||||||
|
def _collect(match: re.Match) -> str:
|
||||||
|
for token in _split_conditions(match.group(1)):
|
||||||
|
parsed = _parse_condition(token)
|
||||||
|
if parsed is not None:
|
||||||
|
conditions.append(parsed)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
clean = _VISIBLE_RE.sub(_collect, info or "")
|
||||||
|
clean = re.sub(r"\s+", " ", clean).strip()
|
||||||
|
return clean, conditions
|
||||||
|
|
||||||
|
|
||||||
|
def _match_schema(trigger_field: dict, value: object) -> dict | None:
|
||||||
|
"""JSON-Schema fragment matching one condition, or None (unsupported)."""
|
||||||
|
input_type = str(trigger_field.get("_input_type") or "")
|
||||||
|
if input_type == "MultiselectInput":
|
||||||
|
return {"contains": {"const": value}}
|
||||||
|
if input_type == "BoolInput":
|
||||||
|
return {"const": value} if isinstance(value, bool) else None
|
||||||
|
if input_type in (
|
||||||
|
"StrInput",
|
||||||
|
"MessageTextInput",
|
||||||
|
"MultilineInput",
|
||||||
|
"SecretStrInput",
|
||||||
|
"DropdownInput",
|
||||||
|
):
|
||||||
|
return {"const": value} if isinstance(value, str) else None
|
||||||
|
if input_type in ("IntInput", "FloatInput"):
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
return None
|
||||||
|
return {"const": value}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cyclic_fields(edges: dict[str, set[str]]) -> set[str]:
|
||||||
|
"""Fields on a trigger→dependent cycle (fail-open candidates)."""
|
||||||
|
cyclic: set[str] = set()
|
||||||
|
visiting: list[str] = []
|
||||||
|
|
||||||
|
def _visit(node: str) -> None:
|
||||||
|
visiting.append(node)
|
||||||
|
for target in edges.get(node, ()):
|
||||||
|
if target in visiting:
|
||||||
|
cyclic.update(visiting[visiting.index(target) :])
|
||||||
|
elif target not in cyclic:
|
||||||
|
_visit(target)
|
||||||
|
visiting.pop()
|
||||||
|
|
||||||
|
for node in edges:
|
||||||
|
if node not in cyclic:
|
||||||
|
_visit(node)
|
||||||
|
return cyclic
|
||||||
|
|
||||||
|
|
||||||
def extract_form_schema(langflow_json: dict) -> dict:
|
def extract_form_schema(langflow_json: dict) -> dict:
|
||||||
"""``{json_schema, ui_schema}``, or with ``fallback: "textarea"``."""
|
"""``{json_schema, ui_schema}``, or with ``fallback: "textarea"``."""
|
||||||
templates = _form_templates(langflow_json)
|
templates = _form_templates(langflow_json)
|
||||||
|
|
@ -122,31 +239,92 @@ def extract_form_schema(langflow_json: dict) -> dict:
|
||||||
"fallback": "textarea",
|
"fallback": "textarea",
|
||||||
}
|
}
|
||||||
template = templates[0].get("template") or {}
|
template = templates[0].get("template") or {}
|
||||||
properties: dict = {}
|
# First pass: sanitize names (existing rules) and parse markers.
|
||||||
required: list[str] = []
|
records: list[dict] = []
|
||||||
ui_schema: dict = {}
|
taken: set[str] = set()
|
||||||
normal: list[str] = []
|
|
||||||
advanced: list[str] = []
|
|
||||||
for key, field in template.items():
|
for key, field in template.items():
|
||||||
if key == "code" or not isinstance(field, dict):
|
if key == "code" or not isinstance(field, dict):
|
||||||
continue
|
continue
|
||||||
if field.get("show") is False:
|
if field.get("show") is False:
|
||||||
continue
|
continue
|
||||||
name = field.get("name") or key
|
raw_name = field.get("name") or key
|
||||||
prop, ui = _field_schema(name, field)
|
name = str(raw_name).strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
if name in taken:
|
||||||
|
suffix = 2
|
||||||
|
while f"{name}_{suffix}" in taken:
|
||||||
|
suffix += 1
|
||||||
|
name = f"{name}_{suffix}"
|
||||||
|
taken.add(name)
|
||||||
|
clean_info, conditions = _extract_visibility(str(field.get("info") or ""))
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"field": {**field, "info": clean_info},
|
||||||
|
"required": bool(field.get("required")),
|
||||||
|
"advanced": bool(field.get("advanced")),
|
||||||
|
"conditions": conditions,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
by_name = {record["name"]: record for record in records}
|
||||||
|
# Validate conditions: known trigger + supported match. Anything else
|
||||||
|
# fails open (field stays always visible) so a stale marker can never
|
||||||
|
# hide data or break validation.
|
||||||
|
matches: dict[str, list[tuple[str, dict]]] = {}
|
||||||
|
for record in records:
|
||||||
|
resolved: list[tuple[str, dict]] = []
|
||||||
|
for trigger, value in record["conditions"]:
|
||||||
|
trigger_record = by_name.get(trigger)
|
||||||
|
match = _match_schema(trigger_record["field"], value) if trigger_record else None
|
||||||
|
if trigger_record is None or match is None or trigger == record["name"]:
|
||||||
|
resolved = []
|
||||||
|
break
|
||||||
|
resolved.append((trigger, match))
|
||||||
|
if resolved:
|
||||||
|
matches[record["name"]] = resolved
|
||||||
|
for name in _cyclic_fields(
|
||||||
|
{name: {trigger for trigger, _ in conds} for name, conds in matches.items()}
|
||||||
|
):
|
||||||
|
del matches[name]
|
||||||
|
# Emit: conditional fields live only in their if/then branch (that is
|
||||||
|
# what hides them in RJSF); everything else stays at root.
|
||||||
|
properties: dict = {}
|
||||||
|
required: list[str] = []
|
||||||
|
ui_schema: dict = {}
|
||||||
|
normal: list[str] = []
|
||||||
|
advanced: list[str] = []
|
||||||
|
for record in records:
|
||||||
|
name = record["name"]
|
||||||
|
prop, ui = _field_schema(name, record["field"])
|
||||||
|
if name not in matches:
|
||||||
properties[name] = prop
|
properties[name] = prop
|
||||||
if field.get("required"):
|
if record["required"]:
|
||||||
required.append(name)
|
required.append(name)
|
||||||
|
else:
|
||||||
|
record["branch_prop"] = prop
|
||||||
if ui:
|
if ui:
|
||||||
ui_schema[name] = ui
|
ui_schema[name] = ui
|
||||||
(advanced if field.get("advanced") else normal).append(name)
|
(advanced if record["advanced"] else normal).append(name)
|
||||||
if advanced:
|
json_schema: dict = {"type": "object", "properties": properties, "required": required}
|
||||||
ui_schema["ui:order"] = normal + advanced + ["*"]
|
if matches:
|
||||||
return {
|
rules = []
|
||||||
"json_schema": {
|
for name, conds in matches.items():
|
||||||
"type": "object",
|
record = by_name[name]
|
||||||
"properties": properties,
|
rules.append(
|
||||||
"required": required,
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {trigger: match for trigger, match in conds},
|
||||||
|
"required": [trigger for trigger, _ in conds],
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"properties": {name: record["branch_prop"]},
|
||||||
|
**({"required": [name]} if record["required"] else {}),
|
||||||
},
|
},
|
||||||
"ui_schema": ui_schema,
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
json_schema["allOf"] = rules
|
||||||
|
ui_schema["ui:order"] = normal + advanced + ["*"]
|
||||||
|
elif advanced:
|
||||||
|
ui_schema["ui:order"] = normal + advanced + ["*"]
|
||||||
|
return {"json_schema": json_schema, "ui_schema": ui_schema}
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,13 @@ derives a JSON Schema + uiSchema from the node's template
|
||||||
|
|
||||||
1. Open the Langflow authoring UI (http://localhost:7860) and load the
|
1. Open the Langflow authoring UI (http://localhost:7860) and load the
|
||||||
flow from `LANGFLOW_LOAD_FLOWS_PATH` (canonical files are preloaded).
|
flow from `LANGFLOW_LOAD_FLOWS_PATH` (canonical files are preloaded).
|
||||||
2. Edit the `CustomInputForm` node: every entry in its `inputs` list is
|
2. Edit the `CustomInputForm` node **in its code editor**: every entry in
|
||||||
one web-form field. Add an `lfx.io` input, e.g.
|
its `inputs` list is one web-form field. Add an `lfx.io` input, e.g.
|
||||||
```python
|
```python
|
||||||
from lfx.io import DropdownInput
|
from lfx.io import DropdownInput
|
||||||
|
|
||||||
DropdownInput(
|
DropdownInput(
|
||||||
name="room", # JSON-Schema property key
|
name="room", # JSON-Schema property key (freely renamable)
|
||||||
display_name="Room", # form label (title)
|
display_name="Room", # form label (title)
|
||||||
info="Where should it take place", # help text (description)
|
info="Where should it take place", # help text (description)
|
||||||
options=["Chapel", "Hall"], # dropdown choices (enum)
|
options=["Chapel", "Hall"], # dropdown choices (enum)
|
||||||
|
|
@ -29,15 +29,91 @@ derives a JSON Schema + uiSchema from the node's template
|
||||||
`DropdownInput` → select, `MultiselectInput` → multi-select,
|
`DropdownInput` → select, `MultiselectInput` → multi-select,
|
||||||
`SliderInput` → slider (needs `range_spec`), `FileInput` → text stub
|
`SliderInput` → slider (needs `range_spec`), `FileInput` → text stub
|
||||||
(real upload later).
|
(real upload later).
|
||||||
3. Add the new field to `collect()` so it reaches the JSON output.
|
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
|
4. **Wire the envelope**: keep the `ChatInput → form_values` edge (hidden
|
||||||
input, `show=False`). Submitted web-form values arrive there as
|
input, `show=False`). Submitted web-form values arrive there as
|
||||||
`{"payload": {...}}` and are merged over the defaults. Never make form
|
`{"payload": {...}}` and are merged over the defaults — including keys
|
||||||
fields required *with* an incoming edge — that's the envelope's job.
|
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/<name>.json` and commit.
|
5. Export the flow (JSON) into `backend/flows/<name>.json` and commit.
|
||||||
The API watcher syncs + deploys it automatically; the form schema is
|
The API watcher syncs + deploys it automatically; the form schema is
|
||||||
regenerated per version.
|
regenerated per version.
|
||||||
|
|
||||||
|
## Branched questionnaires (`visible_if`)
|
||||||
|
|
||||||
|
A field can appear only when other fields have certain values — nested
|
||||||
|
to any depth. Declare it in the code editor with a marker in `info`:
|
||||||
|
|
||||||
|
```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 <feld>=<wert>]`, `&` 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).
|
||||||
|
|
||||||
|
## 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
|
## Conventions the pipeline relies on
|
||||||
|
|
||||||
- `required=True` on a template field: `lfx validate --level 4` demands a
|
- `required=True` on a template field: `lfx validate --level 4` demands a
|
||||||
|
|
@ -45,7 +121,10 @@ derives a JSON Schema + uiSchema from the node's template
|
||||||
(`backend/scripts/make_flow_fixtures.py`) writes a single-space
|
(`backend/scripts/make_flow_fixtures.py`) writes a single-space
|
||||||
sentinel value for such fields. The form parser treats whitespace-only
|
sentinel value for such fields. The form parser treats whitespace-only
|
||||||
values as "no default" — don't ship real whitespace defaults.
|
values as "no default" — don't ship real whitespace defaults.
|
||||||
- Field key = `name` (stable); label = `display_name`; help = `info`.
|
- 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
|
- Unknown future input types degrade to a plain text field in the web
|
||||||
form (parser fallback), so adding exotic lfx inputs never breaks runs.
|
form (parser fallback), so adding exotic lfx inputs never breaks runs.
|
||||||
|
|
||||||
|
|
@ -56,3 +135,9 @@ canonical + test-fixture exports:
|
||||||
|
|
||||||
docker compose exec api /opt/venv/bin/python scripts/make_flow_fixtures.py
|
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
|
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.
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,17 @@ keyed by ``name``. Field metadata maps as follows:
|
||||||
* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint
|
* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint
|
||||||
* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``)
|
* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``)
|
||||||
|
|
||||||
|
``collect()`` is fully dynamic: it emits every input except the plumbing
|
||||||
|
ones (``form_values``, ``code``), so fields can be renamed, added, or
|
||||||
|
removed in the Langflow code editor without touching this method.
|
||||||
|
``name`` is the stable JSON key (freely renamable, must stay unique and
|
||||||
|
non-empty); downstream components read the keys as-is.
|
||||||
|
|
||||||
|
``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``: the
|
||||||
|
values travel as a real JSON object. A terminal ``ChatOutput`` renders it
|
||||||
|
as a fenced ```json block; components consuming it should read ``.data``
|
||||||
|
(or ``get_text()``), not ``.text``.
|
||||||
|
|
||||||
``FileInput`` is a stub for now: it carries a string reference only, real
|
``FileInput`` is a stub for now: it carries a string reference only, real
|
||||||
upload comes later.
|
upload comes later.
|
||||||
"""
|
"""
|
||||||
|
|
@ -36,7 +47,7 @@ from lfx.io import (
|
||||||
SliderInput,
|
SliderInput,
|
||||||
StrInput,
|
StrInput,
|
||||||
)
|
)
|
||||||
from lfx.schema.message import Message
|
from lfx.schema.data import Data
|
||||||
|
|
||||||
|
|
||||||
def _text(value) -> str:
|
def _text(value) -> str:
|
||||||
|
|
@ -47,9 +58,30 @@ def _text(value) -> str:
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_safe(value):
|
||||||
|
"""Plain JSON value for one field: unwrap LFX wrappers, keep scalars."""
|
||||||
|
if value is None or isinstance(value, (bool, int, float, str)):
|
||||||
|
return value
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_json_safe(item) for item in value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(key): _json_safe(item) for key, item in value.items()}
|
||||||
|
text = getattr(value, "text", None)
|
||||||
|
if isinstance(text, str):
|
||||||
|
return text
|
||||||
|
data = getattr(value, "data", None)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return _json_safe(data)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
#: Inputs that are plumbing, not form fields (never part of the output).
|
||||||
|
PLUMBING_INPUTS = frozenset({"form_values", "code"})
|
||||||
|
|
||||||
|
|
||||||
class CustomInputForm(Component):
|
class CustomInputForm(Component):
|
||||||
display_name = "CustomInput Form"
|
display_name = "CustomInput Form"
|
||||||
description = "Collects structured form values into a single JSON message."
|
description = "Collects structured form values into a single JSON object."
|
||||||
icon = "ClipboardList"
|
icon = "ClipboardList"
|
||||||
|
|
||||||
inputs: ClassVar[list] = [
|
inputs: ClassVar[list] = [
|
||||||
|
|
@ -134,29 +166,49 @@ class CustomInputForm(Component):
|
||||||
Output(display_name="Form Data", name="form_data", method="collect"),
|
Output(display_name="Form Data", name="form_data", method="collect"),
|
||||||
]
|
]
|
||||||
|
|
||||||
def collect(self) -> Message:
|
def _field_names(self) -> list:
|
||||||
data = {
|
"""Names of all form fields (every input except the plumbing ones)."""
|
||||||
"full_name": _text(self.full_name),
|
inputs = getattr(self, "_inputs", None)
|
||||||
"notes": _text(self.notes),
|
if isinstance(inputs, dict) and inputs:
|
||||||
"password": _text(self.password),
|
return [name for name in inputs if name not in PLUMBING_INPUTS]
|
||||||
"age": self.age,
|
seen = []
|
||||||
"amount": self.amount,
|
declared = getattr(self, "inputs", None) or getattr(type(self), "inputs", None) or []
|
||||||
"newsletter": self.newsletter,
|
for inp in declared:
|
||||||
"department": _text(self.department),
|
name = getattr(inp, "name", None)
|
||||||
"topics": self.topics,
|
if name and name not in PLUMBING_INPUTS and name not in seen:
|
||||||
"satisfaction": self.satisfaction,
|
seen.append(name)
|
||||||
"attachment": _text(self.attachment),
|
return seen
|
||||||
}
|
|
||||||
|
def _field_value(self, name: str):
|
||||||
|
"""Runtime value of one input.
|
||||||
|
|
||||||
|
Reads ``_inputs`` directly: a field renamed to e.g. ``name`` would
|
||||||
|
otherwise resolve to the component attribute instead of its value.
|
||||||
|
"""
|
||||||
|
inputs = getattr(self, "_inputs", None)
|
||||||
|
if isinstance(inputs, dict) and name in inputs:
|
||||||
|
return getattr(inputs[name], "value", None)
|
||||||
|
try:
|
||||||
|
return getattr(self, name, None)
|
||||||
|
except (AttributeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def collect(self) -> Data:
|
||||||
|
data = {}
|
||||||
|
for name in self._field_names():
|
||||||
|
data[name] = _json_safe(self._field_value(name))
|
||||||
# Submitted values (via form_values) win over the field defaults.
|
# Submitted values (via form_values) win over the field defaults.
|
||||||
# Accepts the raw dict or the run envelope {"payload": {...}}.
|
# Accepts the raw dict or the run envelope {"payload": {...}}.
|
||||||
|
# Unknown keys are kept: the frontend may already know renamed
|
||||||
|
# fields this code revision does not declare.
|
||||||
try:
|
try:
|
||||||
override = json.loads(_text(self.form_values) or "{}")
|
override = json.loads(_text(self.form_values) or "{}")
|
||||||
except json.JSONDecodeError:
|
except (json.JSONDecodeError, ValueError, AttributeError):
|
||||||
override = {}
|
override = {}
|
||||||
if isinstance(override, dict):
|
if isinstance(override, dict):
|
||||||
if isinstance(override.get("payload"), dict):
|
if isinstance(override.get("payload"), dict):
|
||||||
override = override["payload"]
|
override = override["payload"]
|
||||||
for key in data:
|
for key, val in override.items():
|
||||||
if key in override:
|
if isinstance(key, str) and key:
|
||||||
data[key] = override[key]
|
data[key] = _json_safe(val)
|
||||||
return Message(text=json.dumps(data, ensure_ascii=False))
|
return Data(data=data)
|
||||||
|
|
|
||||||
919
backend/flows/bauvorhaben.json
Normal file
919
backend/flows/bauvorhaben.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -555,7 +555,10 @@ def main() -> None:
|
||||||
for filename, doc in flows.items():
|
for filename, doc in flows.items():
|
||||||
payload = json.dumps(doc, indent=2, ensure_ascii=False) + "\n"
|
payload = json.dumps(doc, indent=2, ensure_ascii=False) + "\n"
|
||||||
for target in (FIXTURE_DIR / filename, FLOW_DIR / filename):
|
for target in (FIXTURE_DIR / filename, FLOW_DIR / filename):
|
||||||
target.write_text(payload)
|
# Explicit encoding + LF: the default locale/newline handling
|
||||||
|
# corrupts non-ASCII flow code on Windows (cp1252) and dirties
|
||||||
|
# every line with CRLF.
|
||||||
|
target.write_text(payload, encoding="utf-8", newline="\n")
|
||||||
print(f"wrote {target}")
|
print(f"wrote {target}")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
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():
|
def test_collect_merges_submitted_values_over_defaults():
|
||||||
from lfx.schema.message import Message
|
from lfx.schema.data import Data
|
||||||
|
|
||||||
form = CustomInputForm()
|
form = CustomInputForm()
|
||||||
default = json.loads(form.collect().text)
|
default = form.collect()
|
||||||
assert default["full_name"] == ""
|
assert isinstance(default, Data)
|
||||||
assert default["department"] == "Music"
|
assert default.data["full_name"] == ""
|
||||||
assert isinstance(form.collect(), Message)
|
assert default.data["department"] == "Music"
|
||||||
|
|
||||||
form.form_values = json.dumps({"full_name": "Maria", "age": 41})
|
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["full_name"] == "Maria"
|
||||||
assert merged["age"] == 41
|
assert merged["age"] == 41
|
||||||
assert merged["department"] == "Music" # default kept
|
assert merged["department"] == "Music" # default kept
|
||||||
|
|
||||||
# Run envelope shape {"payload": {...}} unwraps like the other components.
|
# Run envelope shape {"payload": {...}} unwraps like the other components.
|
||||||
form.form_values = json.dumps({"payload": {"notes": "hi"}})
|
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"
|
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():
|
def test_intake_fixture_shape():
|
||||||
|
|
@ -72,6 +104,8 @@ def test_intake_fixture_shape():
|
||||||
assert field in template, field
|
assert field in template, field
|
||||||
assert template["form_values"]["show"] is False
|
assert template["form_values"]["show"] is False
|
||||||
assert len(template["code"]["value"]) > 200
|
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"]}
|
edges = {(e["source"], e["target"]) for e in doc["data"]["edges"]}
|
||||||
assert ("ChatInput-entry", "CustomInputForm-intake") in edges
|
assert ("ChatInput-entry", "CustomInputForm-intake") in edges
|
||||||
assert ("CustomInputForm-intake", "ChatOutput-collected") 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.status_code == 200, run.text
|
||||||
assert run.json()["status"] == "completed"
|
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["full_name"] == "Maria Muster"
|
||||||
assert result["age"] == 41
|
assert result["age"] == 41
|
||||||
assert result["department"] == "Music" # default preserved
|
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": []}}
|
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():
|
def test_type_mapping_table():
|
||||||
doc = _doc(
|
doc = _doc(
|
||||||
_field("a", "StrInput", value="x"),
|
_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 body["fallback"] is None
|
||||||
assert set(body["json_schema"]["properties"]) >= {"full_name", "topics"}
|
assert set(body["json_schema"]["properties"]) >= {"full_name", "topics"}
|
||||||
assert body["ui_schema"]["password"] == {"ui:widget": "password"}
|
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