diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a5a525b7c..86e4be2efa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [UNRELEASED] +### Added +- [#1201](https://github.com/plotly/dash/pull/1201) New attribute `app.validation_layout` allows you to create a multi-page app without `suppress_callback_exceptions=True` or layout function tricks. Set this to a component layout containing the superset of all IDs on all pages in your app. + +### Fixed +- [#1201](https://github.com/plotly/dash/pull/1201) Fixes [#1193](https://github.com/plotly/dash/issues/1193) - prior to Dash 1.11, you could use `flask.has_request_context() == False` inside an `app.layout` function to provide a special layout containing all IDs for validation purposes in a multi-page app. Dash 1.11 broke this when we moved most of this validation into the renderer. This change makes it work again. + ## [1.11.0] - 2020-04-10 ### Added - [#1103](https://github.com/plotly/dash/pull/1103) Pattern-matching IDs and callbacks. Component IDs can be dictionaries, and callbacks can reference patterns of components, using three different wildcards: `ALL`, `MATCH`, and `ALLSMALLER`, available from `dash.dependencies`. This lets you create components on demand, and have callbacks respond to any and all of them. To help with this, `dash.callback_context` gets three new entries: `outputs_list`, `inputs_list`, and `states_list`, which contain all the ids, properties, and except for the outputs, the property values from all matched components. diff --git a/dash-renderer/src/actions/dependencies.js b/dash-renderer/src/actions/dependencies.js index 02d046c2a8..8ae7a7faff 100644 --- a/dash-renderer/src/actions/dependencies.js +++ b/dash-renderer/src/actions/dependencies.js @@ -35,7 +35,7 @@ import { const mergeMax = mergeWith(Math.max); -import {getPath} from './paths'; +import {computePaths, getPath} from './paths'; import {crawlLayout} from './utils'; @@ -464,9 +464,17 @@ function wildcardOverlap({id, property}, objs) { } export function validateCallbacksToLayout(state_, dispatchError) { - const {config, graphs, layout, paths} = state_; - const {outputMap, inputMap, outputPatterns, inputPatterns} = graphs; + const {config, graphs, layout: layout_, paths: paths_} = state_; const validateIds = !config.suppress_callback_exceptions; + let layout, paths; + if (validateIds && config.validation_layout) { + layout = config.validation_layout; + paths = computePaths(layout, [], null, paths_.events); + } else { + layout = layout_; + paths = paths_; + } + const {outputMap, inputMap, outputPatterns, inputPatterns} = graphs; function tail(callbacks) { return ( diff --git a/dash/dash.py b/dash/dash.py index c9489fc85c..a31fbc22c2 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -331,7 +331,8 @@ def __init__( self.routes = [] self._layout = None - self._cached_layout = None + self._layout_is_function = False + self.validation_layout = None self._setup_dev_tools() self._hot_reload = AttributeDict( @@ -421,18 +422,48 @@ def layout(self): return self._layout def _layout_value(self): - if isinstance(self._layout, patch_collections_abc("Callable")): - self._cached_layout = self._layout() - else: - self._cached_layout = self._layout - return self._cached_layout + return self._layout() if self._layout_is_function else self._layout @layout.setter def layout(self, value): _validate.validate_layout_type(value) - self._cached_layout = None + self._layout_is_function = isinstance(value, patch_collections_abc("Callable")) self._layout = value + # for using flask.has_request_context() to deliver a full layout for + # validation inside a layout function - track if a user might be doing this. + if ( + self._layout_is_function + and not self.validation_layout + and not self.config.suppress_callback_exceptions + ): + + def simple_clone(c, children=None): + cls = type(c) + # in Py3 we can use the __init__ signature to reduce to just + # required args and id; in Py2 this doesn't work so we just + # empty out children. + sig = getattr(cls.__init__, "__signature__", None) + props = { + p: getattr(c, p) + for p in c._prop_names # pylint: disable=protected-access + if hasattr(c, p) + and ( + p == "id" or not sig or sig.parameters[p].default == c.REQUIRED + ) + } + if props.get("children", children): + props["children"] = children or [] + return cls(**props) + + layout_value = self._layout_value() + _validate.validate_layout(value, layout_value) + self.validation_layout = simple_clone( + # pylint: disable=protected-access + layout_value, + [simple_clone(c) for c in layout_value._traverse_ids()], + ) + @property def index_string(self): return self._index_string @@ -468,6 +499,9 @@ def _config(self): "interval": int(self._dev_tools.hot_reload_interval * 1000), "max_retry": self._dev_tools.hot_reload_max_retry, } + if self.validation_layout and not self.config.suppress_callback_exceptions: + config["validation_layout"] = self.validation_layout + return config def serve_reload_hash(self): @@ -602,7 +636,7 @@ def _generate_scripts_html(self): def _generate_config_html(self): return ''.format( - json.dumps(self._config()) + json.dumps(self._config(), cls=plotly.utils.PlotlyJSONEncoder) ) def _generate_renderer(self): diff --git a/dash/development/base_component.py b/dash/development/base_component.py index b68b359941..fbf73eb90c 100644 --- a/dash/development/base_component.py +++ b/dash/development/base_component.py @@ -293,11 +293,16 @@ def _traverse_with_paths(self): for p, t in i._traverse_with_paths(): yield "\n".join([list_path, p]), t - def __iter__(self): - """Yield IDs in the tree of children.""" + def _traverse_ids(self): + """Yield components with IDs in the tree of children.""" for t in self._traverse(): if isinstance(t, Component) and getattr(t, "id", None) is not None: - yield t.id + yield t + + def __iter__(self): + """Yield IDs in the tree of children.""" + for t in self._traverse_ids(): + yield t.id def __len__(self): """Return the number of items in the tree.""" diff --git a/tests/integration/devtools/test_callback_validation.py b/tests/integration/devtools/test_callback_validation.py index 08190c6dad..423b534ed8 100644 --- a/tests/integration/devtools/test_callback_validation.py +++ b/tests/integration/devtools/test_callback_validation.py @@ -1,3 +1,6 @@ +import flask +import pytest + import dash_core_components as dcc import dash_html_components as html from dash import Dash @@ -695,3 +698,124 @@ def c3(children): ] ] check_errors(dash_duo, specs) + + +def multipage_app(validation=False): + app = Dash(__name__, suppress_callback_exceptions=(validation == "suppress")) + + skeleton = html.Div( + [dcc.Location(id="url", refresh=False), html.Div(id="page-content")] + ) + + layout_index = html.Div( + [ + dcc.Link('Navigate to "/page-1"', id="index_p1", href="/page-1"), + dcc.Link('Navigate to "/page-2"', id="index_p2", href="/page-2"), + ] + ) + + layout_page_1 = html.Div( + [ + html.H2("Page 1"), + dcc.Input(id="input-1-state", type="text", value="Montreal"), + dcc.Input(id="input-2-state", type="text", value="Canada"), + html.Button(id="submit-button", n_clicks=0, children="Submit"), + html.Div(id="output-state"), + html.Br(), + dcc.Link('Navigate to "/"', id="p1_index", href="/"), + dcc.Link('Navigate to "/page-2"', id="p1_p2", href="/page-2"), + ] + ) + + layout_page_2 = html.Div( + [ + html.H2("Page 2"), + dcc.Input(id="page-2-input", value="LA"), + html.Div(id="page-2-display-value"), + html.Br(), + dcc.Link('Navigate to "/"', id="p2_index", href="/"), + dcc.Link('Navigate to "/page-1"', id="p2_p1", href="/page-1"), + ] + ) + + validation_layout = html.Div([skeleton, layout_index, layout_page_1, layout_page_2]) + + def validation_function(): + return skeleton if flask.has_request_context() else validation_layout + + app.layout = validation_function if validation == "function" else skeleton + if validation == "attribute": + app.validation_layout = validation_layout + + # Index callbacks + @app.callback(Output("page-content", "children"), [Input("url", "pathname")]) + def display_page(pathname): + if pathname == "/page-1": + return layout_page_1 + elif pathname == "/page-2": + return layout_page_2 + else: + return layout_index + + # Page 1 callbacks + @app.callback( + Output("output-state", "children"), + [Input("submit-button", "n_clicks")], + [State("input-1-state", "value"), State("input-2-state", "value")], + ) + def update_output(n_clicks, input1, input2): + return ( + "The Button has been pressed {} times," + 'Input 1 is "{}",' + 'and Input 2 is "{}"' + ).format(n_clicks, input1, input2) + + # Page 2 callbacks + @app.callback( + Output("page-2-display-value", "children"), [Input("page-2-input", "value")] + ) + def display_value(value): + print("display_value") + return 'You have selected "{}"'.format(value) + + return app + + +def test_dvcv014_multipage_errors(dash_duo): + app = multipage_app() + dash_duo.start_server(app, **debugging) + + specs = [ + [ + "ID not found in layout", + ['"page-2-input"', "page-2-display-value.children"], + ], + ["ID not found in layout", ['"submit-button"', "output-state.children"]], + [ + "ID not found in layout", + ['"page-2-display-value"', "page-2-display-value.children"], + ], + ["ID not found in layout", ['"output-state"', "output-state.children"]], + ] + check_errors(dash_duo, specs) + + +@pytest.mark.parametrize("validation", ("function", "attribute", "suppress")) +def test_dvcv015_multipage_validation_layout(validation, dash_duo): + app = multipage_app(validation) + dash_duo.start_server(app, **debugging) + + dash_duo.wait_for_text_to_equal("#index_p1", 'Navigate to "/page-1"') + dash_duo.find_element("#index_p1").click() + + dash_duo.find_element("#submit-button").click() + dash_duo.wait_for_text_to_equal( + "#output-state", + "The Button has been pressed 1 times," + 'Input 1 is "Montreal",and Input 2 is "Canada"', + ) + + dash_duo.find_element("#p1_p2").click() + dash_duo.wait_for_text_to_equal("#page-2-display-value", 'You have selected "LA"') + + assert not dash_duo.get_logs()