From fa52955ed60419e46f6a4873c01e11536cf5193c Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Tue, 22 Aug 2023 10:43:53 -0600 Subject: [PATCH 1/7] Add hooks for layout "pre" and "post" --- dash/dash-renderer/src/APIController.react.js | 9 ++++ dash/dash-renderer/src/AppContainer.react.js | 2 + dash/dash-renderer/src/AppProvider.react.tsx | 4 ++ dash/dash-renderer/src/reducers/hooks.js | 2 + .../renderer/test_request_hooks.py | 43 +++++++++++++++++++ 5 files changed, 60 insertions(+) diff --git a/dash/dash-renderer/src/APIController.react.js b/dash/dash-renderer/src/APIController.react.js index 852d750a59..48f8e7c31a 100644 --- a/dash/dash-renderer/src/APIController.react.js +++ b/dash/dash-renderer/src/APIController.react.js @@ -123,14 +123,21 @@ function storeEffect(props, events, setErrorLoading) { dispatch, error, graphs, + hooks, layout, layoutRequest } = props; if (isEmpty(layoutRequest)) { + if (typeof hooks.layout_pre === 'function') { + hooks.layout_pre(); + } dispatch(apiThunk('_dash-layout', 'GET', 'layoutRequest')); } else if (layoutRequest.status === STATUS.OK) { if (isEmpty(layout)) { + if (typeof hooks.layout_post === 'function') { + hooks.layout_post(layoutRequest.content); + } const finalLayout = applyPersistence( layoutRequest.content, dispatch @@ -190,6 +197,7 @@ UnconnectedContainer.propTypes = { dispatch: PropTypes.func, dependenciesRequest: PropTypes.object, graphs: PropTypes.object, + hooks: PropTypes.object, layoutRequest: PropTypes.object, layout: PropTypes.object, loadingMap: PropTypes.any, @@ -203,6 +211,7 @@ const Container = connect( state => ({ appLifecycle: state.appLifecycle, dependenciesRequest: state.dependenciesRequest, + hooks: state.hooks, layoutRequest: state.layoutRequest, layout: state.layout, loadingMap: state.loadingMap, diff --git a/dash/dash-renderer/src/AppContainer.react.js b/dash/dash-renderer/src/AppContainer.react.js index 85ae475578..56573773d6 100644 --- a/dash/dash-renderer/src/AppContainer.react.js +++ b/dash/dash-renderer/src/AppContainer.react.js @@ -13,6 +13,8 @@ class UnconnectedAppContainer extends React.Component { constructor(props) { super(props); if ( + props.hooks.layout_pre !== null || + props.hooks.layout_post !== null || props.hooks.request_pre !== null || props.hooks.request_post !== null || props.hooks.callback_resolved !== null || diff --git a/dash/dash-renderer/src/AppProvider.react.tsx b/dash/dash-renderer/src/AppProvider.react.tsx index aaf09d59e4..88438067b0 100644 --- a/dash/dash-renderer/src/AppProvider.react.tsx +++ b/dash/dash-renderer/src/AppProvider.react.tsx @@ -16,6 +16,8 @@ const AppProvider = ({hooks}: any) => { AppProvider.propTypes = { hooks: PropTypes.shape({ + layout_pre: PropTypes.func, + layout_post: PropTypes.func, request_pre: PropTypes.func, request_post: PropTypes.func, callback_resolved: PropTypes.func, @@ -25,6 +27,8 @@ AppProvider.propTypes = { AppProvider.defaultProps = { hooks: { + layout_pre: null, + layout_post: null, request_pre: null, request_post: null, callback_resolved: null, diff --git a/dash/dash-renderer/src/reducers/hooks.js b/dash/dash-renderer/src/reducers/hooks.js index 83370efb71..176151c0a4 100644 --- a/dash/dash-renderer/src/reducers/hooks.js +++ b/dash/dash-renderer/src/reducers/hooks.js @@ -1,5 +1,7 @@ const customHooks = ( state = { + layout_pre: null, + layout_post: null, request_pre: null, request_post: null, callback_resolved: null, diff --git a/tests/integration/renderer/test_request_hooks.py b/tests/integration/renderer/test_request_hooks.py index ea2bede020..9d98967839 100644 --- a/tests/integration/renderer/test_request_hooks.py +++ b/tests/integration/renderer/test_request_hooks.py @@ -295,3 +295,46 @@ def wrap(*args, **kwargs): dash_duo.wait_for_text_to_equal("#output-token", "..") assert len(dash_duo.get_logs()) == 2 + + +def test_rdrh004_layout_hooks(dash_duo): + app = Dash(__name__) + + app.index_string = """ + + + {%metas%} + {%title%} + {%favicon%} + {%css%} + + + {%app_entry%} + + + """ + + app.layout = html.Div(id="layout") + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#layout-pre", "layout_pre generated this text") + dash_duo.wait_for_text_to_equal("#layout", "layout_post generated this text") + + assert dash_duo.get_logs() == [] From 8331d26f06768862cbe609ae67efedcdab670c91 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Tue, 22 Aug 2023 12:42:45 -0600 Subject: [PATCH 2/7] Update CHANGELOG --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8d4a9e2c9..903a77b31d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). +## UNRELEASED + +## Added + +- [#2630](https://github.com/plotly/dash/pull/2630) New layout hooks in the renderer + + ## [2.12.1] - 2023-08-16 ## Fixed From 8a61bf5b6c712126f7538dc139000ee8aa63c940 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Tue, 22 Aug 2023 12:58:54 -0600 Subject: [PATCH 3/7] Refactor test to modify only the renderer instead of the whole index_string --- .../renderer/test_request_hooks.py | 44 ++++++------------- 1 file changed, 13 insertions(+), 31 deletions(-) diff --git a/tests/integration/renderer/test_request_hooks.py b/tests/integration/renderer/test_request_hooks.py index 9d98967839..fc3e1f09bf 100644 --- a/tests/integration/renderer/test_request_hooks.py +++ b/tests/integration/renderer/test_request_hooks.py @@ -115,7 +115,6 @@ def update_output(value): def test_rdrh002_with_custom_renderer_interpolated(dash_duo): - renderer = """ - - - """ + app.renderer = """ + new DashRenderer({ + layout_pre: () => { + var layoutPre = document.createElement('div'); + layoutPre.setAttribute('id', 'layout-pre'); + layoutPre.innerHTML = 'layout_pre generated this text'; + document.body.appendChild(layoutPre); + }, + layout_post: (response) => { + response.props.children = "layout_post generated this text"; + } + }) + """ app.layout = html.Div(id="layout") From a2c294f967d6a8652cd6bf0fbce50a0af95e9c2c Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Wed, 23 Aug 2023 14:27:07 -0600 Subject: [PATCH 4/7] Add ability to pass renderer hooks as a constructor argument --- dash/_utils.py | 16 ++++++++++++++- dash/dash.py | 11 +++++----- dash/types.py | 10 ++++++++++ .../renderer/test_request_hooks.py | 20 ++++++++++--------- 4 files changed, 41 insertions(+), 16 deletions(-) create mode 100644 dash/types.py diff --git a/dash/_utils.py b/dash/_utils.py index bd1b7a21b4..9d90d6ac1d 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -11,7 +11,9 @@ import secrets import string from html import escape -from functools import wraps +from functools import wraps, reduce +from typing import Union +from dash.types import RendererHooks logger = logging.getLogger() @@ -267,3 +269,15 @@ def coerce_to_list(obj): def clean_property_name(name: str): return name.split("@")[0] + + +def hooks_to_js_object(hooks: Union[RendererHooks, None]) -> str: + if hooks is None: + return "" + hook_str = reduce( + lambda reduced, hook: f"{reduced}{hook[0]}: {hook[1]},", + hooks.items(), + "", + ) + + return f"{{{hook_str}}}" diff --git a/dash/dash.py b/dash/dash.py index 5ffca55dbd..6186ae40d7 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -16,6 +16,7 @@ import base64 import traceback from urllib.parse import urlparse +from typing import Union import flask @@ -52,6 +53,7 @@ to_json, convert_to_AttributeDict, gen_salt, + hooks_to_js_object, ) from . import _callback from . import _get_paths @@ -70,6 +72,7 @@ _import_layouts_from_pages, ) from ._jupyter import jupyter_dash, JupyterDisplayMode +from .types import RendererHooks # Add explicit mapping for map files mimetypes.add_type("application/json", ".map", True) @@ -134,7 +137,6 @@ def _get_traceback(secret, error: Exception): - try: # pylint: disable=import-outside-toplevel from werkzeug.debug import tbtools @@ -373,6 +375,7 @@ def __init__( # pylint: disable=too-many-statements long_callback_manager=None, background_callback_manager=None, add_log_handler=True, + hooks: Union[RendererHooks, None] = None, **obsolete, ): _validate.check_obsolete(obsolete) @@ -466,7 +469,7 @@ def __init__( # pylint: disable=too-many-statements self._favicon = None # default renderer string - self.renderer = "var renderer = new DashRenderer();" + self.renderer = f"var renderer = new DashRenderer({hooks_to_js_object(hooks)});" # static files from the packages self.css = Css(serve_locally) @@ -1301,7 +1304,6 @@ def _setup_server(self): # Copy over global callback data structures assigned with `dash.callback` for k in list(_callback.GLOBAL_CALLBACK_MAP): - if k in self.callback_map: raise DuplicateCallback( f"The callback `{k}` provided with `dash.callback` was already " @@ -1328,7 +1330,6 @@ def _setup_server(self): if cancels: for cancel_input, manager in cancels.items(): - # pylint: disable=cell-var-from-loop @self.callback( Output(cancel_input.component_id, "id"), @@ -1719,7 +1720,6 @@ def enable_dev_tools( _reload.watch_thread.start() if debug: - if jupyter_dash.active: jupyter_dash.configure_callback_exception_handling( self, dev_tools.prune_errors @@ -1753,7 +1753,6 @@ def _after_request(response): dash_total["dur"] = round((time.time() - dash_total["dur"]) * 1000) for name, info in timing_information.items(): - value = name if info.get("desc") is not None: value += f';desc="{info["desc"]}"' diff --git a/dash/types.py b/dash/types.py new file mode 100644 index 0000000000..c954f654bb --- /dev/null +++ b/dash/types.py @@ -0,0 +1,10 @@ +from typing_extensions import TypedDict, NotRequired + + +class RendererHooks(TypedDict): + layout_pre: NotRequired[str] + layout_post: NotRequired[str] + request_pre: NotRequired[str] + request_post: NotRequired[str] + callback_resolved: NotRequired[str] + request_refresh_jwt: NotRequired[str] diff --git a/tests/integration/renderer/test_request_hooks.py b/tests/integration/renderer/test_request_hooks.py index fc3e1f09bf..9c4febec12 100644 --- a/tests/integration/renderer/test_request_hooks.py +++ b/tests/integration/renderer/test_request_hooks.py @@ -4,6 +4,7 @@ import pytest from dash import Dash, Output, Input, html, dcc +from dash.types import RendererHooks from werkzeug.exceptions import HTTPException @@ -296,22 +297,23 @@ def wrap(*args, **kwargs): def test_rdrh004_layout_hooks(dash_duo): - app = Dash(__name__) - - app.renderer = """ - new DashRenderer({ - layout_pre: () => { + hooks: RendererHooks = { + "layout_pre": """ + () => { var layoutPre = document.createElement('div'); layoutPre.setAttribute('id', 'layout-pre'); layoutPre.innerHTML = 'layout_pre generated this text'; document.body.appendChild(layoutPre); - }, - layout_post: (response) => { + } + """, + "layout_post": """ + (response) => { response.props.children = "layout_post generated this text"; } - }) - """ + """, + } + app = Dash(__name__, hooks=hooks) app.layout = html.Div(id="layout") dash_duo.start_server(app) From 4412ec5dadcc68654a58c6f3a71c3f91ae1530c5 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Fri, 25 Aug 2023 13:19:25 -0600 Subject: [PATCH 5/7] Document new hooks constructor arg --- dash/dash.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dash/dash.py b/dash/dash.py index 6186ae40d7..922cf79322 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -341,6 +341,10 @@ class Dash: :param add_log_handler: Automatically add a StreamHandler to the app logger if not added previously. + + :param hooks: Extend Dash renderer functionality by passing a dictionary of + javascript functions. To hook into the layout, use dict keys "layout_pre" and + "layout_post". To hook into the callbacks, use keys "request_pre" and "request_post" """ def __init__( # pylint: disable=too-many-statements From 5e3688b39d869d9c6d3d272fd96c3f5b4c5ce657 Mon Sep 17 00:00:00 2001 From: Adrian Borrmann Date: Fri, 25 Aug 2023 13:20:24 -0600 Subject: [PATCH 6/7] Address code review style feedback --- dash/_utils.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/dash/_utils.py b/dash/_utils.py index 9d90d6ac1d..d577c87997 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -11,7 +11,7 @@ import secrets import string from html import escape -from functools import wraps, reduce +from functools import wraps from typing import Union from dash.types import RendererHooks @@ -274,10 +274,6 @@ def clean_property_name(name: str): def hooks_to_js_object(hooks: Union[RendererHooks, None]) -> str: if hooks is None: return "" - hook_str = reduce( - lambda reduced, hook: f"{reduced}{hook[0]}: {hook[1]},", - hooks.items(), - "", - ) + hook_str = ",".join(f"{key}: {val}" for key, val in hooks.items()) return f"{{{hook_str}}}" From e22bc106991574561a70965c0fd41ac86fa9d017 Mon Sep 17 00:00:00 2001 From: Alex Johnson Date: Fri, 25 Aug 2023 17:00:40 -0400 Subject: [PATCH 7/7] Update CHANGELOG.md --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3faaca9d5..c61ff91f77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,6 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#2610](https://github.com/plotly/dash/pull/2610) Load plotly.js bundle/version from plotly.py -## UNRELEASED - ## Added - [#2630](https://github.com/plotly/dash/pull/2630) New layout hooks in the renderer