Skip to content

Commit 81e13f7

Browse files
Merge pull request #1 from tilman151/feat/add-on-click-callback
feat: add on click callback
2 parents 01424c5 + 8f7d3b9 commit 81e13f7

File tree

7 files changed

+404
-7
lines changed

7 files changed

+404
-7
lines changed

.github/workflows/ci.yml

+25-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ jobs:
3737
3838
- name: Run tests
3939
run: |
40-
pytest -s
40+
pytest -s -m "not e2e"
4141
4242
- name: Build wheel
4343
run: |
@@ -47,3 +47,27 @@ jobs:
4747
- name: Install built wheel
4848
run: |
4949
uv pip install dist/*.whl
50+
51+
e2e:
52+
runs-on: ubuntu-latest
53+
strategy:
54+
matrix:
55+
python-version: [ '3.10', '3.11', '3.12' ]
56+
steps:
57+
- name: Checkout repository
58+
uses: actions/checkout@v4
59+
- name: Install uv
60+
uses: astral-sh/setup-uv@v4
61+
with:
62+
python-version: ${{ matrix.python-version }}
63+
- name: Install deps
64+
run: uv sync --all-extras
65+
- id: cache-browser
66+
uses: actions/cache@v4
67+
with:
68+
path: /home/runner/.cache/ms-playwright
69+
key: ${{ runner.os }}-browser
70+
- name: Set up playwright
71+
run: uv run playwright install chromium --with-deps
72+
- name: Run e2e tests
73+
run: uv run pytest -m e2e

examples/callback_app.py

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import plotly.data
2+
import plotly.express as px
3+
from fasthtml import serve, ft
4+
from fasthtml.fastapp import fast_app
5+
from fh_plotly import plotly_headers, plotly2fasthtml
6+
from fh_plotly.p2fh import OnClick
7+
8+
app, rt = fast_app(hdrs=(plotly_headers,), live=True)
9+
10+
11+
@rt("/", methods=["GET"])
12+
def _():
13+
data = plotly.data.iris()
14+
plot = px.scatter(data, x="sepal_width", y="sepal_length", color="species")
15+
16+
return ft.Main(
17+
ft.H1("Plotly Callback Example"),
18+
plotly2fasthtml(
19+
plot,
20+
callbacks=[OnClick(hx_post="/click", hx_target="#callback-display")],
21+
),
22+
ft.H2("Click a marker to trigger a callback!"),
23+
ft.P(id="callback-display", data_testid="callback-display"),
24+
cls="container",
25+
)
26+
27+
28+
@rt("/click", methods=["POST"])
29+
def _(x: float, y: float, plot_id: str):
30+
return f"Server received click on ({x}, {y}) from plot: {plot_id}."
31+
32+
33+
serve()

fh_plotly/p2fh.py

+57-4
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,70 @@
1+
from dataclasses import dataclass
2+
from typing import Optional
13
from uuid import uuid4
24
from plotly.io import to_json
35
from fasthtml.common import Div, Script
46

5-
plotly_headers = [Script(src="https://cdn.plot.ly/plotly-latest.min.js")]
67

8+
plotly_headers = [
9+
Script(src="https://cdn.plot.ly/plotly-latest.min.js"),
10+
Script(
11+
"""
12+
const fhPlotlyRegisterOnClick = (plotId, hxPostEndpoint, hxTarget) => {
13+
const plotlyPlot = document.getElementById(plotId);
14+
plotlyPlot.on('plotly_click', (data) => {
15+
const point = data.points[0];
16+
const x = point.x;
17+
const y = point.y;
18+
const payload = new FormData();
19+
payload.append('x', x);
20+
payload.append('y', y);
21+
payload.append('plot_id', plotId);
22+
htmx.ajax("POST", hxPostEndpoint, {values: payload, target: hxTarget});
23+
});
24+
};
25+
"""
26+
),
27+
]
728

8-
def plotly2fasthtml(chart):
29+
30+
def plotly2fasthtml(chart, callbacks=None):
931
chart_id = f"uniq-{uuid4()}"
1032
chart_json = to_json(chart)
33+
if callbacks:
34+
for callback in callbacks:
35+
callback.register_plot(chart_id)
36+
1137
return Div(
12-
Script(f"""
38+
Script(
39+
f"""
1340
var plotly_data = {chart_json};
1441
Plotly.newPlot('{chart_id}', plotly_data.data, plotly_data.layout);
15-
"""),
42+
"""
43+
),
44+
*(callbacks or []),
1645
id=chart_id,
1746
)
47+
48+
49+
@dataclass
50+
class OnClick:
51+
hx_post: str
52+
hx_target: str
53+
plot_id: Optional[str] = None
54+
55+
def register_plot(self, plot_id):
56+
self.plot_id = plot_id
57+
58+
def __ft__(self):
59+
if self.plot_id is None:
60+
raise ValueError(
61+
"The 'plot_id' needs to be set on initialization or the callback must "
62+
"be passed to the 'plotly2fasthtml' function in the 'callbacks' "
63+
"argument."
64+
)
65+
66+
return Script(
67+
f"""
68+
fhPlotlyRegisterOnClick('{self.plot_id}', '{self.hx_post}', '{self.hx_target}');
69+
"""
70+
)

pyproject.toml

+11-2
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@ dev = [
1919
"pytest>=8.3.2",
2020
"pandas>=2.2.2",
2121
"numpy>=2.0.1",
22-
"fastcore>=1.7.1",
22+
"fastcore>=1.7.1"
2323
]
2424

2525
[tool.ruff]
2626
line-length = 300
2727

2828
[tool.ruff.lint]
29-
ignore = ["I001","F403", "F405", "F811"]
29+
ignore = ["I001","F403", "F405", "F811"]
3030
select = ["E", "F", "I"]
3131

3232
[tool.ruff.lint.per-file-ignores]
@@ -35,7 +35,16 @@ select = ["E", "F", "I"]
3535
[tool.pytest.ini_options]
3636
testpaths = ["tests"]
3737
pythonpath = ["src"]
38+
markers = [
39+
"e2e: end-to-end tests",
40+
]
3841

3942
[build-system]
4043
requires = ["setuptools>=61.0"]
4144
build-backend = "setuptools.build_meta"
45+
46+
[dependency-groups]
47+
dev = [
48+
"pytest-playwright>=0.6.2",
49+
"requests>=2.32.3",
50+
]

tests/conftest.py

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import multiprocessing
2+
from time import sleep
3+
4+
import pytest
5+
import uvicorn
6+
7+
8+
@pytest.fixture(scope="module")
9+
def callback_server():
10+
import requests
11+
12+
process = multiprocessing.Process(
13+
target=uvicorn.run,
14+
args=("examples.callback_app:app",),
15+
kwargs={"host": "localhost", "port": 5001},
16+
daemon=True,
17+
)
18+
process.start()
19+
for i in range(50): # 5-second timeout
20+
sleep(0.1)
21+
try:
22+
requests.get("http://localhost:5001")
23+
except requests.ConnectionError:
24+
continue
25+
else:
26+
break
27+
else:
28+
raise TimeoutError("Server did not start in time")
29+
30+
yield process
31+
32+
process.terminate()

tests/test_p2fh.py

+11
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fastcore
22
import pandas as pd
33
import plotly.express as px
4+
import pytest
45

56
from fh_plotly import plotly2fasthtml
67

@@ -43,3 +44,13 @@ def test_3d_surface():
4344
html_div = plotly2fasthtml(fig)
4445
assert html_div is not None
4546
assert isinstance(html_div, fastcore.xml.FT)
47+
48+
49+
@pytest.mark.e2e
50+
def test_callback(callback_server, page):
51+
page.goto("localhost:5001")
52+
click_layer = page.locator("svg.main-svg rect.nsewdrag.drag")
53+
click_layer.click(position={"x": 630, "y": 250})
54+
callback_text = page.get_by_test_id("callback-display").inner_text()
55+
56+
assert callback_text.startswith("Server received click on (3.6, 5.0) from plot")

0 commit comments

Comments
 (0)