diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dc54a946e..ae39d92f32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) @@ -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 diff --git a/dash/dash.py b/dash/dash.py index 374689f781..7c0e38487c 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -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 @@ -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 ( @@ -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 @@ -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) @@ -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 @@ -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 == {}: @@ -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() diff --git a/requires-install.txt b/requires-install.txt index 0606e4cfd9..f69a115de3 100644 --- a/requires-install.txt +++ b/requires-install.txt @@ -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 diff --git a/tests/integration/multi_page/test_pages_layout.py b/tests/integration/multi_page/test_pages_layout.py index 32573aaf36..355ec50d40 100644 --- a/tests/integration/multi_page/test_pages_layout.py +++ b/tests/integration/multi_page/test_pages_layout.py @@ -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 @@ -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` @@ -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", }, ] @@ -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"}, { @@ -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", }, ] @@ -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")