feat: asked-field envelope, standalone form components, branched forms
This commit is contained in:
parent
0d43b0e8fb
commit
f27be044b4
26 changed files with 3651 additions and 104 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
299
backend/components/kirche/bauvorhaben_form.py
Normal file
299
backend/components/kirche/bauvorhaben_form.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
276
backend/components/kirche/mitarbeiter_form.py
Normal file
276
backend/components/kirche/mitarbeiter_form.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue