"""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)