diff --git a/CHANGELOG.md b/CHANGELOG.md index ada9aeee03..612ad09039 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Unreleased ### Added +- [#1343](https://github.com/plotly/dash/pull/1343) Add `title` parameter to set the +document title. This is the recommended alternative to setting app.title or overriding +the index HTML. - [#1315](https://github.com/plotly/dash/pull/1315) Add `update_title` parameter to set or disable the "Updating...." document title during updates. Closes [#856](https://github.com/plotly/dash/issues/856) and [#732](https://github.com/plotly/dash/issues/732) ## [1.13.4] - 2020-06-25 diff --git a/dash-renderer/src/components/core/DocumentTitle.react.js b/dash-renderer/src/components/core/DocumentTitle.react.js index 3bd03aba57..bfea61831e 100644 --- a/dash-renderer/src/components/core/DocumentTitle.react.js +++ b/dash-renderer/src/components/core/DocumentTitle.react.js @@ -7,16 +7,27 @@ class DocumentTitle extends Component { super(props); const {update_title} = props.config; this.state = { - initialTitle: document.title, + title: document.title, update_title, }; } UNSAFE_componentWillReceiveProps(props) { - if (this.state.update_title && props.isLoading) { - document.title = this.state.update_title; + if (!this.state.update_title) { + // Let callbacks or other components have full control over title + return; + } + if (props.isLoading) { + this.setState({title: document.title}); + if (this.state.update_title) { + document.title = this.state.update_title; + } } else { - document.title = this.state.initialTitle; + if (document.title === this.state.update_title) { + document.title = this.state.title; + } else { + this.setState({title: document.title}); + } } } diff --git a/dash/dash.py b/dash/dash.py index 3bcf626cd5..0785bafe7f 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -227,6 +227,15 @@ class Dash(object): with a ``plug`` method, taking a single argument: this app, which will be called after the Flask server is attached. :type plugins: list of objects + + :param title: Default ``Dash``. Configures the document.title + (the text that appears in a browser tab). + + :param update_title: Default ``Updating...``. Configures the document.title + (the text that appears in a browser tab) text when a callback is being run. + Set to None or '' if you don't want the document.title to change or if you + want to control the document.title through a separate component or + clientside callback. """ def __init__( @@ -252,6 +261,7 @@ def __init__( prevent_initial_callbacks=False, show_undo_redo=False, plugins=None, + title='Dash', update_title="Updating...", **obsolete ): @@ -300,6 +310,7 @@ def __init__( ), prevent_initial_callbacks=prevent_initial_callbacks, show_undo_redo=show_undo_redo, + title=title, update_title=update_title, ) self.config.set_read_only( @@ -321,6 +332,9 @@ def __init__( "via the Dash constructor" ) + # keep title as a class property for backwards compatability + self.title = title + # list of dependencies - this one is used by the back end for dispatching self.callback_map = {} # same deps as a list to catch duplicate outputs, and to send to the front end @@ -725,7 +739,9 @@ def index(self, *args, **kwargs): # pylint: disable=unused-argument config = self._generate_config_html() metas = self._generate_meta_html() renderer = self._generate_renderer() - title = getattr(self, "title", "Dash") + + # use self.title instead of app.config.title for backwards compatibility + title = self.title if self._favicon: favicon_mod_time = os.path.getmtime( diff --git a/requires-dev.txt b/requires-dev.txt index e27402850d..86dbb809c1 100644 --- a/requires-dev.txt +++ b/requires-dev.txt @@ -1,5 +1,6 @@ dash_flow_example==0.0.5 dash-dangerously-set-inner-html +isort==4.3.21 mock==4.0.1;python_version>="3.0" mock==3.0.5;python_version=="2.7" flake8==3.7.9 @@ -10,4 +11,4 @@ astroid==2.2.5;python_version=="3.7" black==19.10b0;python_version>="3.0" virtualenv==20.0.10;python_version=="2.7" fire==0.2.1 -coloredlogs==14.0 \ No newline at end of file +coloredlogs==14.0 diff --git a/tests/integration/renderer/test_loading_states.py b/tests/integration/renderer/test_loading_states.py index b946d72e33..3f3fb4dab5 100644 --- a/tests/integration/renderer/test_loading_states.py +++ b/tests/integration/renderer/test_loading_states.py @@ -172,15 +172,21 @@ def find_text(spec): @pytest.mark.parametrize( - "kwargs, expected_update_title", + "kwargs, expected_update_title, clientside_title", [ - ({}, "Updating..."), - ({"update_title": None}, "Dash"), - ({"update_title": ""}, "Dash"), - ({"update_title": "Hello World"}, "Hello World"), - ] + ({}, "Updating...", False), + ({"update_title": None}, "Dash", False), + ({"update_title": ""}, "Dash", False), + ({"update_title": "Hello World"}, "Hello World", False), + ({}, "Updating...", True), + ({"update_title": None}, "Dash", True), + ({"update_title": ""}, "Dash", True), + ({"update_title": "Hello World"}, "Hello World", True), + ], ) -def test_rdls003_update_title(dash_duo, kwargs, expected_update_title): +def test_rdls003_update_title( + dash_duo, kwargs, expected_update_title, clientside_title +): app = dash.Dash("Dash", **kwargs) lock = Lock() @@ -189,13 +195,23 @@ def test_rdls003_update_title(dash_duo, kwargs, expected_update_title): html.H3("Press button see document title updating"), html.Div(id="output"), html.Button("Update", id="button", n_clicks=0), + html.Button("Update Page", id="page", n_clicks=0), + html.Div(id="dummy"), ] ) + if clientside_title: + app.clientside_callback( + """ + function(n_clicks) { + document.title = 'Page ' + n_clicks; + return 'Page ' + n_clicks; + } + """, + Output("dummy", "children"), + [Input("page", "n_clicks")], + ) - @app.callback( - Output("output", "children"), - [Input("button", "n_clicks")] - ) + @app.callback(Output("output", "children"), [Input("button", "n_clicks")]) def update(n): with lock: return n @@ -203,12 +219,86 @@ def update(n): with lock: dash_duo.start_server(app) # check for update-title during startup - until(lambda: dash_duo.driver.title == expected_update_title, timeout=1) + # the clientside callback isn't blocking so it may update the title + if not clientside_title: + until(lambda: dash_duo.driver.title == expected_update_title, timeout=1) # check for original title after loading - until(lambda: dash_duo.driver.title == "Dash", timeout=1) + until(lambda: dash_duo.driver.title == "Page 0" if clientside_title else "Dash", timeout=1) with lock: dash_duo.find_element("#button").click() # check for update-title while processing callback - until(lambda: dash_duo.driver.title == expected_update_title, timeout=1) + if clientside_title and not kwargs.get('update_title', True): + until(lambda: dash_duo.driver.title == 'Page 0', timeout=1) + else: + until(lambda: dash_duo.driver.title == expected_update_title, timeout=1) + + if clientside_title: + dash_duo.find_element("#page").click() + dash_duo.wait_for_text_to_equal("#dummy", "Page 1") + until(lambda: dash_duo.driver.title == "Page 1", timeout=1) + + # verify that when a separate callback runs, the page title gets restored + dash_duo.find_element("#button").click() + dash_duo.wait_for_text_to_equal("#output", "2") + if clientside_title: + until(lambda: dash_duo.driver.title == "Page 1", timeout=1) + else: + until(lambda: dash_duo.driver.title == "Dash", timeout=1) + + +@pytest.mark.parametrize( + "update_title", + [ + None, + 'Custom Update Title', + ], +) +def test_rdls004_update_title_chained_callbacks(dash_duo, update_title): + initial_title = 'Initial Title' + app = dash.Dash("Dash", title=initial_title, update_title=update_title) + lock = Lock() + + app.layout = html.Div( + children=[ + html.Button(id="page-title", n_clicks=0, children='Page Title'), + html.Div(id="page-output"), + html.Div(id="final-output") + ] + ) + app.clientside_callback( + """ + function(n_clicks) { + if (n_clicks > 0) { + document.title = 'Page ' + n_clicks; + } + return n_clicks; + } + """, + Output("page-output", "children"), + [Input("page-title", "n_clicks")], + ) + + @app.callback( + Output("final-output", "children"), + [Input("page-output", "children")]) + def update(n): + with lock: + return n + + # check for original title after loading + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#final-output", "0") + until(lambda: dash_duo.driver.title == initial_title, timeout=1) + + with lock: + dash_duo.find_element("#page-title").click() + # check for update-title while processing the serverside callback + if update_title: + until(lambda: dash_duo.driver.title == update_title, timeout=1) + else: + until(lambda: dash_duo.driver.title == 'Page 1', timeout=1) + + dash_duo.wait_for_text_to_equal("#final-output", "1") + until(lambda: dash_duo.driver.title == 'Page 1', timeout=1) diff --git a/tests/unit/test_configs.py b/tests/unit/test_configs.py index 9a7c8b5c01..a507efd57e 100644 --- a/tests/unit/test_configs.py +++ b/tests/unit/test_configs.py @@ -314,3 +314,13 @@ def test_proxy_failure(mocker, empty_environ): ) assert "port: 8055 is incompatible with the proxy" in excinfo.exconly() assert "you must use port: 8155" in excinfo.exconly() + + +def test_title(): + app = Dash() + assert '