Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Add hooks for layout "pre" and "post" #2630

Merged
merged 9 commits into from
Aug 25, 2023
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ 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

## Added

- [#2630](https://github.com/plotly/dash/pull/2630) New layout hooks in the renderer


## [2.12.1] - 2023-08-16

## Fixed
Expand Down
10 changes: 10 additions & 0 deletions dash/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import string
from html import escape
from functools import wraps
from typing import Union
from dash.types import RendererHooks

logger = logging.getLogger()

Expand Down Expand Up @@ -267,3 +269,11 @@ 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 = ",".join(f"{key}: {val}" for key, val in hooks.items())

return f"{{{hook_str}}}"
9 changes: 9 additions & 0 deletions dash/dash-renderer/src/APIController.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,14 +131,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
Expand Down Expand Up @@ -198,6 +205,7 @@ UnconnectedContainer.propTypes = {
dispatch: PropTypes.func,
dependenciesRequest: PropTypes.object,
graphs: PropTypes.object,
hooks: PropTypes.object,
layoutRequest: PropTypes.object,
layout: PropTypes.object,
loadingMap: PropTypes.any,
Expand All @@ -211,6 +219,7 @@ const Container = connect(
state => ({
appLifecycle: state.appLifecycle,
dependenciesRequest: state.dependenciesRequest,
hooks: state.hooks,
layoutRequest: state.layoutRequest,
layout: state.layout,
loadingMap: state.loadingMap,
Expand Down
2 changes: 2 additions & 0 deletions dash/dash-renderer/src/AppContainer.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down
4 changes: 4 additions & 0 deletions dash/dash-renderer/src/AppProvider.react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,6 +27,8 @@ AppProvider.propTypes = {

AppProvider.defaultProps = {
hooks: {
layout_pre: null,
layout_post: null,
request_pre: null,
request_post: null,
callback_resolved: null,
Expand Down
2 changes: 2 additions & 0 deletions dash/dash-renderer/src/reducers/hooks.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
const customHooks = (
state = {
layout_pre: null,
layout_post: null,
request_pre: null,
request_post: null,
callback_resolved: null,
Expand Down
15 changes: 9 additions & 6 deletions dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import base64
import traceback
from urllib.parse import urlparse
from typing import Union

import flask

Expand Down Expand Up @@ -52,6 +53,7 @@
to_json,
convert_to_AttributeDict,
gen_salt,
hooks_to_js_object,
)
from . import _callback
from . import _get_paths
Expand All @@ -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)
Expand Down Expand Up @@ -134,7 +137,6 @@


def _get_traceback(secret, error: Exception):

try:
# pylint: disable=import-outside-toplevel
from werkzeug.debug import tbtools
Expand Down Expand Up @@ -339,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"
"""

_plotlyjs_url: str
Expand Down Expand Up @@ -375,6 +381,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)
Expand Down Expand Up @@ -468,7 +475,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)
Expand Down Expand Up @@ -1327,7 +1334,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 "
Expand All @@ -1354,7 +1360,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"),
Expand Down Expand Up @@ -1745,7 +1750,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
Expand Down Expand Up @@ -1779,7 +1783,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"]}"'
Expand Down
10 changes: 10 additions & 0 deletions dash/types.py
Original file line number Diff line number Diff line change
@@ -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]
31 changes: 29 additions & 2 deletions tests/integration/renderer/test_request_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import pytest

from dash import Dash, Output, Input, html, dcc
from dash.types import RendererHooks
from werkzeug.exceptions import HTTPException


Expand Down Expand Up @@ -115,7 +116,6 @@ def update_output(value):


def test_rdrh002_with_custom_renderer_interpolated(dash_duo):

renderer = """
<script id="_dash-renderer" type="application/javascript">
console.log('firing up a custom renderer!')
Expand Down Expand Up @@ -198,7 +198,6 @@ def update_output(value):

@pytest.mark.parametrize("expiry_code", [401, 400])
def test_rdrh003_refresh_jwt(expiry_code, dash_duo):

app = Dash(__name__)

app.index_string = """<!DOCTYPE html>
Expand Down Expand Up @@ -295,3 +294,31 @@ 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):
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) => {
response.props.children = "layout_post generated this text";
}
""",
}

app = Dash(__name__, hooks=hooks)
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() == []