kirche-einfach/backend/components/kirche/form_inputs.py

325 lines
12 KiB
Python

"""Structured form-input component for kirche-einfach flows.
A flow that starts with :class:`CustomInputForm` declares the fields of its
web form. The API reads the exported node template
(``template`` minus ``code``) and derives ``{json_schema, ui_schema}`` from
the ``lfx.io`` input definitions below — one JSON-Schema property per field,
keyed by ``name``. Field metadata maps as follows:
* ``display_name`` → ``title``, ``info`` → ``description``,
``value`` → ``default``, ``options`` → ``enum``
* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint
* ``_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.
When the run envelope names the asked fields (``fields`` list, sent by
the API from the version's form schema), defaults are emitted only for
asked fields — the template may declare fields unknown to this code and
unasked code defaults stay out of the output. Without the list (raw dict,
Langflow playground), all declared defaults apply.
``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
upload comes later.
"""
from __future__ import annotations
import json
import re
from typing import ClassVar
from lfx.custom import Component
from lfx.field_typing.range_spec import RangeSpec
from lfx.io import (
BoolInput,
DropdownInput,
FileInput,
FloatInput,
IntInput,
MessageTextInput,
MultilineInput,
MultiselectInput,
Output,
SecretStrInput,
SliderInput,
StrInput,
)
from lfx.schema.data import Data
def _text(value) -> str:
if value is None:
return ""
if hasattr(value, "text"):
return value.text or ""
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)
def _split_conditions(text: str) -> list:
parts: list = []
buf: list = []
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):
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 _field_conditions(info: object) -> list:
"""``[(trigger, value)]`` from a field's ``info`` markers."""
conditions: list = []
def _collect(match) -> str:
for token in _split_conditions(match.group(1)):
parsed = _parse_condition(token)
if parsed is not None:
conditions.append(parsed)
return ""
_VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "")
return conditions
#: Reserved input carrying the run envelope — a real runtime input, so it
#: must stay out of the output (otherwise the whole envelope JSON would
#: leak in as a field value on every path without an asked-fields list).
#: Never name a form field ``form_values``.
PLUMBING_INPUTS = frozenset({"form_values"})
#: ``[visible_if ...]`` marker (same syntax as the backend parser in
#: ``app/flows/input_schema.py`` — keep the two in sync; this module must
#: stay import-light for the LFX runtime and cannot import it).
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
class CustomInputForm(Component):
display_name = "CustomInput Form"
description = "Collects structured form values into a single JSON object."
icon = "ClipboardList"
inputs: ClassVar[list] = [
# Plumbing, not a form field: the API posts submitted web-form
# values as the run envelope (same {"payload": {...}} shape the
# other kirche components use); they arrive here through a ChatInput
# entry node and override the defaults below. Hidden in the GUI and
# skipped by the form parser (show=False).
MessageTextInput(
name="form_values",
display_name="Form values (JSON envelope)",
info="Submitted values as JSON; merged over the defaults",
value="",
show=False,
),
StrInput(
name="full_name",
display_name="Full name",
info="Your first and last name",
value="",
required=True,
),
MultilineInput(
name="notes",
display_name="Notes",
info="Anything else we should know",
value="",
),
SecretStrInput(
name="password",
display_name="Password",
info="Optional access phrase",
value="",
),
IntInput(
name="age",
display_name="Age",
info="Your age in years",
value=18,
),
FloatInput(
name="amount",
display_name="Amount",
info="Amount in euros",
value=0.0,
),
BoolInput(
name="newsletter",
display_name="Newsletter",
info="Send me the parish newsletter",
value=False,
),
DropdownInput(
name="department",
display_name="Department",
info="Which team is this for",
options=["Music", "Youth", "Seniors", "Admin"],
value="Music",
),
MultiselectInput(
name="topics",
display_name="Topics",
info="What are you interested in",
options=["Worship", "Outreach", "Courses"],
value=[],
),
SliderInput(
name="satisfaction",
display_name="Satisfaction",
info="How satisfied are you (1-5)",
value=3,
range_spec=RangeSpec(min=1, max=5, step=1),
),
FileInput(
name="attachment",
display_name="Attachment",
info="String reference only for now (no upload yet)",
file_types=["pdf", "png", "jpg"],
),
]
outputs: ClassVar[list] = [
Output(display_name="Form Data", name="form_data", method="collect"),
]
def _field_names(self) -> list:
"""Names of all form fields (every input except the plumbing ones)."""
inputs = getattr(self, "_inputs", None)
if isinstance(inputs, dict) and inputs:
return [name for name in inputs if name not in PLUMBING_INPUTS]
seen = []
declared = getattr(self, "inputs", None) or getattr(type(self), "inputs", None) or []
for inp in declared:
name = getattr(inp, "name", None)
if name and name not in PLUMBING_INPUTS and name not in seen:
seen.append(name)
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 _is_visible(self, name: str, effective: dict) -> bool:
"""Whether a field's branch conditions hold.
Single pass over effective values (submitted first, code defaults
after): no recursion, so trigger cycles cannot hang — a stale or
cyclic marker simply matches or not. Unknown triggers never match.
"""
inputs = getattr(self, "_inputs", None)
info = ""
if isinstance(inputs, dict) and name in inputs:
info = getattr(inputs[name], "info", "") or ""
for trigger, expected in _field_conditions(info):
if trigger == name or trigger not in effective:
# Self-reference or unknown trigger: fail open (visible),
# mirroring the backend parser.
continue
actual = effective[trigger]
if isinstance(actual, list):
if expected not in actual:
return False
elif actual != expected:
return False
return True
def collect(self) -> Data:
try:
envelope = json.loads(_text(self.form_values) or "{}")
except (json.JSONDecodeError, ValueError, AttributeError):
envelope = {}
payload: dict = {}
asked: list | None = None
if isinstance(envelope, dict):
# Asked-field list counts only in true envelope shape
# ({"payload": {...}, "fields": [...]}); a raw dict or a form
# field literally named "fields" must not confuse it.
if isinstance(envelope.get("payload"), dict):
payload = envelope["payload"]
fields = envelope.get("fields")
if isinstance(fields, list) and all(isinstance(item, str) for item in fields):
asked = [item for item in fields if item]
else:
payload = envelope
# Effective trigger values: submitted first, code defaults after.
declared = self._field_names()
effective = {name: _json_safe(self._field_value(name)) for name in declared}
for key, val in payload.items():
if isinstance(key, str) and key:
effective[key] = _json_safe(val)
# Defaults only for asked AND visible fields (hidden branch fields
# stay out, like the frontend never submits them); without an asked
# list, all visible declared fields (Langflow playground).
scope = [name for name in (asked if asked is not None else declared) if name in declared]
data = {name: effective[name] for name in scope if self._is_visible(name, effective)}
# Submitted values win over the field defaults. Unknown keys are
# kept: the frontend may already know renamed fields this code
# revision does not declare.
for key, val in payload.items():
if isinstance(key, str) and key:
data[key] = _json_safe(val)
return Data(data=data)