Skip to content

Commit 6cbac0e

Browse files
authored
Merge pull request #2630 from plotly/feature/layout-hooks
Feature: Add hooks for layout "pre" and "post"
2 parents cc7fde3 + e22bc10 commit 6cbac0e

File tree

9 files changed

+80
-8
lines changed

9 files changed

+80
-8
lines changed

CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ This project adheres to [Semantic Versioning](https://semver.org/).
77

88
- [#2610](https://github.com/plotly/dash/pull/2610) Load plotly.js bundle/version from plotly.py
99

10+
## Added
11+
12+
- [#2630](https://github.com/plotly/dash/pull/2630) New layout hooks in the renderer
13+
14+
1015
## [2.12.1] - 2023-08-16
1116

1217
## Fixed

dash/_utils.py

+10
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
import string
1313
from html import escape
1414
from functools import wraps
15+
from typing import Union
16+
from dash.types import RendererHooks
1517

1618
logger = logging.getLogger()
1719

@@ -267,3 +269,11 @@ def coerce_to_list(obj):
267269

268270
def clean_property_name(name: str):
269271
return name.split("@")[0]
272+
273+
274+
def hooks_to_js_object(hooks: Union[RendererHooks, None]) -> str:
275+
if hooks is None:
276+
return ""
277+
hook_str = ",".join(f"{key}: {val}" for key, val in hooks.items())
278+
279+
return f"{{{hook_str}}}"

dash/dash-renderer/src/APIController.react.js

+9
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,21 @@ function storeEffect(props, events, setErrorLoading) {
131131
dispatch,
132132
error,
133133
graphs,
134+
hooks,
134135
layout,
135136
layoutRequest
136137
} = props;
137138

138139
if (isEmpty(layoutRequest)) {
140+
if (typeof hooks.layout_pre === 'function') {
141+
hooks.layout_pre();
142+
}
139143
dispatch(apiThunk('_dash-layout', 'GET', 'layoutRequest'));
140144
} else if (layoutRequest.status === STATUS.OK) {
141145
if (isEmpty(layout)) {
146+
if (typeof hooks.layout_post === 'function') {
147+
hooks.layout_post(layoutRequest.content);
148+
}
142149
const finalLayout = applyPersistence(
143150
layoutRequest.content,
144151
dispatch
@@ -198,6 +205,7 @@ UnconnectedContainer.propTypes = {
198205
dispatch: PropTypes.func,
199206
dependenciesRequest: PropTypes.object,
200207
graphs: PropTypes.object,
208+
hooks: PropTypes.object,
201209
layoutRequest: PropTypes.object,
202210
layout: PropTypes.object,
203211
loadingMap: PropTypes.any,
@@ -211,6 +219,7 @@ const Container = connect(
211219
state => ({
212220
appLifecycle: state.appLifecycle,
213221
dependenciesRequest: state.dependenciesRequest,
222+
hooks: state.hooks,
214223
layoutRequest: state.layoutRequest,
215224
layout: state.layout,
216225
loadingMap: state.loadingMap,

dash/dash-renderer/src/AppContainer.react.js

+2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ class UnconnectedAppContainer extends React.Component {
1313
constructor(props) {
1414
super(props);
1515
if (
16+
props.hooks.layout_pre !== null ||
17+
props.hooks.layout_post !== null ||
1618
props.hooks.request_pre !== null ||
1719
props.hooks.request_post !== null ||
1820
props.hooks.callback_resolved !== null ||

dash/dash-renderer/src/AppProvider.react.tsx

+4
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ const AppProvider = ({hooks}: any) => {
1616

1717
AppProvider.propTypes = {
1818
hooks: PropTypes.shape({
19+
layout_pre: PropTypes.func,
20+
layout_post: PropTypes.func,
1921
request_pre: PropTypes.func,
2022
request_post: PropTypes.func,
2123
callback_resolved: PropTypes.func,
@@ -25,6 +27,8 @@ AppProvider.propTypes = {
2527

2628
AppProvider.defaultProps = {
2729
hooks: {
30+
layout_pre: null,
31+
layout_post: null,
2832
request_pre: null,
2933
request_post: null,
3034
callback_resolved: null,

dash/dash-renderer/src/reducers/hooks.js

+2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
const customHooks = (
22
state = {
3+
layout_pre: null,
4+
layout_post: null,
35
request_pre: null,
46
request_post: null,
57
callback_resolved: null,

dash/dash.py

+9-6
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import base64
1717
import traceback
1818
from urllib.parse import urlparse
19+
from typing import Union
1920

2021
import flask
2122

@@ -52,6 +53,7 @@
5253
to_json,
5354
convert_to_AttributeDict,
5455
gen_salt,
56+
hooks_to_js_object,
5557
)
5658
from . import _callback
5759
from . import _get_paths
@@ -70,6 +72,7 @@
7072
_import_layouts_from_pages,
7173
)
7274
from ._jupyter import jupyter_dash, JupyterDisplayMode
75+
from .types import RendererHooks
7376

7477
# Add explicit mapping for map files
7578
mimetypes.add_type("application/json", ".map", True)
@@ -134,7 +137,6 @@
134137

135138

136139
def _get_traceback(secret, error: Exception):
137-
138140
try:
139141
# pylint: disable=import-outside-toplevel
140142
from werkzeug.debug import tbtools
@@ -339,6 +341,10 @@ class Dash:
339341
340342
:param add_log_handler: Automatically add a StreamHandler to the app logger
341343
if not added previously.
344+
345+
:param hooks: Extend Dash renderer functionality by passing a dictionary of
346+
javascript functions. To hook into the layout, use dict keys "layout_pre" and
347+
"layout_post". To hook into the callbacks, use keys "request_pre" and "request_post"
342348
"""
343349

344350
_plotlyjs_url: str
@@ -375,6 +381,7 @@ def __init__( # pylint: disable=too-many-statements
375381
long_callback_manager=None,
376382
background_callback_manager=None,
377383
add_log_handler=True,
384+
hooks: Union[RendererHooks, None] = None,
378385
**obsolete,
379386
):
380387
_validate.check_obsolete(obsolete)
@@ -468,7 +475,7 @@ def __init__( # pylint: disable=too-many-statements
468475
self._favicon = None
469476

470477
# default renderer string
471-
self.renderer = "var renderer = new DashRenderer();"
478+
self.renderer = f"var renderer = new DashRenderer({hooks_to_js_object(hooks)});"
472479

473480
# static files from the packages
474481
self.css = Css(serve_locally)
@@ -1327,7 +1334,6 @@ def _setup_server(self):
13271334

13281335
# Copy over global callback data structures assigned with `dash.callback`
13291336
for k in list(_callback.GLOBAL_CALLBACK_MAP):
1330-
13311337
if k in self.callback_map:
13321338
raise DuplicateCallback(
13331339
f"The callback `{k}` provided with `dash.callback` was already "
@@ -1354,7 +1360,6 @@ def _setup_server(self):
13541360

13551361
if cancels:
13561362
for cancel_input, manager in cancels.items():
1357-
13581363
# pylint: disable=cell-var-from-loop
13591364
@self.callback(
13601365
Output(cancel_input.component_id, "id"),
@@ -1745,7 +1750,6 @@ def enable_dev_tools(
17451750
_reload.watch_thread.start()
17461751

17471752
if debug:
1748-
17491753
if jupyter_dash.active:
17501754
jupyter_dash.configure_callback_exception_handling(
17511755
self, dev_tools.prune_errors
@@ -1779,7 +1783,6 @@ def _after_request(response):
17791783
dash_total["dur"] = round((time.time() - dash_total["dur"]) * 1000)
17801784

17811785
for name, info in timing_information.items():
1782-
17831786
value = name
17841787
if info.get("desc") is not None:
17851788
value += f';desc="{info["desc"]}"'

dash/types.py

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from typing_extensions import TypedDict, NotRequired
2+
3+
4+
class RendererHooks(TypedDict):
5+
layout_pre: NotRequired[str]
6+
layout_post: NotRequired[str]
7+
request_pre: NotRequired[str]
8+
request_post: NotRequired[str]
9+
callback_resolved: NotRequired[str]
10+
request_refresh_jwt: NotRequired[str]

tests/integration/renderer/test_request_hooks.py

+29-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import pytest
55

66
from dash import Dash, Output, Input, html, dcc
7+
from dash.types import RendererHooks
78
from werkzeug.exceptions import HTTPException
89

910

@@ -115,7 +116,6 @@ def update_output(value):
115116

116117

117118
def test_rdrh002_with_custom_renderer_interpolated(dash_duo):
118-
119119
renderer = """
120120
<script id="_dash-renderer" type="application/javascript">
121121
console.log('firing up a custom renderer!')
@@ -198,7 +198,6 @@ def update_output(value):
198198

199199
@pytest.mark.parametrize("expiry_code", [401, 400])
200200
def test_rdrh003_refresh_jwt(expiry_code, dash_duo):
201-
202201
app = Dash(__name__)
203202

204203
app.index_string = """<!DOCTYPE html>
@@ -295,3 +294,31 @@ def wrap(*args, **kwargs):
295294
dash_duo.wait_for_text_to_equal("#output-token", "..")
296295

297296
assert len(dash_duo.get_logs()) == 2
297+
298+
299+
def test_rdrh004_layout_hooks(dash_duo):
300+
hooks: RendererHooks = {
301+
"layout_pre": """
302+
() => {
303+
var layoutPre = document.createElement('div');
304+
layoutPre.setAttribute('id', 'layout-pre');
305+
layoutPre.innerHTML = 'layout_pre generated this text';
306+
document.body.appendChild(layoutPre);
307+
}
308+
""",
309+
"layout_post": """
310+
(response) => {
311+
response.props.children = "layout_post generated this text";
312+
}
313+
""",
314+
}
315+
316+
app = Dash(__name__, hooks=hooks)
317+
app.layout = html.Div(id="layout")
318+
319+
dash_duo.start_server(app)
320+
321+
dash_duo.wait_for_text_to_equal("#layout-pre", "layout_pre generated this text")
322+
dash_duo.wait_for_text_to_equal("#layout", "layout_post generated this text")
323+
324+
assert dash_duo.get_logs() == []

0 commit comments

Comments
 (0)