Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 56 additions & 6 deletions docs/how-to/declare-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,8 @@ validation or auto-generated forms.
## Handle tooltips

By default, handles are plain connection points. You can add a tooltip (shown
on hover) by passing a dict with `"id"` and `"label"` instead of a plain string:
on hover) by passing a dict with `"id"` and `"label"` and/or `"type"` instead
of a plain string:

```python
from panel_reactflow import NodeType
Expand All @@ -253,19 +254,68 @@ node_types = {
"transform": NodeType(
type="transform",
label="Transform",
inputs=[{"id": "in", "label": "Data Input"}],
inputs=[{"id": "in", "label": "Data Input", "type": "DataFrame"}],
outputs=[
{"id": "success", "label": "Successful results"},
{"id": "error", "label": "Failed records"},
{"id": "success", "label": "Successful results", "type": "DataFrame"},
{"id": "error", "label": "Failed records", "type": "list"},
],
),
}
```

Plain strings and dicts can be mixed freely in the same list:
The tooltip combines both when present (`"Data Input (DataFrame)"`), or falls
back to whichever one is given. Plain strings and dicts can be mixed freely in
the same list:

```python
inputs=["simple_port", {"id": "documented_port", "label": "Hover to see this"}]
inputs=["simple_port", {"id": "documented_port", "label": "Hover to see this", "type": "int"}]
```

---

## Show a port's current value on click

Clicking a handle emits a `"handle_clicked"` event with the node id, handle
id, direction (`"input"`/`"output"`) and a screen position. Clicking an edge
emits `"edge_clicked"` with the edge id and position. Use
`ReactFlow.show_popup(content, position)` in the handler to display whatever
you consider the "current value" for that port or connection:

```python
def on_handle_clicked(payload, flow):
node_id, handle_id = payload["node_id"], payload["handle_id"]
value = live_values.get(node_id, {}).get(handle_id)
flow.show_popup(pn.pane.Markdown(f"**{handle_id}**: {value!r}"), payload["position"])

def on_edge_clicked(payload, flow):
source_id, source_port = edge_sources[payload["edge_id"]]
value = live_values.get(source_id, {}).get(source_port)
flow.show_popup(pn.pane.Markdown(f"**Value:** {value!r}"), payload["position"])

flow.on("handle_clicked", on_handle_clicked)
flow.on("edge_clicked", on_edge_clicked)
```

For `popup_trigger="hover"`, register `handle_hovered` and `edge_hovered` to
show the popup and their corresponding `handle_unhovered` and `edge_unhovered`
events to call `flow.close_popup()`. Match the unhovered target against the
currently displayed one so a delayed leave event does not dismiss a newer
popup. See the runnable example below for both pairs of callbacks.

The default `popup_hover_delay` is 500 ms. After opening, an unhover event is
emitted when the pointer moves more than 10% of the shorter canvas dimension
from the anchor (at least 48 pixels) and is not over the popup. Click-opened
popups dismiss on outside click; either mode can dismiss programmatically via
`flow.close_popup()`. Use
`popup_trigger="none"` to disable the built-in inspection events.
`panel-reactflow` only provides the interaction events and overlay; looking up
"the current value" for a port is application-specific.

For a complete runnable graph with typed port hover tooltips and popups for
both handles and edges, run:

```bash
panel serve examples/port_value_inspection.py --show
```

---
Expand Down
54 changes: 45 additions & 9 deletions docs/releases.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,40 @@
# Release Notes

## Unreleased
## Version 0.5.0

### Bug fixes
This release focuses on driving the graph from Python: the
presentational properties of `Node` and `Edge` now sync to the browser
in place, batches of changes render as a single update, and a new error
boundary keeps a malformed graph from blanking the canvas.

- **Progressive re-render when deleting multiple elements** — deleting a
multi-node selection removed the nodes one at a time, syncing an
intermediate graph to the browser per node so the nodes visibly
disappeared one by one. Updates triggered by a frontend message are now
held and combined into a single patch, and node/edge deletion assigns
`nodes` and `edges` once.
### Highlights

- **Error recovery** — the canvas is now wrapped in an error boundary,
controlled by the new `error_recovery` parameter (`"auto"` by default,
or `"manual"` / `"off"`). In `"auto"` mode a React render error
remounts the canvas, then remounts again in a view-only safe mode that
repairs or hides elements it cannot render (invalid positions, unknown
node/edge types, dangling edges, duplicate or missing ids) without
mutating the server-side graph. If retries are exhausted a recovery
panel offers *Try again*, *Reload page* and *Copy details*. Every
error is logged to the `panel.reactflow` logger and emitted as a
`client_error` event, so browser-side failures are no longer invisible
to the server
([#70](https://github.com/panel-extensions/panel-reactflow/pull/70)).

- **Base property sync** — the top-level React Flow fields on `Node` and
`Edge` (`label`, `type`, `style`, `className`, `draggable`,
`connectable`, `deletable`, ...) are now synced to the frontend, so
assigning `node.label = "Start (running)"` patches the browser in place
instead of requiring `flow.nodes` to be replaced. Parameters declared
on a subclass continue to sync into `data`. New `patch_node_props()`
and `patch_edge_props()` methods do the same for dict-based nodes and
edges, where passing `None` clears a field back to the CSS/theme
default, and new `on_props_change` hooks fire on `Node`/`Edge`
subclasses when the frontend changes a property. `position` and
`selected` remain browser-owned during drag and selection and are only
pushed via `patch_node_props()`
([#71](https://github.com/panel-extensions/panel-reactflow/pull/71)).

### Enhancements

Expand All @@ -18,7 +43,18 @@
(`flow.remove_node("n1", "n2")`) or as a sequence
(`flow.remove_node(["n1", "n2"])`), and remove them in a single update.
For any other batch of changes made from Python, wrap them in
`pn.io.hold()` to render them at once.
`pn.io.hold()` to render them at once
([#72](https://github.com/panel-extensions/panel-reactflow/pull/72)).

### Bug fixes

- **Progressive re-render when deleting multiple elements** — deleting a
multi-node selection removed the nodes one at a time, syncing an
intermediate graph to the browser per node so the nodes visibly
disappeared one by one. Updates triggered by a frontend message are now
held and combined into a single patch, and node/edge deletion assigns
`nodes` and `edges` once
([#72](https://github.com/panel-extensions/panel-reactflow/pull/72)).

## Version 0.4.1

Expand Down
123 changes: 123 additions & 0 deletions examples/port_value_inspection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Inspect typed port values by hovering over handles and edges.

Run with:

panel serve examples/port_value_inspection.py --show
"""

import panel as pn

from panel_reactflow import EdgeSpec, NodeSpec, NodeType, ReactFlow

pn.extension("jsoneditor")

node_types = {
"source": NodeType(
type="source",
label="Data Source",
outputs=[{"id": "records", "label": "Output records", "type": "list[dict]"}],
),
"transform": NodeType(
type="transform",
label="Transform",
inputs=[{"id": "records", "label": "Input records", "type": "list[dict]"}],
outputs=[{"id": "summary", "label": "Summary", "type": "dict[str, int]"}],
),
"sink": NodeType(
type="sink",
label="Sink",
inputs=[{"id": "summary", "label": "Input summary", "type": "dict[str, int]"}],
),
}

nodes = [
NodeSpec(id="source", type="source", position={"x": 0, "y": 100}, data={}).to_dict(),
NodeSpec(id="transform", type="transform", position={"x": 300, "y": 100}, data={}).to_dict(),
NodeSpec(id="sink", type="sink", position={"x": 600, "y": 100}, data={}).to_dict(),
]

edges = [
EdgeSpec(id="records", source="source", target="transform", sourceHandle="records", targetHandle="records").to_dict(),
EdgeSpec(id="summary", source="transform", target="sink", sourceHandle="summary", targetHandle="summary").to_dict(),
]

# In an application this mapping would be updated by the code that executes
# the graph. It is intentionally separate from ReactFlow's graph metadata.
live_values = {
"source": {"records": [{"city": "Berlin", "sales": 12}, {"city": "Oslo", "sales": 8}]},
"transform": {"summary": {"record_count": 2, "total_sales": 20}},
"sink": {"summary": {"record_count": 2, "total_sales": 20}},
}

edge_sources = {
"records": ("source", "records"),
"summary": ("transform", "summary"),
}

flow = ReactFlow(
nodes=nodes,
edges=edges,
node_types=node_types,
popup_trigger="hover", # Change to "click" to inspect on click instead.
popup_hover_delay=500,
sizing_mode="stretch_both",
min_height=450,
)


active_target = None


def show_value(title, value, position, target):
global active_target
active_target = target
flow.show_popup(
pn.Column(
pn.pane.Markdown(f"**{title}**", margin=(0, 0, 6, 0)),
pn.pane.JSON(value, depth=3, sizing_mode="stretch_width"),
sizing_mode="stretch_width",
),
position,
)


def on_handle_inspected(payload, flow):
node_id = payload["node_id"]
handle_id = payload["handle_id"]
value = live_values.get(node_id, {}).get(handle_id)
show_value(f"{node_id}.{handle_id}", value, payload["position"], ("handle", node_id, handle_id, payload["direction"]))


def on_edge_inspected(payload, flow):
node_id, handle_id = edge_sources[payload["edge_id"]]
value = live_values[node_id][handle_id]
show_value(f"{node_id}.{handle_id}", value, payload["position"], ("edge", payload["edge_id"]))


def on_handle_unhovered(payload, flow):
global active_target
if active_target == ("handle", payload["node_id"], payload["handle_id"], payload["direction"]):
active_target = None
flow.close_popup()


def on_edge_unhovered(payload, flow):
global active_target
if active_target == ("edge", payload["edge_id"]):
active_target = None
flow.close_popup()


flow.on("handle_clicked", on_handle_inspected)
flow.on("edge_clicked", on_edge_inspected)
flow.on("handle_hovered", on_handle_inspected)
flow.on("edge_hovered", on_edge_inspected)
flow.on("handle_unhovered", on_handle_unhovered)
flow.on("edge_unhovered", on_edge_unhovered)

pn.Column(
"# Port value inspection",
"Hover a port to see its type and pause over a port or edge to inspect its current value.",
flow,
sizing_mode="stretch_both",
).servable()
Loading
Loading