feat: asked-field envelope, standalone form components, branched forms

This commit is contained in:
Jonas Miederer 2026-09-28 14:07:09 +02:00
parent 0d43b0e8fb
commit f27be044b4
26 changed files with 3651 additions and 104 deletions

View file

@ -105,6 +105,68 @@ See `backend/flows/bauvorhaben.json` for a full 4-level example
(Gebäude → Bestandteil → Maßnahme → Gussjahr, plus sibling branch and
bool trigger).
## User-built form components (no kirche base needed)
A plain `CustomComponent` whose display name ends with `"Form"` is also
parsed into a web form — same template contract, no base class. To make
submitted values reach it at runtime, add an envelope input and merge it
in the output method (pattern):
```python
from lfx.io import MessageTextInput # add to imports
import json
# add to inputs:
MessageTextInput(name="form_values", display_name="Form values", value="", show=False)
# at the top of the output method:
try:
raw = self.form_values.text if hasattr(self.form_values, "text") else self.form_values
submitted = json.loads(raw or "{}")
except Exception:
submitted = {}
if isinstance(submitted.get("payload"), dict):
submitted = submitted["payload"]
def val(name, default):
return submitted.get(name, default)
```
Then use `val("rolle", self.rolle)` etc., and wire a `ChatInput` node
into `form_values` on the canvas. Branch markers (`[visible_if ...]`
in `info`) work here too — the parser reads the template only.
## Asked fields only (run envelope `fields`)
The API sends the asked field names (from the version's form schema)
in the run envelope's `fields` list. `collect()` then emits defaults
only for asked AND currently visible fields (branch conditions are
evaluated against submitted values over code defaults, so hidden
fields stay out exactly as the frontend never submits them): the
template may declare fields unknown to this code (they arrive via
envelope and merge normally), and unasked code defaults stay out of
the output. Without the list — raw dicts, Langflow playground — all
visible declared defaults apply as before.
## Variant flows (own component per flow)
A flow whose fields differ from `CustomInputForm` gets its own
standalone component (see `bauvorhaven_form.py` → "Bauvorhaben Form"
in the palette): same class pattern, own `inputs` list. Standalone
means it: no imports between form modules — the LFX runtime executes
the embedded module source without our `kirche` package, so every form
component must be self-contained (stdlib + `lfx` only). Copy the file
as the starting point for future forms.
Rule: code-declared inputs must equal template fields
(`test_embedded_code_matches_template_fields` enforces it; required
Multiselect uses the `[" "]` sentinel). Generate node + flow with
`kirche_node(..., component_cls, code_override)` and
`build_bauvorhaben()` in `backend/scripts/make_flow_fixtures.py`
rather than hand-editing JSON. Restart Langflow afterwards so the
palette picks up the new component, then build/upload the flow.
## Output type: Data (JSON), not Message
`collect()` returns `Data(data={...})` — a real JSON object. Consequences:
@ -119,7 +181,8 @@ bool trigger).
- `required=True` on a template field: `lfx validate --level 4` demands a
value or an edge, so the canonical-flow generator
(`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 (`[" "]` for Multiselect, which rejects
a plain string at runtime). The form parser treats whitespace-only
values as "no default" — don't ship real whitespace defaults.
- Field key = `name` (stable, freely renamable but must stay unique and
non-empty; collisions get a `_2`/`_3` suffix in the form schema);

View file

@ -7,6 +7,8 @@ template into a web form (``app/flows/input_schema.py``); the frontend
renders that schema with RJSF.
"""
from .bauvorhaben_form import CustomInputBauvorhaben
from .form_inputs import CustomInputForm
from .mitarbeiter_form import CustomInputMitarbeiter
__all__ = ["CustomInputForm"]
__all__ = ["CustomInputBauvorhaben", "CustomInputForm", "CustomInputMitarbeiter"]

View file

@ -0,0 +1,299 @@
"""Bauvorhaben form component for kirche-einfach flows.
Standalone by design: this module shares NO code with ``form_inputs``
(no imports from it). The LFX runtime executes the embedded module
source without our ``kirche`` package, so every form component must be
self-contained (stdlib + ``lfx`` only). Do NOT refactor the duplicated
machinery below into a shared import — it would break flow execution.
Copy this file as the starting point for future forms.
A flow that starts with :class:`CustomInputBauvorhaben` 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``:
* ``display_name`` → ``title``, ``info`` → ``description``
(``[visible_if ...]`` markers declare conditional branches, see below),
``value`` → ``default``, ``options`` → ``enum``
* ``required`` → required array (branch-required applies only while visible)
``collect()`` is fully dynamic: it emits every input except the plumbing
one (``form_values``), so fields can be renamed, added, or removed in
the Langflow code editor without touching this method.
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 AND currently visible fields. Without the list (raw dict,
Langflow playground), all visible declared defaults apply.
``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``.
"""
from __future__ import annotations
import json
import re
from typing import ClassVar
from lfx.custom import Component
from lfx.io import (
BoolInput,
DropdownInput,
IntInput,
MessageTextInput,
MultiselectInput,
Output,
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. 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).
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
class CustomInputBauvorhaben(Component):
display_name = "Bauvorhaben Form"
description = "Collects Bauvorhaben form values into a single JSON object."
icon = "Church"
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,
),
DropdownInput(
name="gebaeude",
display_name="Welches Gebäude soll verändert werden?",
info="",
options=["Kirche", "Anderes Gebäude"],
value="",
required=True,
),
MultiselectInput(
name="bestandteil",
display_name="Welcher Bestandteil der Kirche?",
info="Mehrfachauswahl möglich [visible_if gebaeude=Kirche]",
options=["Glocke", "Orgel", "Innenraum", "Fenster", "Fassade"],
value=[],
required=True,
),
DropdownInput(
name="glockenmassnahme",
display_name="Welche Maßnahme an der Glocke?",
info="[visible_if bestandteil=Glocke]",
options=["Reinigung", "Reparatur", "Neuguss"],
value="Reinigung",
),
IntInput(
name="gussjahr",
display_name="Gussjahr (nur bei Neuguss)?",
info="[visible_if glockenmassnahme=Neuguss]",
value=0,
),
DropdownInput(
name="gebaeudeart",
display_name="Art des anderen Gebäudes?",
info='[visible_if gebaeude="Anderes Gebäude"]',
options=["Wohnhaus", "Scheune", "Sonstiges"],
value="Wohnhaus",
),
BoolInput(
name="denkmalschutz",
display_name="Steht das Gebäude unter Denkmalschutz?",
info="",
value=False,
),
StrInput(
name="denkmalnummer",
display_name="Aktenzeichen Denkmalschutz?",
info="[visible_if denkmalschutz=true]",
value="",
required=True,
),
]
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)

View file

@ -17,6 +17,12 @@ 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``
@ -29,6 +35,7 @@ upload comes later.
from __future__ import annotations
import json
import re
from typing import ClassVar
from lfx.custom import Component
@ -75,8 +82,71 @@ def _json_safe(value):
return str(value)
#: Inputs that are plumbing, not form fields (never part of the output).
PLUMBING_INPUTS = frozenset({"form_values", "code"})
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):
@ -193,22 +263,63 @@ class CustomInputForm(Component):
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:
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.
# 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:
override = json.loads(_text(self.form_values) or "{}")
envelope = json.loads(_text(self.form_values) or "{}")
except (json.JSONDecodeError, ValueError, AttributeError):
override = {}
if isinstance(override, dict):
if isinstance(override.get("payload"), dict):
override = override["payload"]
for key, val in override.items():
if isinstance(key, str) and key:
data[key] = _json_safe(val)
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)

View file

@ -0,0 +1,276 @@
"""Mitarbeiter-einstellen form component for kirche-einfach flows.
Standalone by design: this module shares NO code with the other form
modules (no imports from them). The LFX runtime executes the embedded
module source without our ``kirche`` package, so every form component
must be self-contained (stdlib + ``lfx`` only). Do NOT refactor the
duplicated machinery below into a shared import — it would break flow
execution. Copy this file (or ``bauvorhaben_form.py``) as the starting
point for future forms.
A flow that starts with :class:`CustomInputMitarbeiter` 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``:
* ``display_name`` → ``title``, ``info`` → ``description``
(``[visible_if ...]`` markers declare conditional branches),
``value`` → ``default``, ``options`` → ``enum``
* ``required`` → required array (branch-required applies only while visible)
``collect()`` is fully dynamic: it emits every input except the plumbing
one (``form_values``), so fields can be renamed, added, or removed in
the Langflow code editor without touching this method.
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 AND currently visible fields. Without the list (raw dict,
Langflow playground), all visible declared defaults apply.
``collect()`` returns ``Data`` (the LFX JSON type), not ``Message``.
"""
from __future__ import annotations
import json
import re
from typing import ClassVar
from lfx.custom import Component
from lfx.io import (
DropdownInput,
MessageTextInput,
Output,
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. 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).
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
class CustomInputMitarbeiter(Component):
display_name = "Mitarbeiter Einstellen Form"
description = "Collects hiring form values into a single JSON object."
icon = "UserPlus"
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,
),
DropdownInput(
name="rolle",
display_name="Rolle",
info="",
options=["Assistenz", "Pfarrer:in", "Vikar:in", "Messner:in"],
value="Assistenz",
required=True,
),
DropdownInput(
name="einstufung",
display_name="Einstufung",
info="Nur für Pfarrer:in [visible_if rolle=Pfarrer:in]",
options=["Stufe 1", "Stufe 2", "Stufe 3"],
value="Stufe 1",
),
StrInput(
name="mitarbeiter_name",
display_name="Name",
info="Vor- und Nachname des Mitarbeiters",
value="",
required=True,
),
StrInput(
name="geburtsdatum",
display_name="Geburtsdatum",
info="Datum eingeben (z. B. JJJJ-MM-TT)",
value="",
),
]
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)