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
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ This project adheres to [Semantic Versioning](https://semver.org/).
- [#3669](https://github.com/plotly/dash/pull/3669) Selection for DataTable cleared with custom action settings
- [#3680](https://github.com/plotly/dash/pull/3680) Added `search_order` prop to `Dropdown` to allow users to preserve original option order during search
- Added `csrf_token_name` and `csrf_header_name` config options to allow configuring the CSRF cookie and header names. Fixes [#729](https://github.com/plotly/dash/issues/729)

## Added
- [#3523](https://github.com/plotly/dash/pull/3523) Fall back to background callback function names if source cannot be found
- [#3785](https://github.com/plotly/dash/pull/3785) Fix patch with dcc.Graph figure.
- [#3785](https://github.com/plotly/dash/pull/3785) Fix dcc.Graph not sending duplicate clicks because it had the same payload by adding a timestamp in the click event object.

## Fixed
- [#3690](https://github.com/plotly/dash/pull/3690) Fixes Input when min or max is set to None
Expand Down
38 changes: 34 additions & 4 deletions components/dash-core-components/src/fragments/Graph.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
mergeDeepRight,
omit,
type,
clone,
} from 'ramda';
import PropTypes from 'prop-types';
import {graphPropTypes, graphDefaultProps} from '../components/Graph.react';
Expand Down Expand Up @@ -313,10 +314,12 @@ class PlotlyGraph extends Component {
return mergeDeepRight(config, this.getConfigOverride(responsive));
}

getLayout(layout, responsive) {
if (!layout) {
return layout;
getLayout(originalLayout, responsive) {
if (!originalLayout) {
return originalLayout;
}
// Clone layout to avoid mutating the original (important for Patch)
const layout = clone(originalLayout);
const override = this.getLayoutOverride(responsive);
const {override: prev_override, originals: prev_originals} = this.state;
// Store the original data that we're about to override
Expand All @@ -339,7 +342,7 @@ class PlotlyGraph extends Component {
for (const key in override) {
layout[key] = override[key];
}
return layout; // not really a clone
return layout;
}

getConfigOverride(responsive) {
Expand Down Expand Up @@ -414,6 +417,8 @@ class PlotlyGraph extends Component {
gd.on('plotly_click', eventData => {
const clickData = filterEventData(gd, eventData, 'click');
if (!isNil(clickData)) {
// Add timestamp to ensure each click is unique (for DashWrapper deduplication)
clickData.timestamp = Date.now();
setProps({clickData});
}
});
Expand All @@ -422,6 +427,8 @@ class PlotlyGraph extends Component {
['event', 'fullAnnotation'],
eventData
);
// Add timestamp to ensure each click is unique (for DashWrapper deduplication)
clickAnnotationData.timestamp = Date.now();
setProps({clickAnnotationData});
});
gd.on('plotly_hover', eventData => {
Expand All @@ -444,6 +451,29 @@ class PlotlyGraph extends Component {
if (!isNil(relayout) && !equals(relayout, relayoutData)) {
setProps({relayoutData: relayout});
}
// Sync shapes from gd.layout to figure when shapes are modified by user
// This is needed because getLayout() clones layout to prevent mutation issues
Comment on lines +454 to +455
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need to do the same thing for annotations, selections, etc.?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so, it's really meant for toggle clicks in something like a heatmap, you could toggle one cell and toggle it back again later.

if (eventData && gd.layout) {
const hasShapeChanges = Object.keys(eventData).some(
key => key === 'shapes' || key.startsWith('shapes[')
);
if (hasShapeChanges) {
const {figure = {}} = this.props;
const currentShapes = figure?.layout?.shapes;
const newShapes = gd.layout.shapes;
if (!equals(currentShapes, newShapes)) {
setProps({
figure: {
...figure,
layout: {
...figure?.layout,
shapes: newShapes,
},
},
});
}
}
}
});
gd.on('plotly_restyle', eventData => {
const restyle = filterEventData(gd, eventData, 'restyle');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -412,3 +412,138 @@ def toggle_figure(n_clicks):
)

assert dash_dcc.get_logs() == []


def test_grbs009_graph_click_same_point_twice(dash_dcc):
"""Clicking the same point twice should trigger callback both times."""
app = Dash(__name__)

app.layout = html.Div(
[
dcc.Graph(
id="graph",
figure=go.Figure(
data=[go.Scatter(x=[1, 2, 3], y=[1, 2, 3], mode="markers")],
),
style={"width": 600, "height": 400},
),
html.Div(id="output", children="[]"),
]
)

@app.callback(
Output("output", "children"),
Input("graph", "clickData"),
prevent_initial_call=True,
)
def on_click(click_data):
# Return the timestamp to verify each click has a unique one
return json.dumps(click_data.get("timestamp", "missing"))

dash_dcc.start_server(app)
dash_dcc.wait_for_element("#graph .main-svg")

# Click on the graph area - uses the drag overlay which receives clicks
graph = dash_dcc.find_element("#graph .nsewdrag")
graph.click()

# Wait for callback to fire and verify timestamp exists
dash_dcc.wait_for_contains_text("#output", "")
first_output = dash_dcc.find_element("#output").text
assert first_output != "[]", "First click should trigger callback"
assert first_output != '"missing"', "clickData should contain timestamp"

# Click again - should trigger callback with different timestamp
graph.click()
sleep(0.5)
second_output = dash_dcc.find_element("#output").text
assert (
second_output != first_output
), "Second click should trigger callback with new timestamp"

assert dash_dcc.get_logs() == []


def test_grbs010_graph_patch_deeply_nested_figure(dash_dcc):
"""Patching deeply nested figure properties should work correctly."""
from dash import Patch

app = Dash(__name__)

app.layout = html.Div(
[
dcc.Graph(
id="graph",
figure={
"data": [
{
"x": [1, 2, 3],
"y": [1, 2, 3],
"marker": {"color": "red", "size": 10},
"type": "scatter",
"mode": "markers",
}
],
"layout": {"title": {"text": "Initial Title"}},
},
style={"width": 600, "height": 400},
),
html.Button("Patch Color", id="patch-color-btn"),
html.Button("Patch Title", id="patch-title-btn"),
html.Div(id="output"),
]
)

@app.callback(
Output("graph", "figure"),
Input("patch-color-btn", "n_clicks"),
prevent_initial_call=True,
)
def patch_color(n):
p = Patch()
p.data[0].marker.color = "blue" if n % 2 else "green"
return p

@app.callback(
Output("graph", "figure", allow_duplicate=True),
Input("patch-title-btn", "n_clicks"),
prevent_initial_call=True,
)
def patch_title(n):
p = Patch()
p.layout.title.text = f"Updated Title {n}"
return p

@app.callback(
Output("output", "children"),
Input("graph", "figure"),
)
def show_figure_state(figure):
color = figure.get("data", [{}])[0].get("marker", {}).get("color", "unknown")
title = figure.get("layout", {}).get("title", {}).get("text", "unknown")
return f"color={color}, title={title}"

dash_dcc.start_server(app)
dash_dcc.wait_for_element("#graph .main-svg")

# Initial state
dash_dcc.wait_for_text_to_equal("#output", "color=red, title=Initial Title")

# Patch the color
dash_dcc.find_element("#patch-color-btn").click()
dash_dcc.wait_for_text_to_equal("#output", "color=blue, title=Initial Title")

# Patch the title
dash_dcc.find_element("#patch-title-btn").click()
dash_dcc.wait_for_text_to_equal("#output", "color=blue, title=Updated Title 1")

# Patch color again - should toggle
dash_dcc.find_element("#patch-color-btn").click()
dash_dcc.wait_for_text_to_equal("#output", "color=green, title=Updated Title 1")

# Multiple rapid patches
dash_dcc.find_element("#patch-title-btn").click()
dash_dcc.find_element("#patch-title-btn").click()
dash_dcc.wait_for_text_to_equal("#output", "color=green, title=Updated Title 3")

assert dash_dcc.get_logs() == []
3 changes: 3 additions & 0 deletions tests/integration/callbacks/test_arbitrary_callbacks.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import time
from multiprocessing import Value

from flaky import flaky

from dash import (
Dash,
Input,
Expand Down Expand Up @@ -234,6 +236,7 @@ def test_arb007_clientside_no_output(dash_duo):
dash_duo.wait_for_text_to_equal("#output", "start2")


@flaky(max_runs=3)
def test_arb008_set_props_chain_cb(dash_duo):
app = Dash(suppress_callback_exceptions=True)

Expand Down
Loading