diff --git a/CHANGELOG.md b/CHANGELOG.md index 05b7a81c02..7d3f8d9c3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ This project adheres to [Semantic Versioning](https://semver.org/). ### Added +- [#1947](https://github.com/plotly/dash/pull/1947) Added `pages` - a better way to build multi-page apps. For more information see the [forum post.](https://community.plotly.com/t/introducing-dash-pages-a-dash-2-x-feature-preview/57775) + + - [#2049](https://github.com/plotly/dash/pull/2043) Added `wait_for_class_to_equal` and `wait_for_contains_class` methods to `dash.testing` - [#1965](https://github.com/plotly/dash/pull/1965) Add component as props. diff --git a/dash/__init__.py b/dash/__init__.py index b64cde9dab..dc22529454 100644 --- a/dash/__init__.py +++ b/dash/__init__.py @@ -2,7 +2,6 @@ # __plotly_dash is for the "make sure you don't have a dash.py" check # must come before any other imports. __plotly_dash = True -from .dash import Dash, no_update # noqa: F401,E402 from .dependencies import ( # noqa: F401,E402 Input, # noqa: F401,E402 Output, # noqa: F401,E402 @@ -21,10 +20,19 @@ from .version import __version__ # noqa: F401,E402 from ._callback_context import callback_context # noqa: F401,E402 from ._callback import callback, clientside_callback # noqa: F401,E402 +from ._get_app import get_app # noqa: F401,E402 from ._get_paths import ( # noqa: F401,E402 get_asset_url, get_relative_path, strip_relative_path, ) + +from ._pages import register_page, PAGE_REGISTRY as page_registry # noqa: F401,E402 +from .dash import ( # noqa: F401,E402 + Dash, + no_update, + page_container, +) + ctx = callback_context diff --git a/dash/_configs.py b/dash/_configs.py index cd7e1b3051..5c6631bc7d 100644 --- a/dash/_configs.py +++ b/dash/_configs.py @@ -1,4 +1,5 @@ import os +import flask # noinspection PyCompatibility from . import exceptions @@ -119,3 +120,25 @@ def pathname_configs( ) return url_base_pathname, routes_pathname_prefix, requests_pathname_prefix + + +def pages_folder_config(name, pages_folder, use_pages): + if not use_pages: + return None + + pages_folder = pages_folder.lstrip("\\").lstrip("/") + pages_folder = None if pages_folder == "" else pages_folder + pages_folder_path = None + error_msg = f""" + A folder called {pages_folder} does not exist. + If a folder for pages is not required in your application, set `pages_folder=""` + For example, `app = Dash(__name__, pages_folder="")` + """ + + if pages_folder: + pages_folder_path = os.path.join( + flask.helpers.get_root_path(name), pages_folder + ) + if pages_folder and not os.path.isdir(pages_folder_path): + raise Exception(error_msg) + return pages_folder_path diff --git a/dash/_get_app.py b/dash/_get_app.py new file mode 100644 index 0000000000..339ca522b7 --- /dev/null +++ b/dash/_get_app.py @@ -0,0 +1,20 @@ +from textwrap import dedent + +APP = None + + +def get_app(): + if APP is None: + raise Exception( + dedent( + """ + App object is not yet defined. `app = dash.Dash()` needs to be run + before `dash.get_app()` is called and can only be used within apps that use + the `pages` multi-page app feature: `dash.Dash(use_pages=True)`. + + `dash.get_app()` is used to get around circular import issues when Python files + within the pages/` folder need to reference the `app` object. + """ + ) + ) + return APP diff --git a/dash/_pages.py b/dash/_pages.py new file mode 100644 index 0000000000..49d8dcd61e --- /dev/null +++ b/dash/_pages.py @@ -0,0 +1,308 @@ +import os +from os import listdir +from os.path import isfile, join +import collections +from urllib.parse import parse_qs +from fnmatch import fnmatch +import re + +from . import _validate +from ._utils import AttributeDict +from ._get_paths import get_relative_path + + +CONFIG = AttributeDict() +PAGE_REGISTRY = collections.OrderedDict() + + +def _infer_image(module): + """ + Return: + - A page specific image: `assets/.` is used, e.g. `assets/weekly_analytics.png` + - A generic app image at `assets/app.` + - A logo at `assets/logo.` + """ + assets_folder = CONFIG.assets_folder + valid_extensions = ["apng", "avif", "gif", "jpeg", "jpg", "png", "svg", "webp"] + page_id = module.split(".")[-1] + files_in_assets = [] + + if os.path.exists(assets_folder): + files_in_assets = [ + f for f in listdir(assets_folder) if isfile(join(assets_folder, f)) + ] + app_file = None + logo_file = None + for fn in files_in_assets: + fn_without_extension, _, extension = fn.partition(".") + if extension.lower() in valid_extensions: + if ( + fn_without_extension == page_id + or fn_without_extension == page_id.replace("_", "-") + ): + return fn + + if fn_without_extension == "app": + app_file = fn + + if fn_without_extension == "logo": + logo_file = fn + + if app_file: + return app_file + + return logo_file + + +def _module_name_to_page_name(filename): + return filename.split(".")[-1].replace("_", " ").capitalize() + + +def _infer_path(filename, template): + if template is None: + if CONFIG.pages_folder: + pages_folder = CONFIG.pages_folder.split("/")[-1] + path = ( + filename.replace("_", "-") + .replace(".", "/") + .lower() + .split(pages_folder)[-1] + ) + else: + path = filename.replace("_", "-").replace(".", "/").lower() + else: + # replace the variables in the template with "none" to create a default path if no path is supplied + path = re.sub("<.*?>", "none", template) + path = "/" + path if not path.startswith("/") else path + return path + + +def _parse_query_string(search): + if search and len(search) > 0 and search[0] == "?": + search = search[1:] + else: + return {} + + parsed_qs = {} + for (k, v) in parse_qs(search).items(): + v = v[0] if len(v) == 1 else v + parsed_qs[k] = v + return parsed_qs + + +def _parse_path_variables(pathname, path_template): + """ + creates the dict of path variables passed to the layout + e.g. path_template= "/asset/" + if pathname provided by the browser is "/assets/a100" + returns **{"asset_id": "a100"} + """ + + # parse variable definitions e.g. from template + # and create pattern to match + wildcard_pattern = re.sub("<.*?>", "*", path_template) + var_pattern = re.sub("<.*?>", "(.*)", path_template) + + # check that static sections of the pathname match the template + if not fnmatch(pathname, wildcard_pattern): + return None + + # parse variable names e.g. var_name from template + var_names = re.findall("<(.*?)>", path_template) + + # parse variables from path + variables = re.findall(var_pattern, pathname) + variables = variables[0] if isinstance(variables[0], tuple) else variables + + return dict(zip(var_names, variables)) + + +def register_page( + module, + path=None, + path_template=None, + name=None, + order=None, + title=None, + description=None, + image=None, + image_url=None, + redirect_from=None, + layout=None, + **kwargs, +): + """ + Assigns the variables to `dash.page_registry` as an `OrderedDict` + (ordered by `order`). + + `dash.page_registry` is used by `pages_plugin` to set up the layouts as + a multi-page Dash app. This includes the URL routing callbacks + (using `dcc.Location`) and the HTML templates to include title, + meta description, and the meta description image. + + `dash.page_registry` can also be used by Dash developers to create the + page navigation links or by template authors. + + - `module`: + The module path where this page's `layout` is defined. Often `__name__`. + + - `path`: + URL Path, e.g. `/` or `/home-page`. + If not supplied, will be inferred from the `path_template` or `module`, + e.g. based on path_template: `/asset/. The layout function + then receives the as a keyword argument. + e.g. path_template= "/asset/" + then if pathname in browser is "/assets/a100" then layout will receive **{"asset_id":"a100"} + + - `name`: + The name of the link. + If not supplied, will be inferred from `module`, + e.g. `pages.weekly_analytics` to `Weekly analytics` + + - `order`: + The order of the pages in `page_registry`. + If not supplied, then the filename is used and the page with path `/` has + order `0` + + - `title`: + (string or function) The name of the page . That is, what appears in the browser title. + If not supplied, will use the supplied `name` or will be inferred by module, + e.g. `pages.weekly_analytics` to `Weekly analytics` + + - `description`: + (string or function) The <meta type="description"></meta>. + If not supplied, then nothing is supplied. + + - `image`: + The meta description image used by social media platforms. + If not supplied, then it looks for the following images in `assets/`: + - A page specific image: `assets/<module>.<extension>` is used, e.g. `assets/weekly_analytics.png` + - A generic app image at `assets/app.<extension>` + - A logo at `assets/logo.<extension>` + When inferring the image file, it will look for the following extensions: + APNG, AVIF, GIF, JPEG, JPG, PNG, SVG, WebP. + + - `image_url`: + This will use the exact image url provided when sharing on social media. + This is appealing when the image you want to share is hosted on a CDN. + Using this attribute overrides the image attribute. + + - `redirect_from`: + A list of paths that should redirect to this page. + For example: `redirect_from=['/v2', '/v3']` + + - `layout`: + The layout function or component for this page. + If not supplied, then looks for `layout` from within the supplied `module`. + + - `**kwargs`: + Arbitrary keyword arguments that can be stored + + *** + + `page_registry` stores the original property that was passed in under + `supplied_<property>` and the coerced property under `<property>`. + For example, if this was called: + ``` + register_page( + 'pages.historical_outlook', + name='Our historical view', + custom_key='custom value' + ) + ``` + Then this will appear in `page_registry`: + ``` + OrderedDict([ + ( + 'pages.historical_outlook', + dict( + module='pages.historical_outlook', + + supplied_path=None, + path='/historical-outlook', + + supplied_name='Our historical view', + name='Our historical view', + + supplied_title=None, + title='Our historical view' + + supplied_layout=None, + layout=<function pages.historical_outlook.layout>, + + custom_key='custom value' + ) + ), + ]) + ``` + """ + _validate.validate_use_pages(CONFIG) + + page = dict( + module=_validate.validate_module_name(module), + supplied_path=path, + path_template=path_template, + path=path if path is not None else _infer_path(module, path_template), + supplied_name=name, + name=name if name is not None else _module_name_to_page_name(module), + ) + page.update( + supplied_title=title, + title=(title if title is not None else page["name"]), + ) + page.update( + description=description if description else "", + order=order, + supplied_order=order, + supplied_layout=layout, + **kwargs, + ) + page.update( + supplied_image=image, + image=(image if image is not None else _infer_image(module)), + image_url=image_url, + ) + page.update(redirect_from=redirect_from) + + PAGE_REGISTRY[module] = page + + if page["path_template"]: + _validate.validate_template(page["path_template"]) + + if layout is not None: + # Override the layout found in the file set during `plug` + PAGE_REGISTRY[module]["layout"] = layout + + # set home page order + order_supplied = any( + p["supplied_order"] is not None for p in PAGE_REGISTRY.values() + ) + + for p in PAGE_REGISTRY.values(): + p["order"] = ( + 0 if p["path"] == "/" and not order_supplied else p["supplied_order"] + ) + p["relative_path"] = get_relative_path(p["path"]) + + # Sort by order and module, then by module + for page in sorted( + PAGE_REGISTRY.values(), + key=lambda i: (str(i.get("order", i["module"])), i["module"]), + ): + PAGE_REGISTRY.move_to_end(page["module"]) diff --git a/dash/_validate.py b/dash/_validate.py index 06b78bb870..0e5a097ec1 100644 --- a/dash/_validate.py +++ b/dash/_validate.py @@ -1,6 +1,8 @@ from collections.abc import MutableSequence import re from textwrap import dedent +from keyword import iskeyword +import flask from ._grouping import grouping_len, map_grouping from .development.base_component import Component @@ -408,3 +410,72 @@ def validate_layout(layout, layout_value): """ ) component_ids.add(component_id) + + +def validate_template(template): + variable_names = re.findall("<(.*?)>", template) + + for name in variable_names: + if not name.isidentifier() or iskeyword(name): + raise Exception( + f'`{name}` is not a valid Python variable name in `path_template`: "{template}".' + ) + + +def check_for_duplicate_pathnames(registry): + path_to_module = {} + for page in registry.values(): + if page["path"] not in path_to_module: + path_to_module[page["path"]] = [page["module"]] + else: + path_to_module[page["path"]].append(page["module"]) + + for modules in path_to_module.values(): + if len(modules) > 1: + raise Exception(f"modules {modules} have duplicate paths") + + +def validate_registry(registry): + for page in registry.values(): + if "layout" not in page: + raise exceptions.NoLayoutException( + f"No layout in module `{page['module']}` in dash.page_registry" + ) + if page["module"] == "__main__": + raise Exception( + """ + When registering pages from app.py, `__name__` is not a valid module name. Use a string instead. + For example, `dash.register_page("my_module_name")`, rather than `dash.register_page(__name__)` + """ + ) + + +def validate_pages_layout(module, page): + if not hasattr(page, "layout"): + raise exceptions.NoLayoutException( + f""" + No layout found in module {module} + A variable or a function named "layout" is required. + """ + ) + + +def validate_use_pages(config): + if not config.get("assets_folder", None): + raise Exception("`dash.register_page()` must be called after app instantiation") + + if flask.has_request_context(): + raise Exception( + """ + dash.register_page() can’t be called within a callback as it updates dash.page_registry, which is a global variable. + For more details, see https://dash.plotly.com/sharing-data-between-callbacks#why-global-variables-will-break-your-app + """ + ) + + +def validate_module_name(module): + if not isinstance(module, str): + raise Exception( + "The first attribute of dash.register_page() must be a string or '__name__'" + ) + return module diff --git a/dash/dash.py b/dash/dash.py index 75505ac2db..8c95f1791a 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -13,11 +13,13 @@ import base64 import traceback from urllib.parse import urlparse +from textwrap import dedent import flask from flask_compress import Compress from pkg_resources import get_distribution, parse_version + from dash import dcc from dash import html from dash import dash_table @@ -38,7 +40,7 @@ DuplicateCallback, ) from .version import __version__ -from ._configs import get_combined_config, pathname_configs +from ._configs import get_combined_config, pathname_configs, pages_folder_config from ._utils import ( AttributeDict, format_tag, @@ -57,12 +59,20 @@ from . import _dash_renderer from . import _validate from . import _watch +from . import _get_app + from ._grouping import ( flatten_grouping, map_grouping, grouping_len, ) +from . import _pages +from ._pages import ( + _parse_path_variables, + _parse_query_string, +) + _flask_compress_version = parse_version(get_distribution("flask-compress").version) @@ -105,6 +115,25 @@ _re_renderer_scripts_id = 'id="_dash-renderer', "new DashRenderer" +_ID_CONTENT = "_pages_content" +_ID_LOCATION = "_pages_location" +_ID_STORE = "_pages_store" +_ID_DUMMY = "_pages_dummy" + +# Handles the case in a newly cloned environment where the components are not yet generated. +try: + page_container = html.Div( + [ + dcc.Location(id=_ID_LOCATION), + html.Div(id=_ID_CONTENT), + dcc.Store(id=_ID_STORE), + html.Div(id=_ID_DUMMY), + ] + ) +except AttributeError: + page_container = None + + def _get_traceback(secret, error: Exception): try: @@ -180,6 +209,14 @@ class Dash: requested. :type assets_folder: string + :param pages_folder: a path, relative to the current working directory, + for pages of a multi-page app. Default ``'pages'``. + :type pages_folder: string + + :param use_pages: Default False, or True if you set a non-default ``pages_folder``. + When True, the ``pages`` feature for multi-page apps is enabled. + :type pages: boolean + :param assets_url_path: The local urls for assets will be: ``requests_pathname_prefix + assets_url_path + '/' + asset_path`` where ``asset_path`` is the path to a file inside ``assets_folder``. @@ -299,11 +336,13 @@ class Dash: ``DiskcacheLongCallbackManager`` or ``CeleryLongCallbackManager`` """ - def __init__( + def __init__( # pylint: disable=too-many-statements self, name=None, server=True, assets_folder="assets", + pages_folder="pages", + use_pages=False, assets_url_path="assets", assets_ignore="", assets_external_path=None, @@ -366,6 +405,7 @@ def __init__( assets_external_path=get_combined_config( "assets_external_path", assets_external_path, "" ), + pages_folder=pages_folder_config(name, pages_folder, use_pages), eager_loading=eager_loading, include_assets_files=get_combined_config( "include_assets_files", include_assets_files, True @@ -404,6 +444,10 @@ def __init__( ) _get_paths.CONFIG = self.config + _pages.CONFIG = self.config + + self.pages_folder = pages_folder + self.use_pages = True if pages_folder != "pages" else use_pages # keep title as a class property for backwards compatibility self.title = title @@ -522,6 +566,9 @@ def _handle_error(_): # catch-all for front-end routes, used by dcc.Location self._add_url("<path:path>", self.index) + _get_app.APP = self + self.enable_pages() + def _add_url(self, name, view_func, methods=("GET",)): full_name = self.config.routes_pathname_prefix + name @@ -549,7 +596,7 @@ def _layout_value(self): @layout.setter def layout(self, value): _validate.validate_layout_type(value) - self._layout_is_function = isinstance(value, patch_collections_abc("Callable")) + self._layout_is_function = callable(value) self._layout = value # for using flask.has_request_context() to deliver a full layout for @@ -796,17 +843,62 @@ def _generate_meta_html(self): x.get("http-equiv", "") == "X-UA-Compatible" for x in meta_tags ) has_charset = any("charset" in x for x in meta_tags) + has_viewport = any("viewport" in x for x in meta_tags) tags = [] if not has_ie_compat: tags.append('<meta http-equiv="X-UA-Compatible" content="IE=edge">') if not has_charset: tags.append('<meta charset="UTF-8">') + if not has_viewport: + tags.append( + '<meta name="viewport" content="width=device-width, initial-scale=1">' + ) tags += [format_tag("meta", x, opened=True) for x in meta_tags] return "\n ".join(tags) + def _pages_meta_tags(self): + start_page, path_variables = self._path_to_page(flask.request.path.strip("/")) + + # use the supplied image_url or create url based on image in the assets folder + image = start_page.get("image", "") + if image: + image = self.get_asset_url(image) + assets_image_url = ( + "".join([flask.request.url_root, image.lstrip("/")]) if image else None + ) + supplied_image_url = start_page.get("image_url") + image_url = supplied_image_url if supplied_image_url else assets_image_url + + title = start_page.get("title", self.title) + if callable(title): + title = title(**path_variables) if path_variables else title() + + description = start_page.get("description", "") + if callable(description): + description = ( + description(**path_variables) if path_variables else description() + ) + + return dedent( + f""" + <meta name="description" content="{description}" /> + <!-- Twitter Card data --> + <meta property="twitter:card" content="summary_large_image"> + <meta property="twitter:url" content="{flask.request.url}"> + <meta property="twitter:title" content="{title}"> + <meta property="twitter:description" content="{description}"> + <meta property="twitter:image" content="{image_url}"> + <!-- Open Graph data --> + <meta property="og:title" content="{title}" /> + <meta property="og:type" content="website" /> + <meta property="og:description" content="{description}" /> + <meta property="og:image" content="{image_url}"> + """ + ) + # Serve the JS bundles for each package def serve_component_suites(self, package_name, fingerprinted_path): path_in_pkg, has_fingerprint = check_fingerprint(fingerprinted_path) @@ -855,6 +947,9 @@ def index(self, *args, **kwargs): # pylint: disable=unused-argument # use self.title instead of app.config.title for backwards compatibility title = self.title + pages_metas = "" + if self.use_pages: + pages_metas = self._pages_meta_tags() if self._favicon: favicon_mod_time = os.path.getmtime( @@ -872,7 +967,7 @@ def index(self, *args, **kwargs): # pylint: disable=unused-argument ) index = self.interpolate_index( - metas=metas, + metas=pages_metas + metas, title=title, css=css, config=config, @@ -1387,6 +1482,9 @@ def _setup_server(self): if self.config.include_assets_files: self._walk_assets_directory() + if not self.layout and self.use_pages: + self.layout = page_container + _validate.validate_layout(self.layout, self._layout_value()) self._generate_scripts_html() @@ -2060,6 +2158,155 @@ def verify_url_part(served_part, url_part, part_name): self.server.run(host=host, port=port, debug=debug, **flask_run_options) + def _import_layouts_from_pages(self): + walk_dir = self.config.pages_folder + + for (root, _, files) in os.walk(walk_dir): + for file in files: + if file.startswith("_") or not file.endswith(".py"): + continue + with open(os.path.join(root, file), encoding="utf-8") as f: + content = f.read() + if "register_page" not in content: + continue + + page_filename = os.path.join(root, file).replace("\\", "/") + _, _, page_filename = page_filename.partition(walk_dir + "/") + page_filename = page_filename.replace(".py", "").replace("/", ".") + + pages_folder = ( + self.pages_folder.replace("\\", "/").lstrip("/").replace("/", ".") + ) + + module_name = ".".join([pages_folder, page_filename]) + + spec = importlib.util.spec_from_file_location( + module_name, os.path.join(root, file) + ) + page_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(page_module) + + if ( + module_name in _pages.PAGE_REGISTRY + and not _pages.PAGE_REGISTRY[module_name]["supplied_layout"] + ): + _validate.validate_pages_layout(module_name, page_module) + _pages.PAGE_REGISTRY[module_name]["layout"] = getattr( + page_module, "layout" + ) + + @staticmethod + def _path_to_page(path_id): + path_variables = None + for page in _pages.PAGE_REGISTRY.values(): + if page["path_template"]: + template_id = page["path_template"].strip("/") + path_variables = _parse_path_variables(path_id, template_id) + if path_variables: + return page, path_variables + if path_id == page["path"].strip("/"): + return page, path_variables + return {}, None + + def enable_pages(self): + if not self.use_pages: + return + if self.pages_folder: + self._import_layouts_from_pages() + + @self.server.before_first_request + def router(): + @self.callback( + Output(_ID_CONTENT, "children"), + Output(_ID_STORE, "data"), + Input(_ID_LOCATION, "pathname"), + Input(_ID_LOCATION, "search"), + prevent_initial_call=True, + ) + def update(pathname, search): + """ + 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 = self._path_to_page( + self.strip_relative_path(pathname) + ) + + # get layout + if page == {}: + module_404 = ".".join([self.pages_folder, "not_found_404"]) + not_found_404 = _pages.PAGE_REGISTRY.get(module_404) + if not_found_404: + layout = not_found_404["layout"] + title = not_found_404["title"] + else: + layout = html.H1("404 - Page not found") + title = self.title + else: + layout = page.get("layout", "") + title = page["title"] + + if callable(layout): + layout = ( + layout(**path_variables, **query_parameters) + if path_variables + else layout(**query_parameters) + ) + if callable(title): + title = title(**path_variables) if path_variables else title() + + return layout, {"title": title} + + _validate.check_for_duplicate_pathnames(_pages.PAGE_REGISTRY) + _validate.validate_registry(_pages.PAGE_REGISTRY) + + # Set validation_layout + self.validation_layout = html.Div( + [ + page["layout"]() if callable(page["layout"]) else page["layout"] + for page in _pages.PAGE_REGISTRY.values() + ] + + [ + # pylint: disable=not-callable + self.layout() + if callable(self.layout) + else self.layout + ] + ) + if _ID_CONTENT not in self.validation_layout: + raise Exception("`dash.page_container` not found in the layout") + + # Update the page title on page navigation + self.clientside_callback( + """ + function(data) {{ + document.title = data.title + }} + """, + Output(_ID_DUMMY, "children"), + Input(_ID_STORE, "data"), + ) + + def create_redirect_function(redirect_to): + def redirect(): + return flask.redirect(redirect_to, code=301) + + return redirect + + # Set redirects + for module in _pages.PAGE_REGISTRY: + page = _pages.PAGE_REGISTRY[module] + if page["redirect_from"] and len(page["redirect_from"]): + for redirect in page["redirect_from"]: + fullname = self.get_relative_path(redirect) + self.server.add_url_rule( + fullname, + fullname, + create_redirect_function(page["relative_path"]), + ) + def run_server(self, *args, **kwargs): """`run_server` is a deprecated alias of `run` and may be removed in a future version. We recommend using `app.run` instead. diff --git a/dash/testing/application_runners.py b/dash/testing/application_runners.py index 4ce07b8aff..8fccee063e 100644 --- a/dash/testing/application_runners.py +++ b/dash/testing/application_runners.py @@ -20,7 +20,6 @@ ) from dash.testing import wait - logger = logging.getLogger(__name__) diff --git a/tests/integration/multi_page/assets/app.jpeg b/tests/integration/multi_page/assets/app.jpeg new file mode 100644 index 0000000000..204ccd97c4 Binary files /dev/null and b/tests/integration/multi_page/assets/app.jpeg differ diff --git a/tests/integration/multi_page/assets/birds.jpeg b/tests/integration/multi_page/assets/birds.jpeg new file mode 100644 index 0000000000..1b0cfd2ba1 Binary files /dev/null and b/tests/integration/multi_page/assets/birds.jpeg differ diff --git a/tests/integration/multi_page/assets/home.jpeg b/tests/integration/multi_page/assets/home.jpeg new file mode 100644 index 0000000000..6b8b87119c Binary files /dev/null and b/tests/integration/multi_page/assets/home.jpeg differ diff --git a/tests/integration/multi_page/assets/logo.jpeg b/tests/integration/multi_page/assets/logo.jpeg new file mode 100644 index 0000000000..6b8b87119c Binary files /dev/null and b/tests/integration/multi_page/assets/logo.jpeg differ diff --git a/tests/integration/multi_page/pages/__init__.py b/tests/integration/multi_page/pages/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integration/multi_page/pages/defaults.py b/tests/integration/multi_page/pages/defaults.py new file mode 100644 index 0000000000..10e873b8af --- /dev/null +++ b/tests/integration/multi_page/pages/defaults.py @@ -0,0 +1,7 @@ +import dash +from dash import html + + +dash.register_page(__name__, id="defaults") + +layout = html.Div("text for defaults", id="text_defaults") diff --git a/tests/integration/multi_page/pages/metas.py b/tests/integration/multi_page/pages/metas.py new file mode 100644 index 0000000000..a585556c80 --- /dev/null +++ b/tests/integration/multi_page/pages/metas.py @@ -0,0 +1,16 @@ +import dash +from dash import html + +dash.register_page( + __name__, + title="Supplied Title", + description="This is the supplied description", + name="Supplied name", + path="/supplied-path", + image="birds.jpeg", + id="metas", +) + + +def layout(): + return html.Div("text for metas", id="text_metas") diff --git a/tests/integration/multi_page/pages/not_found_404.py b/tests/integration/multi_page/pages/not_found_404.py new file mode 100644 index 0000000000..2e1d16191e --- /dev/null +++ b/tests/integration/multi_page/pages/not_found_404.py @@ -0,0 +1,7 @@ +from dash import html +import dash + +dash.register_page(__name__, path="/404", id="not_found_404") + + +layout = html.Div("text for not_found_404", id="text_not_found_404") diff --git a/tests/integration/multi_page/pages/page1.py b/tests/integration/multi_page/pages/page1.py new file mode 100644 index 0000000000..85b07b5719 --- /dev/null +++ b/tests/integration/multi_page/pages/page1.py @@ -0,0 +1,19 @@ +import dash +from dash import html, Input, Output, callback + + +dash.register_page(__name__, id="page1") + +layout = html.Div( + [ + html.Div("text for page1", id="text_page1"), + html.Button("goto page2", id="btn1", n_clicks=0), + ] +) + + +@callback(Output("url", "pathname"), Input("btn1", "n_clicks")) +def update(n): + if n > 0: + return "/page2" + return dash.no_update diff --git a/tests/integration/multi_page/pages/page2.py b/tests/integration/multi_page/pages/page2.py new file mode 100644 index 0000000000..560bfbf903 --- /dev/null +++ b/tests/integration/multi_page/pages/page2.py @@ -0,0 +1,7 @@ +import dash +from dash import html + + +dash.register_page(__name__, id="page2") + +layout = html.Div("text for page2", id="text_page2") diff --git a/tests/integration/multi_page/pages/path_variables.py b/tests/integration/multi_page/pages/path_variables.py new file mode 100644 index 0000000000..dfb8968acf --- /dev/null +++ b/tests/integration/multi_page/pages/path_variables.py @@ -0,0 +1,14 @@ +import dash +from dash import html + + +dash.register_page(__name__, path_template="/a/<id_a>/b/<id_b>", id="register_page") + + +def layout(id_a=None, id_b=None, **other_unknown_query_strings): + return html.Div( + [ + html.Div("text for register_page", id="text_register_page"), + html.Div(f"variables from pathname:{id_a} {id_b}", id="path_vars"), + ] + ) diff --git a/tests/integration/multi_page/pages/query_string.py b/tests/integration/multi_page/pages/query_string.py new file mode 100644 index 0000000000..9cb3f5297c --- /dev/null +++ b/tests/integration/multi_page/pages/query_string.py @@ -0,0 +1,13 @@ +import dash +from dash import html + +dash.register_page(__name__, id="query_string") + + +def layout(velocity=None, **other_unknown_query_strings): + return html.Div( + [ + html.Div("text for query_string", id="text_query_string"), + dash.dcc.Input(id="velocity", value=velocity), + ] + ) diff --git a/tests/integration/multi_page/pages/redirect.py b/tests/integration/multi_page/pages/redirect.py new file mode 100644 index 0000000000..f81147c7a6 --- /dev/null +++ b/tests/integration/multi_page/pages/redirect.py @@ -0,0 +1,7 @@ +import dash +from dash import html + + +dash.register_page(__name__, redirect_from=["/old-home-page", "/v2"], id="redirect") + +layout = html.Div("text for redirect", id="text_redirect") diff --git a/tests/integration/multi_page/pages_error/__init__.py b/tests/integration/multi_page/pages_error/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integration/multi_page/pages_error/no_layout_page.py b/tests/integration/multi_page/pages_error/no_layout_page.py new file mode 100644 index 0000000000..16c810063c --- /dev/null +++ b/tests/integration/multi_page/pages_error/no_layout_page.py @@ -0,0 +1,6 @@ +import dash + + +dash.register_page(__name__) + +# page with no layout diff --git a/tests/integration/multi_page/test_pages_layout.py b/tests/integration/multi_page/test_pages_layout.py new file mode 100644 index 0000000000..5b12fef080 --- /dev/null +++ b/tests/integration/multi_page/test_pages_layout.py @@ -0,0 +1,181 @@ +import pytest +import dash +from dash import Dash, dcc, html +from dash.exceptions import NoLayoutException + + +def get_app(path1="/", path2="/layout2"): + app = Dash(__name__, use_pages=True) + + # test for storing arbitrary keyword arguments: An `id` prop is defined for every page + # test for defining multiple pages within a single file: layout is passed directly to `register_page` + # in the following two modules: + dash.register_page( + "multi_layout1", + layout=html.Div("text for multi_layout1", id="text_multi_layout1"), + path=path1, + title="Supplied Title", + description="This is the supplied description", + name="Supplied name", + image="birds.jpeg", + id="multi_layout1", + ) + dash.register_page( + "multi_layout2", + layout=html.Div("text for multi_layout2", id="text_multi_layout2"), + path=path2, + id="multi_layout2", + ) + + app.layout = html.Div( + [ + html.Div( + [ + html.Div( + dcc.Link( + f"{page['name']} - {page['path']}", + id=page["id"], + href=page["path"], + ) + ) + for page in dash.page_registry.values() + ] + ), + dash.page_container, + dcc.Location(id="url", refresh=True), + ] + ) + return app + + +def test_pala001_layout(dash_duo): + + dash_duo.start_server(get_app()) + + # test layout and title for each page in `page_registry` with link navigation + for page in dash.page_registry.values(): + dash_duo.find_element("#" + page["id"]).click() + dash_duo.wait_for_text_to_equal("#text_" + page["id"], "text for " + page["id"]) + 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_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_text_to_equal("#text_redirect", "text for redirect") + assert ( + dash_duo.driver.current_url + == f"http://localhost:{dash_duo.server.port}/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.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" + ) + 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_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_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_text_to_equal("#text_not_found_404", "text for not_found_404") + + assert dash_duo.get_logs() == [], "browser console should contain no error" + # dash_duo.percy_snapshot("pala001_layout") + + +def check_metas(dash_duo, metas): + meta = dash_duo.find_elements("meta") + + # -3 for the meta charset and http-equiv and viewport. + assert len(meta) == len(metas) + 3, "Should have extra meta tags" + + assert meta[0].get_attribute("name") == metas[0]["name"] + assert meta[0].get_attribute("content") == metas[0]["content"] + for i in range(1, len(meta) - 3): + assert meta[i].get_attribute("property") == metas[i]["property"] + assert meta[i].get_attribute("content") == metas[i]["content"] + + +def test_pala002_meta_tags_default(dash_duo): + dash_duo.start_server(get_app(path1="/layout1", path2="/")) + # These are the inferred defaults if description, title, image are not supplied + metas_layout2 = [ + {"name": "description", "content": ""}, + {"property": "twitter:card", "content": "summary_large_image"}, + { + "property": "twitter:url", + "content": f"http://localhost:{dash_duo.server.port}/", + }, + {"property": "twitter:title", "content": "Multi layout2"}, + {"property": "twitter:description", "content": ""}, + { + "property": "twitter:image", + "content": f"http://localhost:{dash_duo.server.port}/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", + }, + ] + + check_metas(dash_duo, metas_layout2) + + +def test_pala003_meta_tags_custom(dash_duo): + dash_duo.start_server(get_app()) + # In the "multi_layout1" module, the description, title, image are supplied + metas_layout1 = [ + {"name": "description", "content": "This is the supplied description"}, + {"property": "twitter:card", "content": "summary_large_image"}, + { + "property": "twitter:url", + "content": f"http://localhost:{dash_duo.server.port}/", + }, + {"property": "twitter:title", "content": "Supplied Title"}, + { + "property": "twitter:description", + "content": "This is the supplied description", + }, + { + "property": "twitter:image", + "content": f"http://localhost:{dash_duo.server.port}/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", + }, + ] + + check_metas(dash_duo, metas_layout1) + + +def test_pala004_no_layout_exception(): + error_msg = 'No layout found in module pages_error.no_layout_page\nA variable or a function named "layout" is required.' + + with pytest.raises(NoLayoutException) as err: + Dash(__name__, use_pages=True, pages_folder="pages_error") + + # clean up after this test, so the broken entry doesn't affect other pages tests + del dash.page_registry["pages_error.no_layout_page"] + + assert error_msg in err.value.args[0] diff --git a/tests/integration/multi_page/test_pages_order.py b/tests/integration/multi_page/test_pages_order.py new file mode 100644 index 0000000000..4285d97676 --- /dev/null +++ b/tests/integration/multi_page/test_pages_order.py @@ -0,0 +1,67 @@ +import dash +from dash import Dash, dcc, html + + +def test_paor001_order(dash_duo): + + app = Dash(__name__, use_pages=True) + + dash.register_page( + "multi_layout1", + layout=html.Div("text for multi_layout1", id="text_multi_layout1"), + order=2, + id="multi_layout1", + ) + dash.register_page( + "multi_layout2", + layout=html.Div("text for multi_layout2", id="text_multi_layout2"), + order=1, + id="multi_layout2", + ) + dash.register_page( + "multi_layout3", + layout=html.Div("text for multi_layout3", id="text_multi_layout3"), + order=0, + id="multi_layout3", + ) + + app.layout = html.Div( + [ + html.Div( + [ + html.Div( + dcc.Link( + f"{page['name']} - {page['path']}", + id=page["id"], + href=page["path"], + ) + ) + for page in dash.page_registry.values() + ] + ), + dash.page_container, + dcc.Location(id="url", refresh=True), + ] + ) + + modules = [ + "multi_layout3", + "multi_layout2", + "multi_layout1", + "pages.defaults", + "pages.metas", + "pages.not_found_404", + "pages.page1", + "pages.page2", + "pages.path_variables", + "pages.query_string", + "pages.redirect", + ] + + dash_duo.start_server(app) + + assert ( + list(dash.page_registry) == modules + ), "check order of modules in dash.page_registry" + + assert dash_duo.get_logs() == [], "browser console should contain no error" diff --git a/tests/integration/multi_page/test_pages_relative_path.py b/tests/integration/multi_page/test_pages_relative_path.py new file mode 100644 index 0000000000..2cbc63402a --- /dev/null +++ b/tests/integration/multi_page/test_pages_relative_path.py @@ -0,0 +1,68 @@ +import dash +from dash import Dash, dcc, html + + +def get_app(app): + app = app + + dash.register_page( + "multi_layout1", + layout=html.Div("text for multi_layout1", id="text_multi_layout1"), + path="/", + title="Supplied Title", + description="This is the supplied description", + name="Supplied name", + image="birds.jpeg", + id="multi_layout1", + ) + dash.register_page( + "multi_layout2", + layout=html.Div("text for multi_layout2", id="text_multi_layout2"), + path="/layout2", + id="multi_layout2", + ) + + app.layout = html.Div( + [ + html.Div( + [ + html.Div( + dcc.Link( + f"{page['name']} - {page['path']}", + id=page["id"], + href=page["relative_path"], + ) + ) + for page in dash.page_registry.values() + ] + ), + dash.page_container, + dcc.Location(id="url", refresh=True), + ] + ) + return app + + +def test_pare001_relative_path(dash_duo): + + dash_duo.start_server(get_app(Dash(__name__, use_pages=True))) + for page in dash.page_registry.values(): + dash_duo.find_element("#" + page["id"]).click() + dash_duo.wait_for_text_to_equal("#text_" + page["id"], "text for " + page["id"]) + assert dash_duo.driver.title == page["title"], "check that page title updates" + + assert dash_duo.get_logs() == [], "browser console should contain no error" + + +def test_pare002_relative_path_with_url_base_pathname(dash_br, dash_thread_server): + dash_thread_server( + get_app(Dash(__name__, use_pages=True, url_base_pathname="/app1/")) + ) + dash_br.server_url = "http://localhost:{}/app1/".format(dash_thread_server.port) + + for page in dash.page_registry.values(): + dash_br.find_element("#" + page["id"]).click() + dash_br.wait_for_text_to_equal("#text_" + page["id"], "text for " + page["id"]) + assert dash_br.driver.title == page["title"], "check that page title updates" + + assert dash_br.get_logs() == [], "browser console should contain no error" diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index fe43de82ac..0a28752053 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -9,6 +9,7 @@ import dash_dangerously_set_inner_html import dash_flow_example +import dash from dash import Dash, html, dcc, Input, Output from dash.exceptions import PreventUpdate @@ -122,12 +123,12 @@ def test_inin007_meta_tags(dash_duo): meta = dash_duo.find_elements("meta") - # -2 for the meta charset and http-equiv. - assert len(meta) == len(metas) + 2, "Should have 2 extra meta tags" + # -3 for the meta charset, http-equiv and viewport. + assert len(meta) == len(metas) + 3, "Should have 3 extra meta tags" - for i in range(2, len(meta)): + for i in range(3, len(meta)): meta_tag = meta[i] - meta_info = metas[i - 2] + meta_info = metas[i - 3] assert meta_tag.get_attribute("name") == meta_info["name"] assert meta_tag.get_attribute("content") == meta_info["content"] @@ -344,3 +345,54 @@ def render_content(tab): dash_duo.find_element("#graph2:not(.dash-graph--pending)").click() until(lambda: '"label": 3' in dash_duo.find_element("#graph2_info").text, timeout=3) + + +def test_inin027_multi_page_without_pages_folder(dash_duo): + app = Dash(__name__, pages_folder="") + + # test for storing arbitrary keyword arguments: An `id` prop is defined for every page + # test for defining multiple pages within a single file: layout is passed directly to `register_page` + # in the following two modules: + dash.register_page( + "multi_layout1", + layout=html.Div("text for multi_layout1", id="text_multi_layout1"), + path="/", + title="Supplied Title", + description="This is the supplied description", + name="Supplied name", + image="birds.jpeg", + id="multi_layout1", + ) + dash.register_page( + "multi_layout2", + layout=html.Div("text for multi_layout2", id="text_multi_layout2"), + path="/layout2", + id="multi_layout2", + ) + + app.layout = html.Div( + [ + html.Div( + [ + html.Div( + dcc.Link( + f"{page['name']} - {page['path']}", + id=page["id"], + href=page["path"], + ) + ) + for page in dash.page_registry.values() + ] + ), + dash.page_container, + ] + ) + + dash_duo.start_server(app) + # test layout and title for each page in `page_registry` with link navigation + for page in dash.page_registry.values(): + dash_duo.find_element("#" + page["id"]).click() + dash_duo.wait_for_text_to_equal("#text_" + page["id"], "text for " + page["id"]) + assert dash_duo.driver.title == page["title"], "check that page title updates" + + assert not dash_duo.get_logs() diff --git a/usage-pages/pages_error/__init__.py b/usage-pages/pages_error/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/usage-pages/pages_error/page1.py b/usage-pages/pages_error/page1.py new file mode 100644 index 0000000000..16c810063c --- /dev/null +++ b/usage-pages/pages_error/page1.py @@ -0,0 +1,6 @@ +import dash + + +dash.register_page(__name__) + +# page with no layout