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
|
|
@ -5,7 +5,10 @@ Looks for ``CustomInput*`` nodes (``data.type`` or the inner
|
|||
``backend/components/kirche/form_inputs.py``) and maps their ``template``
|
||||
(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``,
|
||||
``options`` → ``enum``, ``required`` → required array
|
||||
* ``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``
|
||||
* ``_input_type`` → JSON type + widget (unknown types fall back to string
|
||||
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
|
||||
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
|
||||
|
||||
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]:
|
||||
"""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
|
||||
|
||||
|
||||
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:
|
||||
"""``{json_schema, ui_schema}``, or with ``fallback: "textarea"``."""
|
||||
templates = _form_templates(langflow_json)
|
||||
|
|
@ -122,31 +239,92 @@ def extract_form_schema(langflow_json: dict) -> dict:
|
|||
"fallback": "textarea",
|
||||
}
|
||||
template = templates[0].get("template") or {}
|
||||
properties: dict = {}
|
||||
required: list[str] = []
|
||||
ui_schema: dict = {}
|
||||
normal: list[str] = []
|
||||
advanced: list[str] = []
|
||||
# First pass: sanitize names (existing rules) and parse markers.
|
||||
records: list[dict] = []
|
||||
taken: set[str] = set()
|
||||
for key, field in template.items():
|
||||
if key == "code" or not isinstance(field, dict):
|
||||
continue
|
||||
if field.get("show") is False:
|
||||
continue
|
||||
name = field.get("name") or key
|
||||
prop, ui = _field_schema(name, field)
|
||||
properties[name] = prop
|
||||
if field.get("required"):
|
||||
required.append(name)
|
||||
raw_name = field.get("name") or key
|
||||
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
|
||||
if record["required"]:
|
||||
required.append(name)
|
||||
else:
|
||||
record["branch_prop"] = prop
|
||||
if ui:
|
||||
ui_schema[name] = ui
|
||||
(advanced if field.get("advanced") else normal).append(name)
|
||||
if advanced:
|
||||
(advanced if record["advanced"] else normal).append(name)
|
||||
json_schema: dict = {"type": "object", "properties": properties, "required": required}
|
||||
if matches:
|
||||
rules = []
|
||||
for name, conds in matches.items():
|
||||
record = by_name[name]
|
||||
rules.append(
|
||||
{
|
||||
"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 {}),
|
||||
},
|
||||
}
|
||||
)
|
||||
json_schema["allOf"] = rules
|
||||
ui_schema["ui:order"] = normal + advanced + ["*"]
|
||||
return {
|
||||
"json_schema": {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
},
|
||||
"ui_schema": ui_schema,
|
||||
}
|
||||
elif advanced:
|
||||
ui_schema["ui:order"] = normal + advanced + ["*"]
|
||||
return {"json_schema": json_schema, "ui_schema": ui_schema}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue