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

Routing callback inputs #2647

Merged
merged 10 commits into from
Oct 6, 2023
Merged
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).

## [UNRELEASED]

## Fixed
## Fixed

- [#2634](https://github.com/plotly/dash/pull/2634) Fix deprecation warning on pkg_resources, fix [#2631](https://github.com/plotly/dash/issues/2631)

Expand All @@ -25,6 +25,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
## Added

- [#2630](https://github.com/plotly/dash/pull/2630) New layout hooks in the renderer
- [#2647](https://github.com/plotly/dash/pull/2647) `routing_callback_inputs` allowing to pass more Input and/or State arguments to the pages routing callback


## [2.12.1] - 2023-08-16
Expand Down
34 changes: 25 additions & 9 deletions dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import base64
import traceback
from urllib.parse import urlparse
from typing import Union
from typing import Dict, Optional, Union

import flask

Expand All @@ -29,8 +29,9 @@
from .fingerprint import build_fingerprint, check_fingerprint
from .resources import Scripts, Css
from .dependencies import (
Output,
Input,
Output,
State,
)
from .development.base_component import ComponentRegistry
from .exceptions import (
Expand Down Expand Up @@ -347,6 +348,12 @@ class Dash:
: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"

:param routing_callback_inputs: When using Dash pages (use_pages=True), allows to
add new States to the routing callback, to pass additional data to the layout
functions. The syntax for this parameter is a dict of State objects:
`routing_callback_inputs={"language": Input("language", "value")}`
NOTE: the keys "pathname_" and "search_" are reserved for internal use.
"""

_plotlyjs_url: str
Expand Down Expand Up @@ -384,6 +391,7 @@ def __init__( # pylint: disable=too-many-statements
background_callback_manager=None,
add_log_handler=True,
hooks: Union[RendererHooks, None] = None,
routing_callback_inputs: Optional[Dict[str, Union[Input, State]]] = None,
**obsolete,
):
_validate.check_obsolete(obsolete)
Expand Down Expand Up @@ -461,6 +469,7 @@ def __init__( # pylint: disable=too-many-statements

self.pages_folder = str(pages_folder)
self.use_pages = (pages_folder != "pages") if use_pages is None else use_pages
self.routing_callback_inputs = routing_callback_inputs or {}

# keep title as a class property for backwards compatibility
self.title = title
Expand Down Expand Up @@ -2078,21 +2087,28 @@ def router():
return
self._got_first_request["pages"] = True

inputs = {
"pathname_": Input(_ID_LOCATION, "pathname"),
"search_": Input(_ID_LOCATION, "search"),
}
inputs.update(self.routing_callback_inputs)

@self.callback(
Output(_ID_CONTENT, "children"),
Output(_ID_STORE, "data"),
Input(_ID_LOCATION, "pathname"),
Input(_ID_LOCATION, "search"),
inputs=inputs,
prevent_initial_call=True,
)
def update(pathname, search):
def update(pathname_, search_, **states):
"""
Updates dash.page_container layout on page navigation.
Updates the stored page title which will trigger the clientside callback to update the app title
"""

query_parameters = _parse_query_string(search)
page, path_variables = _path_to_page(self.strip_relative_path(pathname))
query_parameters = _parse_query_string(search_)
page, path_variables = _path_to_page(
self.strip_relative_path(pathname_)
)

# get layout
if page == {}:
Expand All @@ -2110,9 +2126,9 @@ def update(pathname, search):

if callable(layout):
layout = (
layout(**path_variables, **query_parameters)
layout(**path_variables, **query_parameters, **states)
if path_variables
else layout(**query_parameters)
else layout(**query_parameters, **states)
)
if callable(title):
title = title(**path_variables) if path_variables else title()
Expand Down
1 change: 1 addition & 0 deletions requires-install.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ dash_html_components==2.0.0
dash_core_components==2.0.0
dash_table==5.0.0
importlib-metadata==4.8.3;python_version<"3.7"
importlib-metadata;python_version>="3.7"
contextvars==2.4;python_version<"3.7"
typing_extensions>=4.1.1
requests
Expand Down
96 changes: 76 additions & 20 deletions tests/integration/multi_page/test_pages_layout.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import pytest
import dash
from dash import Dash, dcc, html
from dash import Dash, Input, State, dcc, html
from dash.dash import _ID_LOCATION
from dash.exceptions import NoLayoutException


Expand Down Expand Up @@ -59,38 +60,33 @@ def test_pala001_layout(dash_duo, clear_pages_state):
assert dash_duo.driver.title == page["title"], "check that page title updates"

# test redirects
dash_duo.wait_for_page(url=f"http://localhost:{dash_duo.server.port}/v2")
dash_duo.wait_for_page(url=f"{dash_duo.server_url}/v2")
dash_duo.wait_for_text_to_equal("#text_redirect", "text for redirect")
dash_duo.wait_for_page(url=f"http://localhost:{dash_duo.server.port}/old-home-page")
dash_duo.wait_for_page(url=f"{dash_duo.server_url}/old-home-page")
dash_duo.wait_for_text_to_equal("#text_redirect", "text for redirect")
assert (
dash_duo.driver.current_url
== f"http://localhost:{dash_duo.server.port}/redirect"
)
assert dash_duo.driver.current_url == f"{dash_duo.server_url}/redirect"

# test redirect with button and user defined dcc.Location
# note: dcc.Location must be defined in app.py
dash_duo.wait_for_page(url=f"http://localhost:{dash_duo.server.port}/page1")
dash_duo.wait_for_page(url=f"{dash_duo.server_url}/page1")
dash_duo.find_element("#btn1").click()
dash_duo.wait_for_text_to_equal("#text_page2", "text for page2")

# test query strings
dash_duo.wait_for_page(
url=f"http://localhost:{dash_duo.server.port}/query-string?velocity=10"
)
dash_duo.wait_for_page(url=f"{dash_duo.server_url}/query-string?velocity=10")
assert (
dash_duo.find_element("#velocity").get_attribute("value") == "10"
), "query string passed to layout"

# test path variables
dash_duo.wait_for_page(url=f"http://localhost:{dash_duo.server.port}/a/none/b/none")
dash_duo.wait_for_page(url=f"{dash_duo.server_url}/a/none/b/none")
dash_duo.wait_for_text_to_equal("#path_vars", "variables from pathname:none none")

dash_duo.wait_for_page(url=f"http://localhost:{dash_duo.server.port}/a/var1/b/var2")
dash_duo.wait_for_page(url=f"{dash_duo.server_url}/a/var1/b/var2")
dash_duo.wait_for_text_to_equal("#path_vars", "variables from pathname:var1 var2")

# test page not found
dash_duo.wait_for_page(url=f"http://localhost:{dash_duo.server.port}/find_me")
dash_duo.wait_for_page(url=f"{dash_duo.server_url}/find_me")
dash_duo.wait_for_text_to_equal("#text_not_found_404", "text for not_found_404")

# test `validation_layout` exists when suppress_callback_exceptions=False`
Expand Down Expand Up @@ -121,20 +117,20 @@ def test_pala002_meta_tags_default(dash_duo, clear_pages_state):
{"property": "twitter:card", "content": "summary_large_image"},
{
"property": "twitter:url",
"content": f"http://localhost:{dash_duo.server.port}/",
"content": f"{dash_duo.server_url}/",
},
{"property": "twitter:title", "content": "Multi layout2"},
{"property": "twitter:description", "content": ""},
{
"property": "twitter:image",
"content": f"http://localhost:{dash_duo.server.port}/assets/app.jpeg",
"content": f"{dash_duo.server_url}/assets/app.jpeg",
},
{"property": "og:title", "content": "Multi layout2"},
{"property": "og:type", "content": "website"},
{"property": "og:description", "content": ""},
{
"property": "og:image",
"content": f"http://localhost:{dash_duo.server.port}/assets/app.jpeg",
"content": f"{dash_duo.server_url}/assets/app.jpeg",
},
]

Expand All @@ -149,7 +145,7 @@ def test_pala003_meta_tags_custom(dash_duo, clear_pages_state):
{"property": "twitter:card", "content": "summary_large_image"},
{
"property": "twitter:url",
"content": f"http://localhost:{dash_duo.server.port}/",
"content": f"{dash_duo.server_url}/",
},
{"property": "twitter:title", "content": "Supplied Title"},
{
Expand All @@ -158,14 +154,14 @@ def test_pala003_meta_tags_custom(dash_duo, clear_pages_state):
},
{
"property": "twitter:image",
"content": f"http://localhost:{dash_duo.server.port}/assets/birds.jpeg",
"content": f"{dash_duo.server_url}/assets/birds.jpeg",
},
{"property": "og:title", "content": "Supplied Title"},
{"property": "og:type", "content": "website"},
{"property": "og:description", "content": "This is the supplied description"},
{
"property": "og:image",
"content": f"http://localhost:{dash_duo.server.port}/assets/birds.jpeg",
"content": f"{dash_duo.server_url}/assets/birds.jpeg",
},
]

Expand All @@ -179,3 +175,63 @@ def test_pala004_no_layout_exception(clear_pages_state):
Dash(__name__, use_pages=True, pages_folder="pages_error")

assert error_msg in err.value.args[0]


def get_routing_inputs_app():
app = Dash(
__name__,
use_pages=True,
routing_callback_inputs={
"hash": State(_ID_LOCATION, "hash"),
"language": Input("language", "value"),
},
)
# Page with layout from a variable: should render and not be impacted
# by routing callback inputs
dash.register_page(
"home",
layout=html.Div("Home", id="contents"),
path="/",
)

# Page with a layout function, should see the routing callback inputs
# as keyword arguments
def layout1(hash: str = None, language: str = "en", **kwargs):
translations = {
"en": "Hash says: {}",
"fr": "Le hash dit: {}",
}
return html.Div(translations[language].format(hash), id="contents")

dash.register_page(
"function_layout",
path="/function-layout",
layout=layout1,
)
app.layout = html.Div(
[
dcc.Dropdown(id="language", options=["en", "fr"], value="en"),
dash.page_container,
]
)
return app


def test_pala005_routing_inputs(dash_duo, clear_pages_state):
dash_duo.start_server(get_routing_inputs_app())
dash_duo.wait_for_page(url=f"{dash_duo.server_url}#123")
dash_duo.wait_for_text_to_equal("#contents", "Home")
dash_duo.wait_for_page(url=f"{dash_duo.server_url}/")
dash_duo.wait_for_text_to_equal("#contents", "Home")
dash_duo.wait_for_page(url=f"{dash_duo.server_url}/function-layout")
dash_duo.wait_for_text_to_equal("#contents", "Hash says:")
# hash is a State therefore navigating to the same page with hash will not
# re-render the layout function
dash_duo.wait_for_page(url=f"{dash_duo.server_url}/function-layout#123")
dash_duo.wait_for_text_to_equal("#contents", "Hash says:")
# Refreshing the page re-runs the layout function
dash_duo.driver.refresh()
dash_duo.wait_for_text_to_equal("#contents", "Hash says: #123")
# Changing the language Input re-runs the layout function
dash_duo.select_dcc_dropdown("#language", "fr")
dash_duo.wait_for_text_to_equal("#contents", "Le hash dit: #123")