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
|
|
@ -8,35 +8,111 @@ derives a JSON Schema + uiSchema from the node's template
|
|||
## Adding a new form field
|
||||
|
||||
1. Open the Langflow authoring UI (http://localhost:7860) and load the
|
||||
flow from `LANGFLOW_LOAD_FLOWS_PATH` (canonical files are preloaded).
|
||||
2. Edit the `CustomInputForm` node: every entry in its `inputs` list is
|
||||
one web-form field. Add an `lfx.io` input, e.g.
|
||||
```python
|
||||
from lfx.io import DropdownInput
|
||||
flow from `LANGFLOW_LOAD_FLOWS_PATH` (canonical files are preloaded).
|
||||
2. Edit the `CustomInputForm` node **in its code editor**: every entry in
|
||||
its `inputs` list is one web-form field. Add an `lfx.io` input, e.g.
|
||||
```python
|
||||
from lfx.io import DropdownInput
|
||||
|
||||
DropdownInput(
|
||||
name="room", # JSON-Schema property key
|
||||
display_name="Room", # form label (title)
|
||||
info="Where should it take place", # help text (description)
|
||||
options=["Chapel", "Hall"], # dropdown choices (enum)
|
||||
value="Chapel", # prefill (default)
|
||||
required=True, # marks the field required
|
||||
)
|
||||
```
|
||||
Supported input types → widgets: `StrInput`/`MessageTextInput` → text,
|
||||
`MultilineInput` → textarea, `SecretStrInput` → password,
|
||||
`IntInput` → integer, `FloatInput` → number, `BoolInput` → checkbox,
|
||||
`DropdownInput` → select, `MultiselectInput` → multi-select,
|
||||
`SliderInput` → slider (needs `range_spec`), `FileInput` → text stub
|
||||
(real upload later).
|
||||
3. Add the new field to `collect()` so it reaches the JSON output.
|
||||
DropdownInput(
|
||||
name="room", # JSON-Schema property key (freely renamable)
|
||||
display_name="Room", # form label (title)
|
||||
info="Where should it take place", # help text (description)
|
||||
options=["Chapel", "Hall"], # dropdown choices (enum)
|
||||
value="Chapel", # prefill (default)
|
||||
required=True, # marks the field required
|
||||
)
|
||||
```
|
||||
Supported input types → widgets: `StrInput`/`MessageTextInput` → text,
|
||||
`MultilineInput` → textarea, `SecretStrInput` → password,
|
||||
`IntInput` → integer, `FloatInput` → number, `BoolInput` → checkbox,
|
||||
`DropdownInput` → select, `MultiselectInput` → multi-select,
|
||||
`SliderInput` → slider (needs `range_spec`), `FileInput` → text stub
|
||||
(real upload later).
|
||||
3. Nothing else to change in code: `collect()` is dynamic — it emits
|
||||
every input except the plumbing ones, so renames, additions, and
|
||||
removals just work. (The inspector panel can tweak `display_name`,
|
||||
`info`, `value`, and `options` of existing fields, but `name` and new
|
||||
fields need the code editor.)
|
||||
4. **Wire the envelope**: keep the `ChatInput → form_values` edge (hidden
|
||||
input, `show=False`). Submitted web-form values arrive there as
|
||||
`{"payload": {...}}` and are merged over the defaults. Never make form
|
||||
fields required *with* an incoming edge — that's the envelope's job.
|
||||
input, `show=False`). Submitted web-form values arrive there as
|
||||
`{"payload": {...}}` and are merged over the defaults — including keys
|
||||
the node code does not declare (forward-compatible with renames).
|
||||
Never make form fields required *with* an incoming edge — that's the
|
||||
envelope's job.
|
||||
5. Export the flow (JSON) into `backend/flows/<name>.json` and commit.
|
||||
The API watcher syncs + deploys it automatically; the form schema is
|
||||
regenerated per version.
|
||||
The API watcher syncs + deploys it automatically; the form schema is
|
||||
regenerated per version.
|
||||
|
||||
## Branched questionnaires (`visible_if`)
|
||||
|
||||
A field can appear only when other fields have certain values — nested
|
||||
to any depth. Declare it in the code editor with a marker in `info`:
|
||||
|
||||
```python
|
||||
(
|
||||
DropdownInput(
|
||||
name="gebaeude",
|
||||
display_name="Welches Gebäude soll verändert werden?",
|
||||
options=["Kirche", "Anderes Gebäude"],
|
||||
value="Kirche",
|
||||
required=True,
|
||||
),
|
||||
)
|
||||
(
|
||||
MultiselectInput(
|
||||
name="bestandteil",
|
||||
display_name="Welcher Bestandteil der Kirche?",
|
||||
options=["Glocke", "Orgel", "Innenraum", "Fenster", "Fassade"],
|
||||
value=[],
|
||||
info="Mehrfachauswahl möglich [visible_if gebaeude=Kirche]",
|
||||
required=True,
|
||||
),
|
||||
)
|
||||
(
|
||||
DropdownInput(
|
||||
name="glockenmassnahme",
|
||||
display_name="Welche Maßnahme an der Glocke?",
|
||||
options=["Reinigung", "Reparatur", "Neuguss"],
|
||||
value="Reinigung",
|
||||
info="[visible_if bestandteil=Glocke]",
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
Syntax: `[visible_if <feld>=<wert>]`, `&` for AND
|
||||
(`[visible_if a=X & b=Y]`), double quotes for values with spaces
|
||||
(`[visible_if gebaeude="Anderes Gebäude"]`). Trigger matching by input
|
||||
type: Dropdown/Text `const`, Bool `const` (`true`/`false`), Multiselect
|
||||
`contains`, Int/Float exact number. Nesting is automatic: a field whose
|
||||
trigger is itself conditional renders one level deeper, without limit.
|
||||
|
||||
What the parser does with markers:
|
||||
|
||||
- The marker is stripped from the shown help text; the field leaves the
|
||||
top-level schema and lives only in its `allOf`/`if`/`then` branch, so
|
||||
RJSF hides it until the condition holds. `ui:order` keeps template
|
||||
order, so new questions appear directly below their trigger.
|
||||
- `required` on a branched field applies only while visible.
|
||||
- Values are always kept: hidden fields still validate and are submitted
|
||||
(stale values can keep deeper branches satisfied until changed).
|
||||
- Safety is fail-open: unknown triggers (renamed?), unsupported trigger
|
||||
types, self-references, and trigger cycles keep the field always
|
||||
visible instead of hiding data. Renaming a trigger means updating its
|
||||
markers.
|
||||
|
||||
See `backend/flows/bauvorhaben.json` for a full 4-level example
|
||||
(Gebäude → Bestandteil → Maßnahme → Gussjahr, plus sibling branch and
|
||||
bool trigger).
|
||||
|
||||
## Output type: Data (JSON), not Message
|
||||
|
||||
`collect()` returns `Data(data={...})` — a real JSON object. Consequences:
|
||||
|
||||
- A terminal `ChatOutput` renders it as a fenced ```json block; the run
|
||||
result text is that block, not raw JSON (unwrap before `json.loads`).
|
||||
- Components consuming `form_data` should read `.data` (or `get_text()`),
|
||||
not `.text` like with `Message`.
|
||||
|
||||
## Conventions the pipeline relies on
|
||||
|
||||
|
|
@ -45,7 +121,10 @@ derives a JSON Schema + uiSchema from the node's template
|
|||
(`backend/scripts/make_flow_fixtures.py`) writes a single-space
|
||||
sentinel value for such fields. The form parser treats whitespace-only
|
||||
values as "no default" — don't ship real whitespace defaults.
|
||||
- Field key = `name` (stable); label = `display_name`; help = `info`.
|
||||
- Field key = `name` (stable, freely renamable but must stay unique and
|
||||
non-empty; collisions get a `_2`/`_3` suffix in the form schema);
|
||||
label = `display_name`; help = `info`. Renames propagate to downstream
|
||||
components, which read the JSON keys as-is.
|
||||
- Unknown future input types degrade to a plain text field in the web
|
||||
form (parser fallback), so adding exotic lfx inputs never breaks runs.
|
||||
|
||||
|
|
@ -56,3 +135,9 @@ canonical + test-fixture exports:
|
|||
|
||||
docker compose exec api /opt/venv/bin/python scripts/make_flow_fixtures.py
|
||||
docker compose exec -T api uv run pytest tests/test_lfx_validate.py
|
||||
|
||||
Notes: the generator writes UTF-8/LF explicitly (Windows-safe), and
|
||||
`backend/flows/*.json` may carry Langflow round-trip state (key order)
|
||||
the generator does not reproduce — if a regen rewrites canonical files
|
||||
beyond the component's own node, revert them and splice only the changed
|
||||
node fields (code, description, output types, edge handles) instead.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,17 @@ keyed by ``name``. Field metadata maps as follows:
|
|||
* ``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.
|
||||
|
||||
``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.
|
||||
"""
|
||||
|
|
@ -36,7 +47,7 @@ from lfx.io import (
|
|||
SliderInput,
|
||||
StrInput,
|
||||
)
|
||||
from lfx.schema.message import Message
|
||||
from lfx.schema.data import Data
|
||||
|
||||
|
||||
def _text(value) -> str:
|
||||
|
|
@ -47,9 +58,30 @@ def _text(value) -> str:
|
|||
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)
|
||||
|
||||
|
||||
#: Inputs that are plumbing, not form fields (never part of the output).
|
||||
PLUMBING_INPUTS = frozenset({"form_values", "code"})
|
||||
|
||||
|
||||
class CustomInputForm(Component):
|
||||
display_name = "CustomInput Form"
|
||||
description = "Collects structured form values into a single JSON message."
|
||||
description = "Collects structured form values into a single JSON object."
|
||||
icon = "ClipboardList"
|
||||
|
||||
inputs: ClassVar[list] = [
|
||||
|
|
@ -134,29 +166,49 @@ class CustomInputForm(Component):
|
|||
Output(display_name="Form Data", name="form_data", method="collect"),
|
||||
]
|
||||
|
||||
def collect(self) -> Message:
|
||||
data = {
|
||||
"full_name": _text(self.full_name),
|
||||
"notes": _text(self.notes),
|
||||
"password": _text(self.password),
|
||||
"age": self.age,
|
||||
"amount": self.amount,
|
||||
"newsletter": self.newsletter,
|
||||
"department": _text(self.department),
|
||||
"topics": self.topics,
|
||||
"satisfaction": self.satisfaction,
|
||||
"attachment": _text(self.attachment),
|
||||
}
|
||||
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 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 "{}")
|
||||
except json.JSONDecodeError:
|
||||
except (json.JSONDecodeError, ValueError, AttributeError):
|
||||
override = {}
|
||||
if isinstance(override, dict):
|
||||
if isinstance(override.get("payload"), dict):
|
||||
override = override["payload"]
|
||||
for key in data:
|
||||
if key in override:
|
||||
data[key] = override[key]
|
||||
return Message(text=json.dumps(data, ensure_ascii=False))
|
||||
for key, val in override.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