This repository was archived by the owner on Aug 29, 2025. It is now read-only.
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
7 changes: 4 additions & 3 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,12 +4,13 @@ This project adheres to [Semantic Versioning](http://semver.org/).

## [UNRELEASED]
### Fixed
- [#903](https://github.com/plotly/dash-core-components/pull/903) - part of fixing dash import bug https://github.com/plotly/dash/issues/1143
- [#905](https://github.com/plotly/dash-core-components/pull/905) Make sure the `figure` prop of `dcc.Graph` receives updates from user interactions in the graph, by using the same `layout` object as provided in the prop rather than cloning it. Fixes [#879](https://github.com/plotly/dash-core-components/issues/879).
- [#903](https://github.com/plotly/dash-core-components/pull/903) Part of fixing dash import bug https://github.com/plotly/dash/issues/1143

### Updated
- [#911](https://github.com/plotly/dash-core-components/pull/911)
- [#911](https://github.com/plotly/dash-core-components/pull/911), [#906](https://github.com/plotly/dash-core-components/pull/906)
- Upgraded Plotly.js to [1.58.4](https://github.com/plotly/plotly.js/releases/tag/v1.58.4)
- Patch Release [1.58.4](https://github.com/plotly/plotly.js/releases/tag/v1.58.4)
- [#906](https://github.com/plotly/dash-core-components/pull/906)
- Patch Release [1.58.3](https://github.com/plotly/plotly.js/releases/tag/v1.58.3)

## [1.14.1] - 2020-12-09
Expand Down
27 changes: 25 additions & 2 deletions src/fragments/Graph.react.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,6 +131,8 @@ class PlotlyGraph extends Component {
this.getLayoutOverride = this.getLayoutOverride.bind(this);
this.graphResize = this.graphResize.bind(this);
this.isResponsive = this.isResponsive.bind(this);

this.state = {override: {}, originals: {}};
}

plot(props) {
Expand DownExpand Up@@ -226,8 +228,29 @@ class PlotlyGraph extends Component {
if (!layout) {
return layout;
}

return mergeDeepRight(layout, this.getLayoutOverride(responsive));
const override = this.getLayoutOverride(responsive);
const {override: prev_override, originals: prev_originals} = this.state;
// Store the original data that we're about to override
const originals = {};
for (const key in override) {
if (layout[key] !== prev_override[key]) {
originals[key] = layout[key];
} else if (prev_originals.hasOwnProperty(key)) {
originals[key] = prev_originals[key];
}
}
this.setState({override, originals});
// Undo the previous override, but only for keys that the user did not change
for (const key in prev_originals) {
if (layout[key] === prev_override[key]) {
layout[key] = prev_originals[key];
}
}
// Apply the current override
for (const key in override) {
layout[key] = override[key];
}
return layout; // not really a clone
}

getConfigOverride(responsive) {
Expand Down
9 changes: 9 additions & 0 deletions tests/dash_core_components_page.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,3 +118,12 @@ def move_to_coord_fractions(self, elem_or_selector, fx, fy):

def release(self):
ActionChains(self.driver).release().perform()

def click_and_drag_at_coord_fractions(self, elem_or_selector, fx1, fy1, fx2, fy2):
elem = self._get_element(elem_or_selector)

ActionChains(self.driver).move_to_element_with_offset(
elem, elem.size["width"] * fx1, elem.size["height"] * fy1
).click_and_hold().move_to_element_with_offset(
elem, elem.size["width"] * fx2, elem.size["height"] * fy2
).release().perform()
179 changes: 172 additions & 7 deletions tests/integration/graph/test_graph_varia.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,7 +25,7 @@ def findAsyncPlotlyJs(scripts):


@pytest.mark.parametrize("is_eager", [True, False])
def test_candlestick(dash_dcc, is_eager):
def test_grva001_candlestick(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)
app.layout = html.Div(
[
Expand DownExpand Up@@ -75,7 +75,7 @@ def update_graph(n_clicks):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graphs_with_different_figures(dash_dcc, is_eager):
def test_grva002_graphs_with_different_figures(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)
app.layout = html.Div(
[
Expand DownExpand Up@@ -160,7 +160,7 @@ def show_relayout_data(data):


@pytest.mark.parametrize("is_eager", [True, False])
def test_empty_graph(dash_dcc, is_eager):
def test_grva003_empty_graph(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

app.layout = html.Div(
Expand DownExpand Up@@ -193,7 +193,7 @@ def render_content(click, prev_graph):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graph_prepend_trace(dash_dcc, is_eager):
def test_grva004_graph_prepend_trace(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

def generate_with_id(id, data=None):
Expand DownExpand Up@@ -358,7 +358,7 @@ def display_data(trigger, fig):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graph_extend_trace(dash_dcc, is_eager):
def test_grva005_graph_extend_trace(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

def generate_with_id(id, data=None):
Expand DownExpand Up@@ -521,7 +521,7 @@ def display_data(trigger, fig):


@pytest.mark.parametrize("is_eager", [True, False])
def test_unmounted_graph_resize(dash_dcc, is_eager):
def test_grva006_unmounted_graph_resize(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

app.layout = html.Div(
Expand DownExpand Up@@ -619,7 +619,7 @@ def test_unmounted_graph_resize(dash_dcc, is_eager):
dash_dcc.driver.set_window_size(window_size["width"], window_size["height"])


def test_external_plotlyjs_prevents_lazy(dash_dcc):
def test_grva007_external_plotlyjs_prevents_lazy(dash_dcc):
app = dash.Dash(
__name__,
eager_loading=False,
Expand DownExpand Up@@ -658,3 +658,168 @@ def load_chart(n_clicks):
scripts = dash_dcc.driver.find_elements(By.CSS_SELECTOR, "script")
assert findSyncPlotlyJs(scripts) is None
assert findAsyncPlotlyJs(scripts) is None


def test_grva008_shapes_not_lost(dash_dcc):
# See issue #879 and pr #905
app = dash.Dash(__name__)

fig = {"data": [], "layout": {"dragmode": "drawrect"}}
graph = dcc.Graph(id="graph", figure=fig, style={"height": "400px"})

app.layout = html.Div(
[
graph,
html.Br(),
html.Button(id="button", children="Clone figure"),
html.Div(id="output", children=""),
]
)

app.clientside_callback(
"""
function clone_figure(_, figure) {
const new_figure = {...figure};
const shapes = new_figure.layout.shapes || [];
return [new_figure, shapes.length];
}
""",
Output("graph", "figure"),
Output("output", "children"),
Input("button", "n_clicks"),
State("graph", "figure"),
)

dash_dcc.start_server(app)
button = dash_dcc.wait_for_element("#button")
dash_dcc.wait_for_text_to_equal("#output", "0")

# Draw a shape
dash_dcc.click_and_hold_at_coord_fractions("#graph", 0.25, 0.25)
dash_dcc.move_to_coord_fractions("#graph", 0.35, 0.75)
dash_dcc.release()

# Click to trigger an update of the output, the shape should survive
dash_dcc.wait_for_text_to_equal("#output", "0")
button.click()
dash_dcc.wait_for_text_to_equal("#output", "1")

# Draw another shape
dash_dcc.click_and_hold_at_coord_fractions("#graph", 0.75, 0.25)
dash_dcc.move_to_coord_fractions("#graph", 0.85, 0.75)
dash_dcc.release()

# Click to trigger an update of the output, the shape should survive
dash_dcc.wait_for_text_to_equal("#output", "1")
button.click()
dash_dcc.wait_for_text_to_equal("#output", "2")


@pytest.mark.parametrize("mutate_fig", [True, False])
def test_grva009_originals_maintained_for_responsive_override(mutate_fig, dash_dcc):
# In #905 we made changes to prevent shapes from being lost.
# This test makes sure that the overrides applied by the `responsive`
# prop are "undone" when the `responsive` prop changes.

app = dash.Dash(__name__)

graph = dcc.Graph(
id="graph",
figure={"data": [{"y": [1, 2]}], "layout": {"width": 300, "height": 250}},
style={"height": "400px", "width": "500px"},
)
responsive_size = [500, 400]
fixed_size = [300, 250]

app.layout = html.Div(
[
graph,
html.Br(),
html.Button(id="edit_figure", children="Edit figure"),
html.Button(id="edit_responsive", children="Edit responsive"),
html.Div(id="output", children=""),
]
)

if mutate_fig:
# Modify the layout in place (which still has changes made by responsive)
change_fig = """
figure.layout.title = {text: String(n_fig || 0)};
const new_figure = {...figure};
"""
else:
# Or create a new one each time
change_fig = """
const new_figure = {
data: [{y: [1, 2]}],
layout: {width: 300, height: 250, title: {text: String(n_fig || 0)}}
};
"""

callback = (
"""
function clone_figure(n_fig, n_resp, figure) {
"""
+ change_fig
+ """
let responsive = [true, false, 'auto'][(n_resp || 0) % 3];
return [new_figure, responsive, (n_fig || 0) + ' ' + responsive];
}
"""
)

app.clientside_callback(
callback,
Output("graph", "figure"),
Output("graph", "responsive"),
Output("output", "children"),
Input("edit_figure", "n_clicks"),
Input("edit_responsive", "n_clicks"),
State("graph", "figure"),
)

dash_dcc.start_server(app)
edit_figure = dash_dcc.wait_for_element("#edit_figure")
edit_responsive = dash_dcc.wait_for_element("#edit_responsive")

def graph_dims():
return dash_dcc.driver.execute_script(
"""
const layout = document.querySelector('.js-plotly-plot')._fullLayout;
return [layout.width, layout.height];
"""
)

dash_dcc.wait_for_text_to_equal("#output", "0 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "0")
assert graph_dims() == responsive_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "1 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "1")
assert graph_dims() == responsive_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "1 false")
dash_dcc.wait_for_text_to_equal(".gtitle", "1")
assert graph_dims() == fixed_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "2 false")
dash_dcc.wait_for_text_to_equal(".gtitle", "2")
assert graph_dims() == fixed_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "2 auto")
dash_dcc.wait_for_text_to_equal(".gtitle", "2")
assert graph_dims() == fixed_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "3 auto")
dash_dcc.wait_for_text_to_equal(".gtitle", "3")
assert graph_dims() == fixed_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "3 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "3")
assert graph_dims() == responsive_size
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
This repository was archived by the owner on Aug 29, 2025. It is now read-only.
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
7 changes: 4 additions & 3 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,12 +4,13 @@ This project adheres to [Semantic Versioning](http://semver.org/).

## [UNRELEASED]
### Fixed
- [#903](https://github.com/plotly/dash-core-components/pull/903) - part of fixing dash import bug https://github.com/plotly/dash/issues/1143
- [#905](https://github.com/plotly/dash-core-components/pull/905) Make sure the `figure` prop of `dcc.Graph` receives updates from user interactions in the graph, by using the same `layout` object as provided in the prop rather than cloning it. Fixes [#879](https://github.com/plotly/dash-core-components/issues/879).
- [#903](https://github.com/plotly/dash-core-components/pull/903) Part of fixing dash import bug https://github.com/plotly/dash/issues/1143

### Updated
- [#911](https://github.com/plotly/dash-core-components/pull/911)
- [#911](https://github.com/plotly/dash-core-components/pull/911), [#906](https://github.com/plotly/dash-core-components/pull/906)
- Upgraded Plotly.js to [1.58.4](https://github.com/plotly/plotly.js/releases/tag/v1.58.4)
- Patch Release [1.58.4](https://github.com/plotly/plotly.js/releases/tag/v1.58.4)
- [#906](https://github.com/plotly/dash-core-components/pull/906)
- Patch Release [1.58.3](https://github.com/plotly/plotly.js/releases/tag/v1.58.3)

## [1.14.1] - 2020-12-09
Expand Down
27 changes: 25 additions & 2 deletions src/fragments/Graph.react.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,6 +131,8 @@ class PlotlyGraph extends Component {
this.getLayoutOverride = this.getLayoutOverride.bind(this);
this.graphResize = this.graphResize.bind(this);
this.isResponsive = this.isResponsive.bind(this);

this.state = {override: {}, originals: {}};
}

plot(props) {
Expand DownExpand Up@@ -226,8 +228,29 @@ class PlotlyGraph extends Component {
if (!layout) {
return layout;
}

return mergeDeepRight(layout, this.getLayoutOverride(responsive));
const override = this.getLayoutOverride(responsive);
const {override: prev_override, originals: prev_originals} = this.state;
// Store the original data that we're about to override
const originals = {};
for (const key in override) {
if (layout[key] !== prev_override[key]) {
originals[key] = layout[key];
} else if (prev_originals.hasOwnProperty(key)) {
originals[key] = prev_originals[key];
}
}
this.setState({override, originals});
// Undo the previous override, but only for keys that the user did not change
for (const key in prev_originals) {
if (layout[key] === prev_override[key]) {
layout[key] = prev_originals[key];
}
}
// Apply the current override
for (const key in override) {
layout[key] = override[key];
}
return layout; // not really a clone
}

getConfigOverride(responsive) {
Expand Down
9 changes: 9 additions & 0 deletions tests/dash_core_components_page.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,3 +118,12 @@ def move_to_coord_fractions(self, elem_or_selector, fx, fy):

def release(self):
ActionChains(self.driver).release().perform()

def click_and_drag_at_coord_fractions(self, elem_or_selector, fx1, fy1, fx2, fy2):
elem = self._get_element(elem_or_selector)

ActionChains(self.driver).move_to_element_with_offset(
elem, elem.size["width"] * fx1, elem.size["height"] * fy1
).click_and_hold().move_to_element_with_offset(
elem, elem.size["width"] * fx2, elem.size["height"] * fy2
).release().perform()
179 changes: 172 additions & 7 deletions tests/integration/graph/test_graph_varia.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,7 +25,7 @@ def findAsyncPlotlyJs(scripts):


@pytest.mark.parametrize("is_eager", [True, False])
def test_candlestick(dash_dcc, is_eager):
def test_grva001_candlestick(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)
app.layout = html.Div(
[
Expand DownExpand Up@@ -75,7 +75,7 @@ def update_graph(n_clicks):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graphs_with_different_figures(dash_dcc, is_eager):
def test_grva002_graphs_with_different_figures(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)
app.layout = html.Div(
[
Expand DownExpand Up@@ -160,7 +160,7 @@ def show_relayout_data(data):


@pytest.mark.parametrize("is_eager", [True, False])
def test_empty_graph(dash_dcc, is_eager):
def test_grva003_empty_graph(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

app.layout = html.Div(
Expand DownExpand Up@@ -193,7 +193,7 @@ def render_content(click, prev_graph):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graph_prepend_trace(dash_dcc, is_eager):
def test_grva004_graph_prepend_trace(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

def generate_with_id(id, data=None):
Expand DownExpand Up@@ -358,7 +358,7 @@ def display_data(trigger, fig):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graph_extend_trace(dash_dcc, is_eager):
def test_grva005_graph_extend_trace(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

def generate_with_id(id, data=None):
Expand DownExpand Up@@ -521,7 +521,7 @@ def display_data(trigger, fig):


@pytest.mark.parametrize("is_eager", [True, False])
def test_unmounted_graph_resize(dash_dcc, is_eager):
def test_grva006_unmounted_graph_resize(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

app.layout = html.Div(
Expand DownExpand Up@@ -619,7 +619,7 @@ def test_unmounted_graph_resize(dash_dcc, is_eager):
dash_dcc.driver.set_window_size(window_size["width"], window_size["height"])


def test_external_plotlyjs_prevents_lazy(dash_dcc):
def test_grva007_external_plotlyjs_prevents_lazy(dash_dcc):
app = dash.Dash(
__name__,
eager_loading=False,
Expand DownExpand Up@@ -658,3 +658,168 @@ def load_chart(n_clicks):
scripts = dash_dcc.driver.find_elements(By.CSS_SELECTOR, "script")
assert findSyncPlotlyJs(scripts) is None
assert findAsyncPlotlyJs(scripts) is None


def test_grva008_shapes_not_lost(dash_dcc):
# See issue #879 and pr #905
app = dash.Dash(__name__)

fig = {"data": [], "layout": {"dragmode": "drawrect"}}
graph = dcc.Graph(id="graph", figure=fig, style={"height": "400px"})

app.layout = html.Div(
[
graph,
html.Br(),
html.Button(id="button", children="Clone figure"),
html.Div(id="output", children=""),
]
)

app.clientside_callback(
"""
function clone_figure(_, figure) {
const new_figure = {...figure};
const shapes = new_figure.layout.shapes || [];
return [new_figure, shapes.length];
}
""",
Output("graph", "figure"),
Output("output", "children"),
Input("button", "n_clicks"),
State("graph", "figure"),
)

dash_dcc.start_server(app)
button = dash_dcc.wait_for_element("#button")
dash_dcc.wait_for_text_to_equal("#output", "0")

# Draw a shape
dash_dcc.click_and_hold_at_coord_fractions("#graph", 0.25, 0.25)
dash_dcc.move_to_coord_fractions("#graph", 0.35, 0.75)
dash_dcc.release()

# Click to trigger an update of the output, the shape should survive
dash_dcc.wait_for_text_to_equal("#output", "0")
button.click()
dash_dcc.wait_for_text_to_equal("#output", "1")

# Draw another shape
dash_dcc.click_and_hold_at_coord_fractions("#graph", 0.75, 0.25)
dash_dcc.move_to_coord_fractions("#graph", 0.85, 0.75)
dash_dcc.release()

# Click to trigger an update of the output, the shape should survive
dash_dcc.wait_for_text_to_equal("#output", "1")
button.click()
dash_dcc.wait_for_text_to_equal("#output", "2")


@pytest.mark.parametrize("mutate_fig", [True, False])
def test_grva009_originals_maintained_for_responsive_override(mutate_fig, dash_dcc):
# In #905 we made changes to prevent shapes from being lost.
# This test makes sure that the overrides applied by the `responsive`
# prop are "undone" when the `responsive` prop changes.

app = dash.Dash(__name__)

graph = dcc.Graph(
id="graph",
figure={"data": [{"y": [1, 2]}], "layout": {"width": 300, "height": 250}},
style={"height": "400px", "width": "500px"},
)
responsive_size = [500, 400]
fixed_size = [300, 250]

app.layout = html.Div(
[
graph,
html.Br(),
html.Button(id="edit_figure", children="Edit figure"),
html.Button(id="edit_responsive", children="Edit responsive"),
html.Div(id="output", children=""),
]
)

if mutate_fig:
# Modify the layout in place (which still has changes made by responsive)
change_fig = """
figure.layout.title = {text: String(n_fig || 0)};
const new_figure = {...figure};
"""
else:
# Or create a new one each time
change_fig = """
const new_figure = {
data: [{y: [1, 2]}],
layout: {width: 300, height: 250, title: {text: String(n_fig || 0)}}
};
"""

callback = (
"""
function clone_figure(n_fig, n_resp, figure) {
"""
+ change_fig
+ """
let responsive = [true, false, 'auto'][(n_resp || 0) % 3];
return [new_figure, responsive, (n_fig || 0) + ' ' + responsive];
}
"""
)

app.clientside_callback(
callback,
Output("graph", "figure"),
Output("graph", "responsive"),
Output("output", "children"),
Input("edit_figure", "n_clicks"),
Input("edit_responsive", "n_clicks"),
State("graph", "figure"),
)

dash_dcc.start_server(app)
edit_figure = dash_dcc.wait_for_element("#edit_figure")
edit_responsive = dash_dcc.wait_for_element("#edit_responsive")

def graph_dims():
return dash_dcc.driver.execute_script(
"""
const layout = document.querySelector('.js-plotly-plot')._fullLayout;
return [layout.width, layout.height];
"""
)

dash_dcc.wait_for_text_to_equal("#output", "0 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "0")
assert graph_dims() == responsive_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "1 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "1")
assert graph_dims() == responsive_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "1 false")
dash_dcc.wait_for_text_to_equal(".gtitle", "1")
assert graph_dims() == fixed_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "2 false")
dash_dcc.wait_for_text_to_equal(".gtitle", "2")
assert graph_dims() == fixed_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "2 auto")
dash_dcc.wait_for_text_to_equal(".gtitle", "2")
assert graph_dims() == fixed_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "3 auto")
dash_dcc.wait_for_text_to_equal(".gtitle", "3")
assert graph_dims() == fixed_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "3 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "3")
assert graph_dims() == responsive_size
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
This repository was archived by the owner on Aug 29, 2025. It is now read-only.
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
7 changes: 4 additions & 3 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,12 +4,13 @@ This project adheres to [Semantic Versioning](http://semver.org/).

## [UNRELEASED]
### Fixed
- [#903](https://github.com/plotly/dash-core-components/pull/903) - part of fixing dash import bug https://github.com/plotly/dash/issues/1143
- [#905](https://github.com/plotly/dash-core-components/pull/905) Make sure the `figure` prop of `dcc.Graph` receives updates from user interactions in the graph, by using the same `layout` object as provided in the prop rather than cloning it. Fixes [#879](https://github.com/plotly/dash-core-components/issues/879).
- [#903](https://github.com/plotly/dash-core-components/pull/903) Part of fixing dash import bug https://github.com/plotly/dash/issues/1143

### Updated
- [#911](https://github.com/plotly/dash-core-components/pull/911)
- [#911](https://github.com/plotly/dash-core-components/pull/911), [#906](https://github.com/plotly/dash-core-components/pull/906)
- Upgraded Plotly.js to [1.58.4](https://github.com/plotly/plotly.js/releases/tag/v1.58.4)
- Patch Release [1.58.4](https://github.com/plotly/plotly.js/releases/tag/v1.58.4)
- [#906](https://github.com/plotly/dash-core-components/pull/906)
- Patch Release [1.58.3](https://github.com/plotly/plotly.js/releases/tag/v1.58.3)

## [1.14.1] - 2020-12-09
Expand Down
27 changes: 25 additions & 2 deletions src/fragments/Graph.react.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,6 +131,8 @@ class PlotlyGraph extends Component {
this.getLayoutOverride = this.getLayoutOverride.bind(this);
this.graphResize = this.graphResize.bind(this);
this.isResponsive = this.isResponsive.bind(this);

this.state = {override: {}, originals: {}};
}

plot(props) {
Expand DownExpand Up@@ -226,8 +228,29 @@ class PlotlyGraph extends Component {
if (!layout) {
return layout;
}

return mergeDeepRight(layout, this.getLayoutOverride(responsive));
const override = this.getLayoutOverride(responsive);
const {override: prev_override, originals: prev_originals} = this.state;
// Store the original data that we're about to override
const originals = {};
for (const key in override) {
if (layout[key] !== prev_override[key]) {
originals[key] = layout[key];
} else if (prev_originals.hasOwnProperty(key)) {
originals[key] = prev_originals[key];
}
}
this.setState({override, originals});
// Undo the previous override, but only for keys that the user did not change
for (const key in prev_originals) {
if (layout[key] === prev_override[key]) {
layout[key] = prev_originals[key];
}
}
// Apply the current override
for (const key in override) {
layout[key] = override[key];
}
return layout; // not really a clone
}

getConfigOverride(responsive) {
Expand Down
9 changes: 9 additions & 0 deletions tests/dash_core_components_page.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,3 +118,12 @@ def move_to_coord_fractions(self, elem_or_selector, fx, fy):

def release(self):
ActionChains(self.driver).release().perform()

def click_and_drag_at_coord_fractions(self, elem_or_selector, fx1, fy1, fx2, fy2):
elem = self._get_element(elem_or_selector)

ActionChains(self.driver).move_to_element_with_offset(
elem, elem.size["width"] * fx1, elem.size["height"] * fy1
).click_and_hold().move_to_element_with_offset(
elem, elem.size["width"] * fx2, elem.size["height"] * fy2
).release().perform()
179 changes: 172 additions & 7 deletions tests/integration/graph/test_graph_varia.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,7 +25,7 @@ def findAsyncPlotlyJs(scripts):


@pytest.mark.parametrize("is_eager", [True, False])
def test_candlestick(dash_dcc, is_eager):
def test_grva001_candlestick(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)
app.layout = html.Div(
[
Expand DownExpand Up@@ -75,7 +75,7 @@ def update_graph(n_clicks):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graphs_with_different_figures(dash_dcc, is_eager):
def test_grva002_graphs_with_different_figures(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)
app.layout = html.Div(
[
Expand DownExpand Up@@ -160,7 +160,7 @@ def show_relayout_data(data):


@pytest.mark.parametrize("is_eager", [True, False])
def test_empty_graph(dash_dcc, is_eager):
def test_grva003_empty_graph(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

app.layout = html.Div(
Expand DownExpand Up@@ -193,7 +193,7 @@ def render_content(click, prev_graph):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graph_prepend_trace(dash_dcc, is_eager):
def test_grva004_graph_prepend_trace(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

def generate_with_id(id, data=None):
Expand DownExpand Up@@ -358,7 +358,7 @@ def display_data(trigger, fig):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graph_extend_trace(dash_dcc, is_eager):
def test_grva005_graph_extend_trace(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

def generate_with_id(id, data=None):
Expand DownExpand Up@@ -521,7 +521,7 @@ def display_data(trigger, fig):


@pytest.mark.parametrize("is_eager", [True, False])
def test_unmounted_graph_resize(dash_dcc, is_eager):
def test_grva006_unmounted_graph_resize(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

app.layout = html.Div(
Expand DownExpand Up@@ -619,7 +619,7 @@ def test_unmounted_graph_resize(dash_dcc, is_eager):
dash_dcc.driver.set_window_size(window_size["width"], window_size["height"])


def test_external_plotlyjs_prevents_lazy(dash_dcc):
def test_grva007_external_plotlyjs_prevents_lazy(dash_dcc):
app = dash.Dash(
__name__,
eager_loading=False,
Expand DownExpand Up@@ -658,3 +658,168 @@ def load_chart(n_clicks):
scripts = dash_dcc.driver.find_elements(By.CSS_SELECTOR, "script")
assert findSyncPlotlyJs(scripts) is None
assert findAsyncPlotlyJs(scripts) is None


def test_grva008_shapes_not_lost(dash_dcc):
# See issue #879 and pr #905
app = dash.Dash(__name__)

fig = {"data": [], "layout": {"dragmode": "drawrect"}}
graph = dcc.Graph(id="graph", figure=fig, style={"height": "400px"})

app.layout = html.Div(
[
graph,
html.Br(),
html.Button(id="button", children="Clone figure"),
html.Div(id="output", children=""),
]
)

app.clientside_callback(
"""
function clone_figure(_, figure) {
const new_figure = {...figure};
const shapes = new_figure.layout.shapes || [];
return [new_figure, shapes.length];
}
""",
Output("graph", "figure"),
Output("output", "children"),
Input("button", "n_clicks"),
State("graph", "figure"),
)

dash_dcc.start_server(app)
button = dash_dcc.wait_for_element("#button")
dash_dcc.wait_for_text_to_equal("#output", "0")

# Draw a shape
dash_dcc.click_and_hold_at_coord_fractions("#graph", 0.25, 0.25)
dash_dcc.move_to_coord_fractions("#graph", 0.35, 0.75)
dash_dcc.release()

# Click to trigger an update of the output, the shape should survive
dash_dcc.wait_for_text_to_equal("#output", "0")
button.click()
dash_dcc.wait_for_text_to_equal("#output", "1")

# Draw another shape
dash_dcc.click_and_hold_at_coord_fractions("#graph", 0.75, 0.25)
dash_dcc.move_to_coord_fractions("#graph", 0.85, 0.75)
dash_dcc.release()

# Click to trigger an update of the output, the shape should survive
dash_dcc.wait_for_text_to_equal("#output", "1")
button.click()
dash_dcc.wait_for_text_to_equal("#output", "2")


@pytest.mark.parametrize("mutate_fig", [True, False])
def test_grva009_originals_maintained_for_responsive_override(mutate_fig, dash_dcc):
# In #905 we made changes to prevent shapes from being lost.
# This test makes sure that the overrides applied by the `responsive`
# prop are "undone" when the `responsive` prop changes.

app = dash.Dash(__name__)

graph = dcc.Graph(
id="graph",
figure={"data": [{"y": [1, 2]}], "layout": {"width": 300, "height": 250}},
style={"height": "400px", "width": "500px"},
)
responsive_size = [500, 400]
fixed_size = [300, 250]

app.layout = html.Div(
[
graph,
html.Br(),
html.Button(id="edit_figure", children="Edit figure"),
html.Button(id="edit_responsive", children="Edit responsive"),
html.Div(id="output", children=""),
]
)

if mutate_fig:
# Modify the layout in place (which still has changes made by responsive)
change_fig = """
figure.layout.title = {text: String(n_fig || 0)};
const new_figure = {...figure};
"""
else:
# Or create a new one each time
change_fig = """
const new_figure = {
data: [{y: [1, 2]}],
layout: {width: 300, height: 250, title: {text: String(n_fig || 0)}}
};
"""

callback = (
"""
function clone_figure(n_fig, n_resp, figure) {
"""
+ change_fig
+ """
let responsive = [true, false, 'auto'][(n_resp || 0) % 3];
return [new_figure, responsive, (n_fig || 0) + ' ' + responsive];
}
"""
)

app.clientside_callback(
callback,
Output("graph", "figure"),
Output("graph", "responsive"),
Output("output", "children"),
Input("edit_figure", "n_clicks"),
Input("edit_responsive", "n_clicks"),
State("graph", "figure"),
)

dash_dcc.start_server(app)
edit_figure = dash_dcc.wait_for_element("#edit_figure")
edit_responsive = dash_dcc.wait_for_element("#edit_responsive")

def graph_dims():
return dash_dcc.driver.execute_script(
"""
const layout = document.querySelector('.js-plotly-plot')._fullLayout;
return [layout.width, layout.height];
"""
)

dash_dcc.wait_for_text_to_equal("#output", "0 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "0")
assert graph_dims() == responsive_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "1 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "1")
assert graph_dims() == responsive_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "1 false")
dash_dcc.wait_for_text_to_equal(".gtitle", "1")
assert graph_dims() == fixed_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "2 false")
dash_dcc.wait_for_text_to_equal(".gtitle", "2")
assert graph_dims() == fixed_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "2 auto")
dash_dcc.wait_for_text_to_equal(".gtitle", "2")
assert graph_dims() == fixed_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "3 auto")
dash_dcc.wait_for_text_to_equal(".gtitle", "3")
assert graph_dims() == fixed_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "3 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "3")
assert graph_dims() == responsive_size
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
This repository was archived by the owner on Aug 29, 2025. It is now read-only.
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
7 changes: 4 additions & 3 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,12 +4,13 @@ This project adheres to [Semantic Versioning](http://semver.org/).

## [UNRELEASED]
### Fixed
- [#903](https://github.com/plotly/dash-core-components/pull/903) - part of fixing dash import bug https://github.com/plotly/dash/issues/1143
- [#905](https://github.com/plotly/dash-core-components/pull/905) Make sure the `figure` prop of `dcc.Graph` receives updates from user interactions in the graph, by using the same `layout` object as provided in the prop rather than cloning it. Fixes [#879](https://github.com/plotly/dash-core-components/issues/879).
- [#903](https://github.com/plotly/dash-core-components/pull/903) Part of fixing dash import bug https://github.com/plotly/dash/issues/1143

### Updated
- [#911](https://github.com/plotly/dash-core-components/pull/911)
- [#911](https://github.com/plotly/dash-core-components/pull/911), [#906](https://github.com/plotly/dash-core-components/pull/906)
- Upgraded Plotly.js to [1.58.4](https://github.com/plotly/plotly.js/releases/tag/v1.58.4)
- Patch Release [1.58.4](https://github.com/plotly/plotly.js/releases/tag/v1.58.4)
- [#906](https://github.com/plotly/dash-core-components/pull/906)
- Patch Release [1.58.3](https://github.com/plotly/plotly.js/releases/tag/v1.58.3)

## [1.14.1] - 2020-12-09
Expand Down
27 changes: 25 additions & 2 deletions src/fragments/Graph.react.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,6 +131,8 @@ class PlotlyGraph extends Component {
this.getLayoutOverride = this.getLayoutOverride.bind(this);
this.graphResize = this.graphResize.bind(this);
this.isResponsive = this.isResponsive.bind(this);

this.state = {override: {}, originals: {}};
}

plot(props) {
Expand DownExpand Up@@ -226,8 +228,29 @@ class PlotlyGraph extends Component {
if (!layout) {
return layout;
}

return mergeDeepRight(layout, this.getLayoutOverride(responsive));
const override = this.getLayoutOverride(responsive);
const {override: prev_override, originals: prev_originals} = this.state;
// Store the original data that we're about to override
const originals = {};
for (const key in override) {
if (layout[key] !== prev_override[key]) {
originals[key] = layout[key];
} else if (prev_originals.hasOwnProperty(key)) {
originals[key] = prev_originals[key];
}
}
this.setState({override, originals});
// Undo the previous override, but only for keys that the user did not change
for (const key in prev_originals) {
if (layout[key] === prev_override[key]) {
layout[key] = prev_originals[key];
}
}
// Apply the current override
for (const key in override) {
layout[key] = override[key];
}
return layout; // not really a clone
}

getConfigOverride(responsive) {
Expand Down
9 changes: 9 additions & 0 deletions tests/dash_core_components_page.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,3 +118,12 @@ def move_to_coord_fractions(self, elem_or_selector, fx, fy):

def release(self):
ActionChains(self.driver).release().perform()

def click_and_drag_at_coord_fractions(self, elem_or_selector, fx1, fy1, fx2, fy2):
elem = self._get_element(elem_or_selector)

ActionChains(self.driver).move_to_element_with_offset(
elem, elem.size["width"] * fx1, elem.size["height"] * fy1
).click_and_hold().move_to_element_with_offset(
elem, elem.size["width"] * fx2, elem.size["height"] * fy2
).release().perform()
179 changes: 172 additions & 7 deletions tests/integration/graph/test_graph_varia.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,7 +25,7 @@ def findAsyncPlotlyJs(scripts):


@pytest.mark.parametrize("is_eager", [True, False])
def test_candlestick(dash_dcc, is_eager):
def test_grva001_candlestick(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)
app.layout = html.Div(
[
Expand DownExpand Up@@ -75,7 +75,7 @@ def update_graph(n_clicks):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graphs_with_different_figures(dash_dcc, is_eager):
def test_grva002_graphs_with_different_figures(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)
app.layout = html.Div(
[
Expand DownExpand Up@@ -160,7 +160,7 @@ def show_relayout_data(data):


@pytest.mark.parametrize("is_eager", [True, False])
def test_empty_graph(dash_dcc, is_eager):
def test_grva003_empty_graph(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

app.layout = html.Div(
Expand DownExpand Up@@ -193,7 +193,7 @@ def render_content(click, prev_graph):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graph_prepend_trace(dash_dcc, is_eager):
def test_grva004_graph_prepend_trace(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

def generate_with_id(id, data=None):
Expand DownExpand Up@@ -358,7 +358,7 @@ def display_data(trigger, fig):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graph_extend_trace(dash_dcc, is_eager):
def test_grva005_graph_extend_trace(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

def generate_with_id(id, data=None):
Expand DownExpand Up@@ -521,7 +521,7 @@ def display_data(trigger, fig):


@pytest.mark.parametrize("is_eager", [True, False])
def test_unmounted_graph_resize(dash_dcc, is_eager):
def test_grva006_unmounted_graph_resize(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

app.layout = html.Div(
Expand DownExpand Up@@ -619,7 +619,7 @@ def test_unmounted_graph_resize(dash_dcc, is_eager):
dash_dcc.driver.set_window_size(window_size["width"], window_size["height"])


def test_external_plotlyjs_prevents_lazy(dash_dcc):
def test_grva007_external_plotlyjs_prevents_lazy(dash_dcc):
app = dash.Dash(
__name__,
eager_loading=False,
Expand DownExpand Up@@ -658,3 +658,168 @@ def load_chart(n_clicks):
scripts = dash_dcc.driver.find_elements(By.CSS_SELECTOR, "script")
assert findSyncPlotlyJs(scripts) is None
assert findAsyncPlotlyJs(scripts) is None


def test_grva008_shapes_not_lost(dash_dcc):
# See issue #879 and pr #905
app = dash.Dash(__name__)

fig = {"data": [], "layout": {"dragmode": "drawrect"}}
graph = dcc.Graph(id="graph", figure=fig, style={"height": "400px"})

app.layout = html.Div(
[
graph,
html.Br(),
html.Button(id="button", children="Clone figure"),
html.Div(id="output", children=""),
]
)

app.clientside_callback(
"""
function clone_figure(_, figure) {
const new_figure = {...figure};
const shapes = new_figure.layout.shapes || [];
return [new_figure, shapes.length];
}
""",
Output("graph", "figure"),
Output("output", "children"),
Input("button", "n_clicks"),
State("graph", "figure"),
)

dash_dcc.start_server(app)
button = dash_dcc.wait_for_element("#button")
dash_dcc.wait_for_text_to_equal("#output", "0")

# Draw a shape
dash_dcc.click_and_hold_at_coord_fractions("#graph", 0.25, 0.25)
dash_dcc.move_to_coord_fractions("#graph", 0.35, 0.75)
dash_dcc.release()

# Click to trigger an update of the output, the shape should survive
dash_dcc.wait_for_text_to_equal("#output", "0")
button.click()
dash_dcc.wait_for_text_to_equal("#output", "1")

# Draw another shape
dash_dcc.click_and_hold_at_coord_fractions("#graph", 0.75, 0.25)
dash_dcc.move_to_coord_fractions("#graph", 0.85, 0.75)
dash_dcc.release()

# Click to trigger an update of the output, the shape should survive
dash_dcc.wait_for_text_to_equal("#output", "1")
button.click()
dash_dcc.wait_for_text_to_equal("#output", "2")


@pytest.mark.parametrize("mutate_fig", [True, False])
def test_grva009_originals_maintained_for_responsive_override(mutate_fig, dash_dcc):
# In #905 we made changes to prevent shapes from being lost.
# This test makes sure that the overrides applied by the `responsive`
# prop are "undone" when the `responsive` prop changes.

app = dash.Dash(__name__)

graph = dcc.Graph(
id="graph",
figure={"data": [{"y": [1, 2]}], "layout": {"width": 300, "height": 250}},
style={"height": "400px", "width": "500px"},
)
responsive_size = [500, 400]
fixed_size = [300, 250]

app.layout = html.Div(
[
graph,
html.Br(),
html.Button(id="edit_figure", children="Edit figure"),
html.Button(id="edit_responsive", children="Edit responsive"),
html.Div(id="output", children=""),
]
)

if mutate_fig:
# Modify the layout in place (which still has changes made by responsive)
change_fig = """
figure.layout.title = {text: String(n_fig || 0)};
const new_figure = {...figure};
"""
else:
# Or create a new one each time
change_fig = """
const new_figure = {
data: [{y: [1, 2]}],
layout: {width: 300, height: 250, title: {text: String(n_fig || 0)}}
};
"""

callback = (
"""
function clone_figure(n_fig, n_resp, figure) {
"""
+ change_fig
+ """
let responsive = [true, false, 'auto'][(n_resp || 0) % 3];
return [new_figure, responsive, (n_fig || 0) + ' ' + responsive];
}
"""
)

app.clientside_callback(
callback,
Output("graph", "figure"),
Output("graph", "responsive"),
Output("output", "children"),
Input("edit_figure", "n_clicks"),
Input("edit_responsive", "n_clicks"),
State("graph", "figure"),
)

dash_dcc.start_server(app)
edit_figure = dash_dcc.wait_for_element("#edit_figure")
edit_responsive = dash_dcc.wait_for_element("#edit_responsive")

def graph_dims():
return dash_dcc.driver.execute_script(
"""
const layout = document.querySelector('.js-plotly-plot')._fullLayout;
return [layout.width, layout.height];
"""
)

dash_dcc.wait_for_text_to_equal("#output", "0 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "0")
assert graph_dims() == responsive_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "1 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "1")
assert graph_dims() == responsive_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "1 false")
dash_dcc.wait_for_text_to_equal(".gtitle", "1")
assert graph_dims() == fixed_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "2 false")
dash_dcc.wait_for_text_to_equal(".gtitle", "2")
assert graph_dims() == fixed_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "2 auto")
dash_dcc.wait_for_text_to_equal(".gtitle", "2")
assert graph_dims() == fixed_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "3 auto")
dash_dcc.wait_for_text_to_equal(".gtitle", "3")
assert graph_dims() == fixed_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "3 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "3")
assert graph_dims() == responsive_size
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
This repository was archived by the owner on Aug 29, 2025. It is now read-only.
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
7 changes: 4 additions & 3 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,12 +4,13 @@ This project adheres to [Semantic Versioning](http://semver.org/).

## [UNRELEASED]
### Fixed
- [#903](https://github.com/plotly/dash-core-components/pull/903) - part of fixing dash import bug https://github.com/plotly/dash/issues/1143
- [#905](https://github.com/plotly/dash-core-components/pull/905) Make sure the `figure` prop of `dcc.Graph` receives updates from user interactions in the graph, by using the same `layout` object as provided in the prop rather than cloning it. Fixes [#879](https://github.com/plotly/dash-core-components/issues/879).
- [#903](https://github.com/plotly/dash-core-components/pull/903) Part of fixing dash import bug https://github.com/plotly/dash/issues/1143

### Updated
- [#911](https://github.com/plotly/dash-core-components/pull/911)
- [#911](https://github.com/plotly/dash-core-components/pull/911), [#906](https://github.com/plotly/dash-core-components/pull/906)
- Upgraded Plotly.js to [1.58.4](https://github.com/plotly/plotly.js/releases/tag/v1.58.4)
- Patch Release [1.58.4](https://github.com/plotly/plotly.js/releases/tag/v1.58.4)
- [#906](https://github.com/plotly/dash-core-components/pull/906)
- Patch Release [1.58.3](https://github.com/plotly/plotly.js/releases/tag/v1.58.3)

## [1.14.1] - 2020-12-09
Expand Down
27 changes: 25 additions & 2 deletions src/fragments/Graph.react.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,6 +131,8 @@ class PlotlyGraph extends Component {
this.getLayoutOverride = this.getLayoutOverride.bind(this);
this.graphResize = this.graphResize.bind(this);
this.isResponsive = this.isResponsive.bind(this);

this.state = {override: {}, originals: {}};
}

plot(props) {
Expand DownExpand Up@@ -226,8 +228,29 @@ class PlotlyGraph extends Component {
if (!layout) {
return layout;
}

return mergeDeepRight(layout, this.getLayoutOverride(responsive));
const override = this.getLayoutOverride(responsive);
const {override: prev_override, originals: prev_originals} = this.state;
// Store the original data that we're about to override
const originals = {};
for (const key in override) {
if (layout[key] !== prev_override[key]) {
originals[key] = layout[key];
} else if (prev_originals.hasOwnProperty(key)) {
originals[key] = prev_originals[key];
}
}
this.setState({override, originals});
// Undo the previous override, but only for keys that the user did not change
for (const key in prev_originals) {
if (layout[key] === prev_override[key]) {
layout[key] = prev_originals[key];
}
}
// Apply the current override
for (const key in override) {
layout[key] = override[key];
}
return layout; // not really a clone
}

getConfigOverride(responsive) {
Expand Down
9 changes: 9 additions & 0 deletions tests/dash_core_components_page.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,3 +118,12 @@ def move_to_coord_fractions(self, elem_or_selector, fx, fy):

def release(self):
ActionChains(self.driver).release().perform()

def click_and_drag_at_coord_fractions(self, elem_or_selector, fx1, fy1, fx2, fy2):
elem = self._get_element(elem_or_selector)

ActionChains(self.driver).move_to_element_with_offset(
elem, elem.size["width"] * fx1, elem.size["height"] * fy1
).click_and_hold().move_to_element_with_offset(
elem, elem.size["width"] * fx2, elem.size["height"] * fy2
).release().perform()
179 changes: 172 additions & 7 deletions tests/integration/graph/test_graph_varia.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,7 +25,7 @@ def findAsyncPlotlyJs(scripts):


@pytest.mark.parametrize("is_eager", [True, False])
def test_candlestick(dash_dcc, is_eager):
def test_grva001_candlestick(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)
app.layout = html.Div(
[
Expand DownExpand Up@@ -75,7 +75,7 @@ def update_graph(n_clicks):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graphs_with_different_figures(dash_dcc, is_eager):
def test_grva002_graphs_with_different_figures(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)
app.layout = html.Div(
[
Expand DownExpand Up@@ -160,7 +160,7 @@ def show_relayout_data(data):


@pytest.mark.parametrize("is_eager", [True, False])
def test_empty_graph(dash_dcc, is_eager):
def test_grva003_empty_graph(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

app.layout = html.Div(
Expand DownExpand Up@@ -193,7 +193,7 @@ def render_content(click, prev_graph):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graph_prepend_trace(dash_dcc, is_eager):
def test_grva004_graph_prepend_trace(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

def generate_with_id(id, data=None):
Expand DownExpand Up@@ -358,7 +358,7 @@ def display_data(trigger, fig):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graph_extend_trace(dash_dcc, is_eager):
def test_grva005_graph_extend_trace(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

def generate_with_id(id, data=None):
Expand DownExpand Up@@ -521,7 +521,7 @@ def display_data(trigger, fig):


@pytest.mark.parametrize("is_eager", [True, False])
def test_unmounted_graph_resize(dash_dcc, is_eager):
def test_grva006_unmounted_graph_resize(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

app.layout = html.Div(
Expand DownExpand Up@@ -619,7 +619,7 @@ def test_unmounted_graph_resize(dash_dcc, is_eager):
dash_dcc.driver.set_window_size(window_size["width"], window_size["height"])


def test_external_plotlyjs_prevents_lazy(dash_dcc):
def test_grva007_external_plotlyjs_prevents_lazy(dash_dcc):
app = dash.Dash(
__name__,
eager_loading=False,
Expand DownExpand Up@@ -658,3 +658,168 @@ def load_chart(n_clicks):
scripts = dash_dcc.driver.find_elements(By.CSS_SELECTOR, "script")
assert findSyncPlotlyJs(scripts) is None
assert findAsyncPlotlyJs(scripts) is None


def test_grva008_shapes_not_lost(dash_dcc):
# See issue #879 and pr #905
app = dash.Dash(__name__)

fig = {"data": [], "layout": {"dragmode": "drawrect"}}
graph = dcc.Graph(id="graph", figure=fig, style={"height": "400px"})

app.layout = html.Div(
[
graph,
html.Br(),
html.Button(id="button", children="Clone figure"),
html.Div(id="output", children=""),
]
)

app.clientside_callback(
"""
function clone_figure(_, figure) {
const new_figure = {...figure};
const shapes = new_figure.layout.shapes || [];
return [new_figure, shapes.length];
}
""",
Output("graph", "figure"),
Output("output", "children"),
Input("button", "n_clicks"),
State("graph", "figure"),
)

dash_dcc.start_server(app)
button = dash_dcc.wait_for_element("#button")
dash_dcc.wait_for_text_to_equal("#output", "0")

# Draw a shape
dash_dcc.click_and_hold_at_coord_fractions("#graph", 0.25, 0.25)
dash_dcc.move_to_coord_fractions("#graph", 0.35, 0.75)
dash_dcc.release()

# Click to trigger an update of the output, the shape should survive
dash_dcc.wait_for_text_to_equal("#output", "0")
button.click()
dash_dcc.wait_for_text_to_equal("#output", "1")

# Draw another shape
dash_dcc.click_and_hold_at_coord_fractions("#graph", 0.75, 0.25)
dash_dcc.move_to_coord_fractions("#graph", 0.85, 0.75)
dash_dcc.release()

# Click to trigger an update of the output, the shape should survive
dash_dcc.wait_for_text_to_equal("#output", "1")
button.click()
dash_dcc.wait_for_text_to_equal("#output", "2")


@pytest.mark.parametrize("mutate_fig", [True, False])
def test_grva009_originals_maintained_for_responsive_override(mutate_fig, dash_dcc):
# In #905 we made changes to prevent shapes from being lost.
# This test makes sure that the overrides applied by the `responsive`
# prop are "undone" when the `responsive` prop changes.

app = dash.Dash(__name__)

graph = dcc.Graph(
id="graph",
figure={"data": [{"y": [1, 2]}], "layout": {"width": 300, "height": 250}},
style={"height": "400px", "width": "500px"},
)
responsive_size = [500, 400]
fixed_size = [300, 250]

app.layout = html.Div(
[
graph,
html.Br(),
html.Button(id="edit_figure", children="Edit figure"),
html.Button(id="edit_responsive", children="Edit responsive"),
html.Div(id="output", children=""),
]
)

if mutate_fig:
# Modify the layout in place (which still has changes made by responsive)
change_fig = """
figure.layout.title = {text: String(n_fig || 0)};
const new_figure = {...figure};
"""
else:
# Or create a new one each time
change_fig = """
const new_figure = {
data: [{y: [1, 2]}],
layout: {width: 300, height: 250, title: {text: String(n_fig || 0)}}
};
"""

callback = (
"""
function clone_figure(n_fig, n_resp, figure) {
"""
+ change_fig
+ """
let responsive = [true, false, 'auto'][(n_resp || 0) % 3];
return [new_figure, responsive, (n_fig || 0) + ' ' + responsive];
}
"""
)

app.clientside_callback(
callback,
Output("graph", "figure"),
Output("graph", "responsive"),
Output("output", "children"),
Input("edit_figure", "n_clicks"),
Input("edit_responsive", "n_clicks"),
State("graph", "figure"),
)

dash_dcc.start_server(app)
edit_figure = dash_dcc.wait_for_element("#edit_figure")
edit_responsive = dash_dcc.wait_for_element("#edit_responsive")

def graph_dims():
return dash_dcc.driver.execute_script(
"""
const layout = document.querySelector('.js-plotly-plot')._fullLayout;
return [layout.width, layout.height];
"""
)

dash_dcc.wait_for_text_to_equal("#output", "0 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "0")
assert graph_dims() == responsive_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "1 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "1")
assert graph_dims() == responsive_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "1 false")
dash_dcc.wait_for_text_to_equal(".gtitle", "1")
assert graph_dims() == fixed_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "2 false")
dash_dcc.wait_for_text_to_equal(".gtitle", "2")
assert graph_dims() == fixed_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "2 auto")
dash_dcc.wait_for_text_to_equal(".gtitle", "2")
assert graph_dims() == fixed_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "3 auto")
dash_dcc.wait_for_text_to_equal(".gtitle", "3")
assert graph_dims() == fixed_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "3 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "3")
assert graph_dims() == responsive_size
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
This repository was archived by the owner on Aug 29, 2025. It is now read-only.
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
7 changes: 4 additions & 3 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,12 +4,13 @@ This project adheres to [Semantic Versioning](http://semver.org/).

## [UNRELEASED]
### Fixed
- [#903](https://github.com/plotly/dash-core-components/pull/903) - part of fixing dash import bug https://github.com/plotly/dash/issues/1143
- [#905](https://github.com/plotly/dash-core-components/pull/905) Make sure the `figure` prop of `dcc.Graph` receives updates from user interactions in the graph, by using the same `layout` object as provided in the prop rather than cloning it. Fixes [#879](https://github.com/plotly/dash-core-components/issues/879).
- [#903](https://github.com/plotly/dash-core-components/pull/903) Part of fixing dash import bug https://github.com/plotly/dash/issues/1143

### Updated
- [#911](https://github.com/plotly/dash-core-components/pull/911)
- [#911](https://github.com/plotly/dash-core-components/pull/911), [#906](https://github.com/plotly/dash-core-components/pull/906)
- Upgraded Plotly.js to [1.58.4](https://github.com/plotly/plotly.js/releases/tag/v1.58.4)
- Patch Release [1.58.4](https://github.com/plotly/plotly.js/releases/tag/v1.58.4)
- [#906](https://github.com/plotly/dash-core-components/pull/906)
- Patch Release [1.58.3](https://github.com/plotly/plotly.js/releases/tag/v1.58.3)

## [1.14.1] - 2020-12-09
Expand Down
27 changes: 25 additions & 2 deletions src/fragments/Graph.react.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,6 +131,8 @@ class PlotlyGraph extends Component {
this.getLayoutOverride = this.getLayoutOverride.bind(this);
this.graphResize = this.graphResize.bind(this);
this.isResponsive = this.isResponsive.bind(this);

this.state = {override: {}, originals: {}};
}

plot(props) {
Expand DownExpand Up@@ -226,8 +228,29 @@ class PlotlyGraph extends Component {
if (!layout) {
return layout;
}

return mergeDeepRight(layout, this.getLayoutOverride(responsive));
const override = this.getLayoutOverride(responsive);
const {override: prev_override, originals: prev_originals} = this.state;
// Store the original data that we're about to override
const originals = {};
for (const key in override) {
if (layout[key] !== prev_override[key]) {
originals[key] = layout[key];
} else if (prev_originals.hasOwnProperty(key)) {
originals[key] = prev_originals[key];
}
}
this.setState({override, originals});
// Undo the previous override, but only for keys that the user did not change
for (const key in prev_originals) {
if (layout[key] === prev_override[key]) {
layout[key] = prev_originals[key];
}
}
// Apply the current override
for (const key in override) {
layout[key] = override[key];
}
return layout; // not really a clone
}

getConfigOverride(responsive) {
Expand Down
9 changes: 9 additions & 0 deletions tests/dash_core_components_page.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,3 +118,12 @@ def move_to_coord_fractions(self, elem_or_selector, fx, fy):

def release(self):
ActionChains(self.driver).release().perform()

def click_and_drag_at_coord_fractions(self, elem_or_selector, fx1, fy1, fx2, fy2):
elem = self._get_element(elem_or_selector)

ActionChains(self.driver).move_to_element_with_offset(
elem, elem.size["width"] * fx1, elem.size["height"] * fy1
).click_and_hold().move_to_element_with_offset(
elem, elem.size["width"] * fx2, elem.size["height"] * fy2
).release().perform()
179 changes: 172 additions & 7 deletions tests/integration/graph/test_graph_varia.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,7 +25,7 @@ def findAsyncPlotlyJs(scripts):


@pytest.mark.parametrize("is_eager", [True, False])
def test_candlestick(dash_dcc, is_eager):
def test_grva001_candlestick(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)
app.layout = html.Div(
[
Expand DownExpand Up@@ -75,7 +75,7 @@ def update_graph(n_clicks):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graphs_with_different_figures(dash_dcc, is_eager):
def test_grva002_graphs_with_different_figures(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)
app.layout = html.Div(
[
Expand DownExpand Up@@ -160,7 +160,7 @@ def show_relayout_data(data):


@pytest.mark.parametrize("is_eager", [True, False])
def test_empty_graph(dash_dcc, is_eager):
def test_grva003_empty_graph(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

app.layout = html.Div(
Expand DownExpand Up@@ -193,7 +193,7 @@ def render_content(click, prev_graph):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graph_prepend_trace(dash_dcc, is_eager):
def test_grva004_graph_prepend_trace(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

def generate_with_id(id, data=None):
Expand DownExpand Up@@ -358,7 +358,7 @@ def display_data(trigger, fig):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graph_extend_trace(dash_dcc, is_eager):
def test_grva005_graph_extend_trace(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

def generate_with_id(id, data=None):
Expand DownExpand Up@@ -521,7 +521,7 @@ def display_data(trigger, fig):


@pytest.mark.parametrize("is_eager", [True, False])
def test_unmounted_graph_resize(dash_dcc, is_eager):
def test_grva006_unmounted_graph_resize(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

app.layout = html.Div(
Expand DownExpand Up@@ -619,7 +619,7 @@ def test_unmounted_graph_resize(dash_dcc, is_eager):
dash_dcc.driver.set_window_size(window_size["width"], window_size["height"])


def test_external_plotlyjs_prevents_lazy(dash_dcc):
def test_grva007_external_plotlyjs_prevents_lazy(dash_dcc):
app = dash.Dash(
__name__,
eager_loading=False,
Expand DownExpand Up@@ -658,3 +658,168 @@ def load_chart(n_clicks):
scripts = dash_dcc.driver.find_elements(By.CSS_SELECTOR, "script")
assert findSyncPlotlyJs(scripts) is None
assert findAsyncPlotlyJs(scripts) is None


def test_grva008_shapes_not_lost(dash_dcc):
# See issue #879 and pr #905
app = dash.Dash(__name__)

fig = {"data": [], "layout": {"dragmode": "drawrect"}}
graph = dcc.Graph(id="graph", figure=fig, style={"height": "400px"})

app.layout = html.Div(
[
graph,
html.Br(),
html.Button(id="button", children="Clone figure"),
html.Div(id="output", children=""),
]
)

app.clientside_callback(
"""
function clone_figure(_, figure) {
const new_figure = {...figure};
const shapes = new_figure.layout.shapes || [];
return [new_figure, shapes.length];
}
""",
Output("graph", "figure"),
Output("output", "children"),
Input("button", "n_clicks"),
State("graph", "figure"),
)

dash_dcc.start_server(app)
button = dash_dcc.wait_for_element("#button")
dash_dcc.wait_for_text_to_equal("#output", "0")

# Draw a shape
dash_dcc.click_and_hold_at_coord_fractions("#graph", 0.25, 0.25)
dash_dcc.move_to_coord_fractions("#graph", 0.35, 0.75)
dash_dcc.release()

# Click to trigger an update of the output, the shape should survive
dash_dcc.wait_for_text_to_equal("#output", "0")
button.click()
dash_dcc.wait_for_text_to_equal("#output", "1")

# Draw another shape
dash_dcc.click_and_hold_at_coord_fractions("#graph", 0.75, 0.25)
dash_dcc.move_to_coord_fractions("#graph", 0.85, 0.75)
dash_dcc.release()

# Click to trigger an update of the output, the shape should survive
dash_dcc.wait_for_text_to_equal("#output", "1")
button.click()
dash_dcc.wait_for_text_to_equal("#output", "2")


@pytest.mark.parametrize("mutate_fig", [True, False])
def test_grva009_originals_maintained_for_responsive_override(mutate_fig, dash_dcc):
# In #905 we made changes to prevent shapes from being lost.
# This test makes sure that the overrides applied by the `responsive`
# prop are "undone" when the `responsive` prop changes.

app = dash.Dash(__name__)

graph = dcc.Graph(
id="graph",
figure={"data": [{"y": [1, 2]}], "layout": {"width": 300, "height": 250}},
style={"height": "400px", "width": "500px"},
)
responsive_size = [500, 400]
fixed_size = [300, 250]

app.layout = html.Div(
[
graph,
html.Br(),
html.Button(id="edit_figure", children="Edit figure"),
html.Button(id="edit_responsive", children="Edit responsive"),
html.Div(id="output", children=""),
]
)

if mutate_fig:
# Modify the layout in place (which still has changes made by responsive)
change_fig = """
figure.layout.title = {text: String(n_fig || 0)};
const new_figure = {...figure};
"""
else:
# Or create a new one each time
change_fig = """
const new_figure = {
data: [{y: [1, 2]}],
layout: {width: 300, height: 250, title: {text: String(n_fig || 0)}}
};
"""

callback = (
"""
function clone_figure(n_fig, n_resp, figure) {
"""
+ change_fig
+ """
let responsive = [true, false, 'auto'][(n_resp || 0) % 3];
return [new_figure, responsive, (n_fig || 0) + ' ' + responsive];
}
"""
)

app.clientside_callback(
callback,
Output("graph", "figure"),
Output("graph", "responsive"),
Output("output", "children"),
Input("edit_figure", "n_clicks"),
Input("edit_responsive", "n_clicks"),
State("graph", "figure"),
)

dash_dcc.start_server(app)
edit_figure = dash_dcc.wait_for_element("#edit_figure")
edit_responsive = dash_dcc.wait_for_element("#edit_responsive")

def graph_dims():
return dash_dcc.driver.execute_script(
"""
const layout = document.querySelector('.js-plotly-plot')._fullLayout;
return [layout.width, layout.height];
"""
)

dash_dcc.wait_for_text_to_equal("#output", "0 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "0")
assert graph_dims() == responsive_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "1 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "1")
assert graph_dims() == responsive_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "1 false")
dash_dcc.wait_for_text_to_equal(".gtitle", "1")
assert graph_dims() == fixed_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "2 false")
dash_dcc.wait_for_text_to_equal(".gtitle", "2")
assert graph_dims() == fixed_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "2 auto")
dash_dcc.wait_for_text_to_equal(".gtitle", "2")
assert graph_dims() == fixed_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "3 auto")
dash_dcc.wait_for_text_to_equal(".gtitle", "3")
assert graph_dims() == fixed_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "3 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "3")
assert graph_dims() == responsive_size
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
This repository was archived by the owner on Aug 29, 2025. It is now read-only.
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
7 changes: 4 additions & 3 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,12 +4,13 @@ This project adheres to [Semantic Versioning](http://semver.org/).

## [UNRELEASED]
### Fixed
- [#903](https://github.com/plotly/dash-core-components/pull/903) - part of fixing dash import bug https://github.com/plotly/dash/issues/1143
- [#905](https://github.com/plotly/dash-core-components/pull/905) Make sure the `figure` prop of `dcc.Graph` receives updates from user interactions in the graph, by using the same `layout` object as provided in the prop rather than cloning it. Fixes [#879](https://github.com/plotly/dash-core-components/issues/879).
- [#903](https://github.com/plotly/dash-core-components/pull/903) Part of fixing dash import bug https://github.com/plotly/dash/issues/1143

### Updated
- [#911](https://github.com/plotly/dash-core-components/pull/911)
- [#911](https://github.com/plotly/dash-core-components/pull/911), [#906](https://github.com/plotly/dash-core-components/pull/906)
- Upgraded Plotly.js to [1.58.4](https://github.com/plotly/plotly.js/releases/tag/v1.58.4)
- Patch Release [1.58.4](https://github.com/plotly/plotly.js/releases/tag/v1.58.4)
- [#906](https://github.com/plotly/dash-core-components/pull/906)
- Patch Release [1.58.3](https://github.com/plotly/plotly.js/releases/tag/v1.58.3)

## [1.14.1] - 2020-12-09
Expand Down
27 changes: 25 additions & 2 deletions src/fragments/Graph.react.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,6 +131,8 @@ class PlotlyGraph extends Component {
this.getLayoutOverride = this.getLayoutOverride.bind(this);
this.graphResize = this.graphResize.bind(this);
this.isResponsive = this.isResponsive.bind(this);

this.state = {override: {}, originals: {}};
}

plot(props) {
Expand DownExpand Up@@ -226,8 +228,29 @@ class PlotlyGraph extends Component {
if (!layout) {
return layout;
}

return mergeDeepRight(layout, this.getLayoutOverride(responsive));
const override = this.getLayoutOverride(responsive);
const {override: prev_override, originals: prev_originals} = this.state;
// Store the original data that we're about to override
const originals = {};
for (const key in override) {
if (layout[key] !== prev_override[key]) {
originals[key] = layout[key];
} else if (prev_originals.hasOwnProperty(key)) {
originals[key] = prev_originals[key];
}
}
this.setState({override, originals});
// Undo the previous override, but only for keys that the user did not change
for (const key in prev_originals) {
if (layout[key] === prev_override[key]) {
layout[key] = prev_originals[key];
}
}
// Apply the current override
for (const key in override) {
layout[key] = override[key];
}
return layout; // not really a clone
}

getConfigOverride(responsive) {
Expand Down
9 changes: 9 additions & 0 deletions tests/dash_core_components_page.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,3 +118,12 @@ def move_to_coord_fractions(self, elem_or_selector, fx, fy):

def release(self):
ActionChains(self.driver).release().perform()

def click_and_drag_at_coord_fractions(self, elem_or_selector, fx1, fy1, fx2, fy2):
elem = self._get_element(elem_or_selector)

ActionChains(self.driver).move_to_element_with_offset(
elem, elem.size["width"] * fx1, elem.size["height"] * fy1
).click_and_hold().move_to_element_with_offset(
elem, elem.size["width"] * fx2, elem.size["height"] * fy2
).release().perform()
179 changes: 172 additions & 7 deletions tests/integration/graph/test_graph_varia.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,7 +25,7 @@ def findAsyncPlotlyJs(scripts):


@pytest.mark.parametrize("is_eager", [True, False])
def test_candlestick(dash_dcc, is_eager):
def test_grva001_candlestick(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)
app.layout = html.Div(
[
Expand DownExpand Up@@ -75,7 +75,7 @@ def update_graph(n_clicks):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graphs_with_different_figures(dash_dcc, is_eager):
def test_grva002_graphs_with_different_figures(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)
app.layout = html.Div(
[
Expand DownExpand Up@@ -160,7 +160,7 @@ def show_relayout_data(data):


@pytest.mark.parametrize("is_eager", [True, False])
def test_empty_graph(dash_dcc, is_eager):
def test_grva003_empty_graph(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

app.layout = html.Div(
Expand DownExpand Up@@ -193,7 +193,7 @@ def render_content(click, prev_graph):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graph_prepend_trace(dash_dcc, is_eager):
def test_grva004_graph_prepend_trace(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

def generate_with_id(id, data=None):
Expand DownExpand Up@@ -358,7 +358,7 @@ def display_data(trigger, fig):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graph_extend_trace(dash_dcc, is_eager):
def test_grva005_graph_extend_trace(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

def generate_with_id(id, data=None):
Expand DownExpand Up@@ -521,7 +521,7 @@ def display_data(trigger, fig):


@pytest.mark.parametrize("is_eager", [True, False])
def test_unmounted_graph_resize(dash_dcc, is_eager):
def test_grva006_unmounted_graph_resize(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

app.layout = html.Div(
Expand DownExpand Up@@ -619,7 +619,7 @@ def test_unmounted_graph_resize(dash_dcc, is_eager):
dash_dcc.driver.set_window_size(window_size["width"], window_size["height"])


def test_external_plotlyjs_prevents_lazy(dash_dcc):
def test_grva007_external_plotlyjs_prevents_lazy(dash_dcc):
app = dash.Dash(
__name__,
eager_loading=False,
Expand DownExpand Up@@ -658,3 +658,168 @@ def load_chart(n_clicks):
scripts = dash_dcc.driver.find_elements(By.CSS_SELECTOR, "script")
assert findSyncPlotlyJs(scripts) is None
assert findAsyncPlotlyJs(scripts) is None


def test_grva008_shapes_not_lost(dash_dcc):
# See issue #879 and pr #905
app = dash.Dash(__name__)

fig = {"data": [], "layout": {"dragmode": "drawrect"}}
graph = dcc.Graph(id="graph", figure=fig, style={"height": "400px"})

app.layout = html.Div(
[
graph,
html.Br(),
html.Button(id="button", children="Clone figure"),
html.Div(id="output", children=""),
]
)

app.clientside_callback(
"""
function clone_figure(_, figure) {
const new_figure = {...figure};
const shapes = new_figure.layout.shapes || [];
return [new_figure, shapes.length];
}
""",
Output("graph", "figure"),
Output("output", "children"),
Input("button", "n_clicks"),
State("graph", "figure"),
)

dash_dcc.start_server(app)
button = dash_dcc.wait_for_element("#button")
dash_dcc.wait_for_text_to_equal("#output", "0")

# Draw a shape
dash_dcc.click_and_hold_at_coord_fractions("#graph", 0.25, 0.25)
dash_dcc.move_to_coord_fractions("#graph", 0.35, 0.75)
dash_dcc.release()

# Click to trigger an update of the output, the shape should survive
dash_dcc.wait_for_text_to_equal("#output", "0")
button.click()
dash_dcc.wait_for_text_to_equal("#output", "1")

# Draw another shape
dash_dcc.click_and_hold_at_coord_fractions("#graph", 0.75, 0.25)
dash_dcc.move_to_coord_fractions("#graph", 0.85, 0.75)
dash_dcc.release()

# Click to trigger an update of the output, the shape should survive
dash_dcc.wait_for_text_to_equal("#output", "1")
button.click()
dash_dcc.wait_for_text_to_equal("#output", "2")


@pytest.mark.parametrize("mutate_fig", [True, False])
def test_grva009_originals_maintained_for_responsive_override(mutate_fig, dash_dcc):
# In #905 we made changes to prevent shapes from being lost.
# This test makes sure that the overrides applied by the `responsive`
# prop are "undone" when the `responsive` prop changes.

app = dash.Dash(__name__)

graph = dcc.Graph(
id="graph",
figure={"data": [{"y": [1, 2]}], "layout": {"width": 300, "height": 250}},
style={"height": "400px", "width": "500px"},
)
responsive_size = [500, 400]
fixed_size = [300, 250]

app.layout = html.Div(
[
graph,
html.Br(),
html.Button(id="edit_figure", children="Edit figure"),
html.Button(id="edit_responsive", children="Edit responsive"),
html.Div(id="output", children=""),
]
)

if mutate_fig:
# Modify the layout in place (which still has changes made by responsive)
change_fig = """
figure.layout.title = {text: String(n_fig || 0)};
const new_figure = {...figure};
"""
else:
# Or create a new one each time
change_fig = """
const new_figure = {
data: [{y: [1, 2]}],
layout: {width: 300, height: 250, title: {text: String(n_fig || 0)}}
};
"""

callback = (
"""
function clone_figure(n_fig, n_resp, figure) {
"""
+ change_fig
+ """
let responsive = [true, false, 'auto'][(n_resp || 0) % 3];
return [new_figure, responsive, (n_fig || 0) + ' ' + responsive];
}
"""
)

app.clientside_callback(
callback,
Output("graph", "figure"),
Output("graph", "responsive"),
Output("output", "children"),
Input("edit_figure", "n_clicks"),
Input("edit_responsive", "n_clicks"),
State("graph", "figure"),
)

dash_dcc.start_server(app)
edit_figure = dash_dcc.wait_for_element("#edit_figure")
edit_responsive = dash_dcc.wait_for_element("#edit_responsive")

def graph_dims():
return dash_dcc.driver.execute_script(
"""
const layout = document.querySelector('.js-plotly-plot')._fullLayout;
return [layout.width, layout.height];
"""
)

dash_dcc.wait_for_text_to_equal("#output", "0 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "0")
assert graph_dims() == responsive_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "1 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "1")
assert graph_dims() == responsive_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "1 false")
dash_dcc.wait_for_text_to_equal(".gtitle", "1")
assert graph_dims() == fixed_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "2 false")
dash_dcc.wait_for_text_to_equal(".gtitle", "2")
assert graph_dims() == fixed_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "2 auto")
dash_dcc.wait_for_text_to_equal(".gtitle", "2")
assert graph_dims() == fixed_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "3 auto")
dash_dcc.wait_for_text_to_equal(".gtitle", "3")
assert graph_dims() == fixed_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "3 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "3")
assert graph_dims() == responsive_size
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
This repository was archived by the owner on Aug 29, 2025. It is now read-only.
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
7 changes: 4 additions & 3 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,12 +4,13 @@ This project adheres to [Semantic Versioning](http://semver.org/).

## [UNRELEASED]
### Fixed
- [#903](https://github.com/plotly/dash-core-components/pull/903) - part of fixing dash import bug https://github.com/plotly/dash/issues/1143
- [#905](https://github.com/plotly/dash-core-components/pull/905) Make sure the `figure` prop of `dcc.Graph` receives updates from user interactions in the graph, by using the same `layout` object as provided in the prop rather than cloning it. Fixes [#879](https://github.com/plotly/dash-core-components/issues/879).
- [#903](https://github.com/plotly/dash-core-components/pull/903) Part of fixing dash import bug https://github.com/plotly/dash/issues/1143

### Updated
- [#911](https://github.com/plotly/dash-core-components/pull/911)
- [#911](https://github.com/plotly/dash-core-components/pull/911), [#906](https://github.com/plotly/dash-core-components/pull/906)
- Upgraded Plotly.js to [1.58.4](https://github.com/plotly/plotly.js/releases/tag/v1.58.4)
- Patch Release [1.58.4](https://github.com/plotly/plotly.js/releases/tag/v1.58.4)
- [#906](https://github.com/plotly/dash-core-components/pull/906)
- Patch Release [1.58.3](https://github.com/plotly/plotly.js/releases/tag/v1.58.3)

## [1.14.1] - 2020-12-09
Expand Down
27 changes: 25 additions & 2 deletions src/fragments/Graph.react.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,6 +131,8 @@ class PlotlyGraph extends Component {
this.getLayoutOverride = this.getLayoutOverride.bind(this);
this.graphResize = this.graphResize.bind(this);
this.isResponsive = this.isResponsive.bind(this);

this.state = {override: {}, originals: {}};
}

plot(props) {
Expand DownExpand Up@@ -226,8 +228,29 @@ class PlotlyGraph extends Component {
if (!layout) {
return layout;
}

return mergeDeepRight(layout, this.getLayoutOverride(responsive));
const override = this.getLayoutOverride(responsive);
const {override: prev_override, originals: prev_originals} = this.state;
// Store the original data that we're about to override
const originals = {};
for (const key in override) {
if (layout[key] !== prev_override[key]) {
originals[key] = layout[key];
} else if (prev_originals.hasOwnProperty(key)) {
originals[key] = prev_originals[key];
}
}
this.setState({override, originals});
// Undo the previous override, but only for keys that the user did not change
for (const key in prev_originals) {
if (layout[key] === prev_override[key]) {
layout[key] = prev_originals[key];
}
}
// Apply the current override
for (const key in override) {
layout[key] = override[key];
}
return layout; // not really a clone
}

getConfigOverride(responsive) {
Expand Down
9 changes: 9 additions & 0 deletions tests/dash_core_components_page.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,3 +118,12 @@ def move_to_coord_fractions(self, elem_or_selector, fx, fy):

def release(self):
ActionChains(self.driver).release().perform()

def click_and_drag_at_coord_fractions(self, elem_or_selector, fx1, fy1, fx2, fy2):
elem = self._get_element(elem_or_selector)

ActionChains(self.driver).move_to_element_with_offset(
elem, elem.size["width"] * fx1, elem.size["height"] * fy1
).click_and_hold().move_to_element_with_offset(
elem, elem.size["width"] * fx2, elem.size["height"] * fy2
).release().perform()
179 changes: 172 additions & 7 deletions tests/integration/graph/test_graph_varia.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,7 +25,7 @@ def findAsyncPlotlyJs(scripts):


@pytest.mark.parametrize("is_eager", [True, False])
def test_candlestick(dash_dcc, is_eager):
def test_grva001_candlestick(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)
app.layout = html.Div(
[
Expand DownExpand Up@@ -75,7 +75,7 @@ def update_graph(n_clicks):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graphs_with_different_figures(dash_dcc, is_eager):
def test_grva002_graphs_with_different_figures(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)
app.layout = html.Div(
[
Expand DownExpand Up@@ -160,7 +160,7 @@ def show_relayout_data(data):


@pytest.mark.parametrize("is_eager", [True, False])
def test_empty_graph(dash_dcc, is_eager):
def test_grva003_empty_graph(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

app.layout = html.Div(
Expand DownExpand Up@@ -193,7 +193,7 @@ def render_content(click, prev_graph):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graph_prepend_trace(dash_dcc, is_eager):
def test_grva004_graph_prepend_trace(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

def generate_with_id(id, data=None):
Expand DownExpand Up@@ -358,7 +358,7 @@ def display_data(trigger, fig):


@pytest.mark.parametrize("is_eager", [True, False])
def test_graph_extend_trace(dash_dcc, is_eager):
def test_grva005_graph_extend_trace(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

def generate_with_id(id, data=None):
Expand DownExpand Up@@ -521,7 +521,7 @@ def display_data(trigger, fig):


@pytest.mark.parametrize("is_eager", [True, False])
def test_unmounted_graph_resize(dash_dcc, is_eager):
def test_grva006_unmounted_graph_resize(dash_dcc, is_eager):
app = dash.Dash(__name__, eager_loading=is_eager)

app.layout = html.Div(
Expand DownExpand Up@@ -619,7 +619,7 @@ def test_unmounted_graph_resize(dash_dcc, is_eager):
dash_dcc.driver.set_window_size(window_size["width"], window_size["height"])


def test_external_plotlyjs_prevents_lazy(dash_dcc):
def test_grva007_external_plotlyjs_prevents_lazy(dash_dcc):
app = dash.Dash(
__name__,
eager_loading=False,
Expand DownExpand Up@@ -658,3 +658,168 @@ def load_chart(n_clicks):
scripts = dash_dcc.driver.find_elements(By.CSS_SELECTOR, "script")
assert findSyncPlotlyJs(scripts) is None
assert findAsyncPlotlyJs(scripts) is None


def test_grva008_shapes_not_lost(dash_dcc):
# See issue #879 and pr #905
app = dash.Dash(__name__)

fig = {"data": [], "layout": {"dragmode": "drawrect"}}
graph = dcc.Graph(id="graph", figure=fig, style={"height": "400px"})

app.layout = html.Div(
[
graph,
html.Br(),
html.Button(id="button", children="Clone figure"),
html.Div(id="output", children=""),
]
)

app.clientside_callback(
"""
function clone_figure(_, figure) {
const new_figure = {...figure};
const shapes = new_figure.layout.shapes || [];
return [new_figure, shapes.length];
}
""",
Output("graph", "figure"),
Output("output", "children"),
Input("button", "n_clicks"),
State("graph", "figure"),
)

dash_dcc.start_server(app)
button = dash_dcc.wait_for_element("#button")
dash_dcc.wait_for_text_to_equal("#output", "0")

# Draw a shape
dash_dcc.click_and_hold_at_coord_fractions("#graph", 0.25, 0.25)
dash_dcc.move_to_coord_fractions("#graph", 0.35, 0.75)
dash_dcc.release()

# Click to trigger an update of the output, the shape should survive
dash_dcc.wait_for_text_to_equal("#output", "0")
button.click()
dash_dcc.wait_for_text_to_equal("#output", "1")

# Draw another shape
dash_dcc.click_and_hold_at_coord_fractions("#graph", 0.75, 0.25)
dash_dcc.move_to_coord_fractions("#graph", 0.85, 0.75)
dash_dcc.release()

# Click to trigger an update of the output, the shape should survive
dash_dcc.wait_for_text_to_equal("#output", "1")
button.click()
dash_dcc.wait_for_text_to_equal("#output", "2")


@pytest.mark.parametrize("mutate_fig", [True, False])
def test_grva009_originals_maintained_for_responsive_override(mutate_fig, dash_dcc):
# In #905 we made changes to prevent shapes from being lost.
# This test makes sure that the overrides applied by the `responsive`
# prop are "undone" when the `responsive` prop changes.

app = dash.Dash(__name__)

graph = dcc.Graph(
id="graph",
figure={"data": [{"y": [1, 2]}], "layout": {"width": 300, "height": 250}},
style={"height": "400px", "width": "500px"},
)
responsive_size = [500, 400]
fixed_size = [300, 250]

app.layout = html.Div(
[
graph,
html.Br(),
html.Button(id="edit_figure", children="Edit figure"),
html.Button(id="edit_responsive", children="Edit responsive"),
html.Div(id="output", children=""),
]
)

if mutate_fig:
# Modify the layout in place (which still has changes made by responsive)
change_fig = """
figure.layout.title = {text: String(n_fig || 0)};
const new_figure = {...figure};
"""
else:
# Or create a new one each time
change_fig = """
const new_figure = {
data: [{y: [1, 2]}],
layout: {width: 300, height: 250, title: {text: String(n_fig || 0)}}
};
"""

callback = (
"""
function clone_figure(n_fig, n_resp, figure) {
"""
+ change_fig
+ """
let responsive = [true, false, 'auto'][(n_resp || 0) % 3];
return [new_figure, responsive, (n_fig || 0) + ' ' + responsive];
}
"""
)

app.clientside_callback(
callback,
Output("graph", "figure"),
Output("graph", "responsive"),
Output("output", "children"),
Input("edit_figure", "n_clicks"),
Input("edit_responsive", "n_clicks"),
State("graph", "figure"),
)

dash_dcc.start_server(app)
edit_figure = dash_dcc.wait_for_element("#edit_figure")
edit_responsive = dash_dcc.wait_for_element("#edit_responsive")

def graph_dims():
return dash_dcc.driver.execute_script(
"""
const layout = document.querySelector('.js-plotly-plot')._fullLayout;
return [layout.width, layout.height];
"""
)

dash_dcc.wait_for_text_to_equal("#output", "0 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "0")
assert graph_dims() == responsive_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "1 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "1")
assert graph_dims() == responsive_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "1 false")
dash_dcc.wait_for_text_to_equal(".gtitle", "1")
assert graph_dims() == fixed_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "2 false")
dash_dcc.wait_for_text_to_equal(".gtitle", "2")
assert graph_dims() == fixed_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "2 auto")
dash_dcc.wait_for_text_to_equal(".gtitle", "2")
assert graph_dims() == fixed_size

edit_figure.click()
dash_dcc.wait_for_text_to_equal("#output", "3 auto")
dash_dcc.wait_for_text_to_equal(".gtitle", "3")
assert graph_dims() == fixed_size

edit_responsive.click()
dash_dcc.wait_for_text_to_equal("#output", "3 true")
dash_dcc.wait_for_text_to_equal(".gtitle", "3")
assert graph_dims() == responsive_size