diff --git a/CHANGELOG.md b/CHANGELOG.md index abd632581a..f3f704695d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [UNRELEASED] ## Added + +- [#2795](https://github.com/plotly/dash/pull/2795) Allow list of components to be passed as layout. - [2760](https://github.com/plotly/dash/pull/2760) New additions to dcc.Loading resolving multiple issues: - `delay_show` and `delay_hide` props to prevent flickering during brief loading periods (similar to Dash Bootstrap Components dbc.Spinner) - `overlay_style` for styling the loading overlay, such as setting visibility and opacity for children diff --git a/components/dash-core-components/package.json b/components/dash-core-components/package.json index 3180048aee..c38b0bed77 100644 --- a/components/dash-core-components/package.json +++ b/components/dash-core-components/package.json @@ -103,6 +103,6 @@ "react-dom": ">=16" }, "browserslist": [ - "last 8 years and not dead" + "last 9 years and not dead" ] } diff --git a/components/dash-html-components/package.json b/components/dash-html-components/package.json index 759968457e..0d43687785 100644 --- a/components/dash-html-components/package.json +++ b/components/dash-html-components/package.json @@ -59,6 +59,6 @@ "/dash_html_components/*{.js,.map}" ], "browserslist": [ - "last 8 years and not dead" + "last 9 years and not dead" ] } diff --git a/components/dash-table/package.json b/components/dash-table/package.json index 54972bea0e..5cc3b41f09 100644 --- a/components/dash-table/package.json +++ b/components/dash-table/package.json @@ -125,6 +125,6 @@ "npm": ">=6.1.0" }, "browserslist": [ - "last 8 years and not dead" + "last 9 years and not dead" ] } diff --git a/dash/_validate.py b/dash/_validate.py index dcd075b8cd..b5e21d9eb5 100644 --- a/dash/_validate.py +++ b/dash/_validate.py @@ -398,12 +398,13 @@ def validate_index(name, checks, index): def validate_layout_type(value): - if not isinstance(value, (Component, patch_collections_abc("Callable"))): + if not isinstance( + value, (Component, patch_collections_abc("Callable"), list, tuple) + ): raise exceptions.NoLayoutException( """ - Layout must be a single dash component + Layout must be a single dash component, a list of dash components, or a function that returns a dash component. - Cannot be a tuple (are there any trailing commas?) """ ) @@ -418,18 +419,34 @@ def validate_layout(layout, layout_value): """ ) - layout_id = stringify_id(getattr(layout_value, "id", None)) + component_ids = set() - component_ids = {layout_id} if layout_id else set() - for component in layout_value._traverse(): # pylint: disable=protected-access - component_id = stringify_id(getattr(component, "id", None)) - if component_id and component_id in component_ids: - raise exceptions.DuplicateIdError( - f""" - Duplicate component id found in the initial layout: `{component_id}` - """ - ) - component_ids.add(component_id) + def _validate(value): + def _validate_id(comp): + component_id = stringify_id(getattr(comp, "id", None)) + if component_id and component_id in component_ids: + raise exceptions.DuplicateIdError( + f""" + Duplicate component id found in the initial layout: `{component_id}` + """ + ) + component_ids.add(component_id) + + _validate_id(value) + + for component in value._traverse(): # pylint: disable=protected-access + _validate_id(component) + + if isinstance(layout_value, (list, tuple)): + for component in layout_value: + if isinstance(component, (Component,)): + _validate(component) + else: + raise exceptions.NoLayoutException( + "List of components as layout must be a list of components only." + ) + else: + _validate(layout_value) def validate_template(template): diff --git a/dash/dash-renderer/package.json b/dash/dash-renderer/package.json index cb8ee99b38..d7375d1e9e 100644 --- a/dash/dash-renderer/package.json +++ b/dash/dash-renderer/package.json @@ -88,6 +88,6 @@ ], "prettier": "@plotly/prettier-config-dash", "browserslist": [ - "last 8 years and not dead" + "last 9 years and not dead" ] } diff --git a/dash/dash-renderer/src/APIController.react.js b/dash/dash-renderer/src/APIController.react.js index ed443bad5c..b956e6313c 100644 --- a/dash/dash-renderer/src/APIController.react.js +++ b/dash/dash-renderer/src/APIController.react.js @@ -21,6 +21,7 @@ import {getAppState} from './reducers/constants'; import {STATUS} from './constants/constants'; import {getLoadingState, getLoadingHash} from './utils/TreeContainer'; import wait from './utils/wait'; +import isSimpleComponent from './isSimpleComponent'; export const DashContext = createContext({}); @@ -97,20 +98,44 @@ const UnconnectedContainer = props => { content = ( - + {Array.isArray(layout) ? ( + layout.map((c, i) => + isSimpleComponent(c) ? ( + c + ) : ( + + ) + ) + ) : ( + + )} ); } else { @@ -216,7 +241,7 @@ UnconnectedContainer.propTypes = { graphs: PropTypes.object, hooks: PropTypes.object, layoutRequest: PropTypes.object, - layout: PropTypes.object, + layout: PropTypes.any, loadingMap: PropTypes.any, history: PropTypes.any, error: PropTypes.object, diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index ab88a45b8f..4108a33b7b 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -427,3 +427,87 @@ def test_inin027_multi_page_without_pages_folder(dash_duo): del dash.page_registry["not_found_404"] assert not dash_duo.get_logs() + + +def test_inin028_layout_as_list(dash_duo): + app = Dash() + + app.layout = [ + html.Div("one", id="one"), + html.Div("two", id="two"), + html.Button("direct", id="direct"), + html.Div(id="direct-output"), + html.Div([html.Button("nested", id="nested"), html.Div(id="nested-output")]), + ] + + @app.callback( + Output("direct-output", "children"), + Input("direct", "n_clicks"), + prevent_initial_call=True, + ) + def on_direct_click(n_clicks): + return f"Clicked {n_clicks} times" + + @app.callback( + Output("nested-output", "children"), + Input("nested", "n_clicks"), + prevent_initial_call=True, + ) + def on_nested_click(n_clicks): + return f"Clicked {n_clicks} times" + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#one", "one") + dash_duo.wait_for_text_to_equal("#two", "two") + + dash_duo.wait_for_element("#direct").click() + dash_duo.wait_for_text_to_equal("#direct-output", "Clicked 1 times") + + dash_duo.wait_for_element("#nested").click() + dash_duo.wait_for_text_to_equal("#nested-output", "Clicked 1 times") + + +def test_inin029_layout_as_list_with_pages(dash_duo): + app = Dash(use_pages=True, pages_folder="") + + dash.register_page( + "list-pages", + "/", + layout=[ + html.Div("one", id="one"), + html.Div("two", id="two"), + html.Button("direct", id="direct"), + html.Div(id="direct-output"), + html.Div( + [html.Button("nested", id="nested"), html.Div(id="nested-output")] + ), + ], + ) + + @app.callback( + Output("direct-output", "children"), + Input("direct", "n_clicks"), + prevent_initial_call=True, + ) + def on_direct_click(n_clicks): + return f"Clicked {n_clicks} times" + + @app.callback( + Output("nested-output", "children"), + Input("nested", "n_clicks"), + prevent_initial_call=True, + ) + def on_nested_click(n_clicks): + return f"Clicked {n_clicks} times" + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#one", "one") + dash_duo.wait_for_text_to_equal("#two", "two") + + dash_duo.wait_for_element("#direct").click() + dash_duo.wait_for_text_to_equal("#direct-output", "Clicked 1 times") + + dash_duo.wait_for_element("#nested").click() + dash_duo.wait_for_text_to_equal("#nested-output", "Clicked 1 times")