Skip to content

Commit d7d71ac

Browse files
authored
Merge pull request #1201 from plotly/1193-multipage-validation
1193 multipage validation
2 parents 65a1d5c + 00e6e73 commit d7d71ac

File tree

5 files changed

+192
-14
lines changed

5 files changed

+192
-14
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22
All notable changes to `dash` will be documented in this file.
33
This project adheres to [Semantic Versioning](http://semver.org/).
44

5+
## [UNRELEASED]
6+
### Added
7+
- [#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.
8+
9+
### Fixed
10+
- [#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.
11+
512
## [1.11.0] - 2020-04-10
613
### Added
714
- [#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.

dash-renderer/src/actions/dependencies.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import {
3535

3636
const mergeMax = mergeWith(Math.max);
3737

38-
import {getPath} from './paths';
38+
import {computePaths, getPath} from './paths';
3939

4040
import {crawlLayout} from './utils';
4141

@@ -464,9 +464,17 @@ function wildcardOverlap({id, property}, objs) {
464464
}
465465

466466
export function validateCallbacksToLayout(state_, dispatchError) {
467-
const {config, graphs, layout, paths} = state_;
468-
const {outputMap, inputMap, outputPatterns, inputPatterns} = graphs;
467+
const {config, graphs, layout: layout_, paths: paths_} = state_;
469468
const validateIds = !config.suppress_callback_exceptions;
469+
let layout, paths;
470+
if (validateIds && config.validation_layout) {
471+
layout = config.validation_layout;
472+
paths = computePaths(layout, [], null, paths_.events);
473+
} else {
474+
layout = layout_;
475+
paths = paths_;
476+
}
477+
const {outputMap, inputMap, outputPatterns, inputPatterns} = graphs;
470478

471479
function tail(callbacks) {
472480
return (

dash/dash.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,8 @@ def __init__(
331331
self.routes = []
332332

333333
self._layout = None
334-
self._cached_layout = None
334+
self._layout_is_function = False
335+
self.validation_layout = None
335336

336337
self._setup_dev_tools()
337338
self._hot_reload = AttributeDict(
@@ -421,18 +422,48 @@ def layout(self):
421422
return self._layout
422423

423424
def _layout_value(self):
424-
if isinstance(self._layout, patch_collections_abc("Callable")):
425-
self._cached_layout = self._layout()
426-
else:
427-
self._cached_layout = self._layout
428-
return self._cached_layout
425+
return self._layout() if self._layout_is_function else self._layout
429426

430427
@layout.setter
431428
def layout(self, value):
432429
_validate.validate_layout_type(value)
433-
self._cached_layout = None
430+
self._layout_is_function = isinstance(value, patch_collections_abc("Callable"))
434431
self._layout = value
435432

433+
# for using flask.has_request_context() to deliver a full layout for
434+
# validation inside a layout function - track if a user might be doing this.
435+
if (
436+
self._layout_is_function
437+
and not self.validation_layout
438+
and not self.config.suppress_callback_exceptions
439+
):
440+
441+
def simple_clone(c, children=None):
442+
cls = type(c)
443+
# in Py3 we can use the __init__ signature to reduce to just
444+
# required args and id; in Py2 this doesn't work so we just
445+
# empty out children.
446+
sig = getattr(cls.__init__, "__signature__", None)
447+
props = {
448+
p: getattr(c, p)
449+
for p in c._prop_names # pylint: disable=protected-access
450+
if hasattr(c, p)
451+
and (
452+
p == "id" or not sig or sig.parameters[p].default == c.REQUIRED
453+
)
454+
}
455+
if props.get("children", children):
456+
props["children"] = children or []
457+
return cls(**props)
458+
459+
layout_value = self._layout_value()
460+
_validate.validate_layout(value, layout_value)
461+
self.validation_layout = simple_clone(
462+
# pylint: disable=protected-access
463+
layout_value,
464+
[simple_clone(c) for c in layout_value._traverse_ids()],
465+
)
466+
436467
@property
437468
def index_string(self):
438469
return self._index_string
@@ -468,6 +499,9 @@ def _config(self):
468499
"interval": int(self._dev_tools.hot_reload_interval * 1000),
469500
"max_retry": self._dev_tools.hot_reload_max_retry,
470501
}
502+
if self.validation_layout and not self.config.suppress_callback_exceptions:
503+
config["validation_layout"] = self.validation_layout
504+
471505
return config
472506

473507
def serve_reload_hash(self):
@@ -602,7 +636,7 @@ def _generate_scripts_html(self):
602636

603637
def _generate_config_html(self):
604638
return '<script id="_dash-config" type="application/json">{}</script>'.format(
605-
json.dumps(self._config())
639+
json.dumps(self._config(), cls=plotly.utils.PlotlyJSONEncoder)
606640
)
607641

608642
def _generate_renderer(self):

dash/development/base_component.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -293,11 +293,16 @@ def _traverse_with_paths(self):
293293
for p, t in i._traverse_with_paths():
294294
yield "\n".join([list_path, p]), t
295295

296-
def __iter__(self):
297-
"""Yield IDs in the tree of children."""
296+
def _traverse_ids(self):
297+
"""Yield components with IDs in the tree of children."""
298298
for t in self._traverse():
299299
if isinstance(t, Component) and getattr(t, "id", None) is not None:
300-
yield t.id
300+
yield t
301+
302+
def __iter__(self):
303+
"""Yield IDs in the tree of children."""
304+
for t in self._traverse_ids():
305+
yield t.id
301306

302307
def __len__(self):
303308
"""Return the number of items in the tree."""

tests/integration/devtools/test_callback_validation.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import flask
2+
import pytest
3+
14
import dash_core_components as dcc
25
import dash_html_components as html
36
from dash import Dash
@@ -695,3 +698,124 @@ def c3(children):
695698
]
696699
]
697700
check_errors(dash_duo, specs)
701+
702+
703+
def multipage_app(validation=False):
704+
app = Dash(__name__, suppress_callback_exceptions=(validation == "suppress"))
705+
706+
skeleton = html.Div(
707+
[dcc.Location(id="url", refresh=False), html.Div(id="page-content")]
708+
)
709+
710+
layout_index = html.Div(
711+
[
712+
dcc.Link('Navigate to "/page-1"', id="index_p1", href="/page-1"),
713+
dcc.Link('Navigate to "/page-2"', id="index_p2", href="/page-2"),
714+
]
715+
)
716+
717+
layout_page_1 = html.Div(
718+
[
719+
html.H2("Page 1"),
720+
dcc.Input(id="input-1-state", type="text", value="Montreal"),
721+
dcc.Input(id="input-2-state", type="text", value="Canada"),
722+
html.Button(id="submit-button", n_clicks=0, children="Submit"),
723+
html.Div(id="output-state"),
724+
html.Br(),
725+
dcc.Link('Navigate to "/"', id="p1_index", href="/"),
726+
dcc.Link('Navigate to "/page-2"', id="p1_p2", href="/page-2"),
727+
]
728+
)
729+
730+
layout_page_2 = html.Div(
731+
[
732+
html.H2("Page 2"),
733+
dcc.Input(id="page-2-input", value="LA"),
734+
html.Div(id="page-2-display-value"),
735+
html.Br(),
736+
dcc.Link('Navigate to "/"', id="p2_index", href="/"),
737+
dcc.Link('Navigate to "/page-1"', id="p2_p1", href="/page-1"),
738+
]
739+
)
740+
741+
validation_layout = html.Div([skeleton, layout_index, layout_page_1, layout_page_2])
742+
743+
def validation_function():
744+
return skeleton if flask.has_request_context() else validation_layout
745+
746+
app.layout = validation_function if validation == "function" else skeleton
747+
if validation == "attribute":
748+
app.validation_layout = validation_layout
749+
750+
# Index callbacks
751+
@app.callback(Output("page-content", "children"), [Input("url", "pathname")])
752+
def display_page(pathname):
753+
if pathname == "/page-1":
754+
return layout_page_1
755+
elif pathname == "/page-2":
756+
return layout_page_2
757+
else:
758+
return layout_index
759+
760+
# Page 1 callbacks
761+
@app.callback(
762+
Output("output-state", "children"),
763+
[Input("submit-button", "n_clicks")],
764+
[State("input-1-state", "value"), State("input-2-state", "value")],
765+
)
766+
def update_output(n_clicks, input1, input2):
767+
return (
768+
"The Button has been pressed {} times,"
769+
'Input 1 is "{}",'
770+
'and Input 2 is "{}"'
771+
).format(n_clicks, input1, input2)
772+
773+
# Page 2 callbacks
774+
@app.callback(
775+
Output("page-2-display-value", "children"), [Input("page-2-input", "value")]
776+
)
777+
def display_value(value):
778+
print("display_value")
779+
return 'You have selected "{}"'.format(value)
780+
781+
return app
782+
783+
784+
def test_dvcv014_multipage_errors(dash_duo):
785+
app = multipage_app()
786+
dash_duo.start_server(app, **debugging)
787+
788+
specs = [
789+
[
790+
"ID not found in layout",
791+
['"page-2-input"', "page-2-display-value.children"],
792+
],
793+
["ID not found in layout", ['"submit-button"', "output-state.children"]],
794+
[
795+
"ID not found in layout",
796+
['"page-2-display-value"', "page-2-display-value.children"],
797+
],
798+
["ID not found in layout", ['"output-state"', "output-state.children"]],
799+
]
800+
check_errors(dash_duo, specs)
801+
802+
803+
@pytest.mark.parametrize("validation", ("function", "attribute", "suppress"))
804+
def test_dvcv015_multipage_validation_layout(validation, dash_duo):
805+
app = multipage_app(validation)
806+
dash_duo.start_server(app, **debugging)
807+
808+
dash_duo.wait_for_text_to_equal("#index_p1", 'Navigate to "/page-1"')
809+
dash_duo.find_element("#index_p1").click()
810+
811+
dash_duo.find_element("#submit-button").click()
812+
dash_duo.wait_for_text_to_equal(
813+
"#output-state",
814+
"The Button has been pressed 1 times,"
815+
'Input 1 is "Montreal",and Input 2 is "Canada"',
816+
)
817+
818+
dash_duo.find_element("#p1_p2").click()
819+
dash_duo.wait_for_text_to_equal("#page-2-display-value", 'You have selected "LA"')
820+
821+
assert not dash_duo.get_logs()

0 commit comments

Comments
 (0)