diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1199f7a8..ac56d5d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,6 +66,15 @@ pages: - container: ExampleContainer ``` +### Override container toolbar + +In the generated webviz application, your container will as default be given +a button toolbar. The default buttons to appear is stored in the class constant +`WebvizContainer.TOOLBAR_BUTTONS`. If you want to override which buttons should +appear, redefine this class constant in your subclass. To remove all buttons, +simply define it as an empty list. See [this section](#data-download-callback) +for more information regarding the `data_download` button. + ### Callbacks If you want to include user interactivity which triggers actions in the Python @@ -123,6 +132,34 @@ There are three fundamental additions to the minimal example without callbacks: [uuid.uuid4()](https://docs.python.org/3/library/uuid.html#uuid.uuid4), as demonstrated in the example above. +#### Data download callback + +There is a [data download button](#override-container-toolbar) provided by +the `WebvizContainer` class. However, it will only appear if the corresponding +callback is set. A typical data download callback will look like + +``` + @app.callback(self.container_data_output, + [self.container_data_requested]) + def cb_user_download_data(data_requested): + return WebvizContainer.container_data_compress( + [{'filename': 'some_file.txt', + 'content': 'Some download data'}] + ) if data_requested else '' +``` +By letting the container define the callback, the container author is able +to utilize the whole callback machinery, including e.g. state of the individual +components in the container. This way the data downloaded can e.g. depend on +the visual state or user selection. + +The attributes `self.container_data_output` and `self.container_data_requested` +are Dash `Output` and `Input` instances respectively, and are provided by +the base class `WebvizContainer` (i.e. include them as shown here). + +The function `WebvizContainer.container_data_compress` is a utility function +which takes a list of dictionaries, giving filenames and corresponding data, +and compresses them to a zip archive which is then downloaded by the user. + ### User provided arguments Since the containers are reusable and generic, they usually take in some diff --git a/examples/basic_example.yaml b/examples/basic_example.yaml index c43d4c6d..5ff0114b 100644 --- a/examples/basic_example.yaml +++ b/examples/basic_example.yaml @@ -40,6 +40,10 @@ pages: content: - container: TablePlotter csv_file: ./example_data.csv + contact_person: + name: Ola Nordmann + phone: +47 12345678 + email: some@email.com - title: Plot a table (locked) content: diff --git a/setup.py b/setup.py index 442a73c3..e4f8d90e 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,8 @@ 'markdown~=3.0.1', 'pandas~=0.24.1', 'pyarrow~=0.11.1', - 'pyyaml~=5.1' + 'pyyaml~=5.1', + 'webviz-core-components~=0.0.2' ], tests_require=tests_requires, extras_require={'tests': tests_requires}, diff --git a/webviz_config/_config_parser.py b/webviz_config/_config_parser.py index 0d8e8188..d554de1f 100644 --- a/webviz_config/_config_parser.py +++ b/webviz_config/_config_parser.py @@ -23,8 +23,8 @@ def _is_webviz_container(obj): inspect.getmembers(module, _is_webviz_container)] -def _call_signature(module, module_name, container_name, - container_settings, kwargs, config_folder): +def _call_signature(module, module_name, container_name, container_settings, + kwargs, config_folder, contact_person=None): '''Takes as input the name of a container, the module it is located in, together with user given arguments (originating from the configuration file). Returns the equivalent Python code wrt. initiating an instance of @@ -53,13 +53,30 @@ def _call_signature(module, module_name, container_name, 'file.' '\033[0m') - for arg in kwargs: + for arg in list(kwargs): if arg in SPECIAL_ARGS: raise ParserError('\033[91m' f'Container argument `{arg}` not allowed.' '\033[0m') - if arg not in argspec.args: + if arg == 'contact_person': + if not isinstance(kwargs['contact_person'], dict): + raise ParserError('\033[91m' + f'The contact information provided for ' + f'container `{container_name}` is ' + f'not a dictionary. ' + '\033[0m') + elif any(key not in ['name', 'phone', 'email'] + for key in kwargs['contact_person']): + raise ParserError('\033[91m' + f'Unrecognized contact information key ' + f'given to container `{container_name}`.' + f'Should be "name", "phone" and/or "email".' + '\033[0m') + else: + contact_person = kwargs.pop('contact_person') + + elif arg not in argspec.args: raise ParserError('\033[91m' 'Unrecognized argument. The container ' f'`{container_name}` does not take an ' @@ -89,7 +106,8 @@ def _call_signature(module, module_name, container_name, if 'container_settings' in argspec.args: kwargs['container_settings'] = container_settings - return f'{module_name}.{container_name}({special_args}**{kwargs})' + return (f'{module_name}.{container_name}({special_args}**{kwargs})' + f'.container_layout(app=app, contact_person={contact_person})') class ParserError(Exception): diff --git a/webviz_config/containers/__init__.py b/webviz_config/containers/__init__.py index c77a28bc..e3f42884 100644 --- a/webviz_config/containers/__init__.py +++ b/webviz_config/containers/__init__.py @@ -9,6 +9,7 @@ from ._container_class import WebvizContainer from ._example_container import ExampleContainer +from ._example_data_download import ExampleDataDownload from ._example_assets import ExampleAssets from ._example_portable import ExamplePortable from ._banner_image import BannerImage diff --git a/webviz_config/containers/_banner_image.py b/webviz_config/containers/_banner_image.py index d7a72e0f..d261163c 100644 --- a/webviz_config/containers/_banner_image.py +++ b/webviz_config/containers/_banner_image.py @@ -18,6 +18,8 @@ class BannerImage(WebvizContainer): * `shadow`: Set to `False` if you do not want text shadow for the title. ''' + TOOLBAR_BUTTONS = [] + def __init__(self, image: Path, title: str = '', color: str = 'white', shadow: bool = True): diff --git a/webviz_config/containers/_container_class.py b/webviz_config/containers/_container_class.py index 5232fd6d..d14673de 100644 --- a/webviz_config/containers/_container_class.py +++ b/webviz_config/containers/_container_class.py @@ -1,4 +1,11 @@ +import io import abc +import base64 +import zipfile +from uuid import uuid4 +import bleach +from dash.dependencies import Input, Output +import webviz_core_components as wcc class WebvizContainer(abc.ABC): @@ -16,6 +23,20 @@ def layout(self): ``` ''' + # This is the default set of buttons to show in the rendered container + # toolbar. If the list is empty, the subclass container layout will be + # used directly, without any visual encapsulation layout from this + # abstract base class. The containers subclassing this abstract base class + # can override this variable setting by defining a class constant with + # the same name. + # + # Some buttons will only appear if in addition necessary data is available. + # E.g. download of zip archive will only appear if the container also + # has defined the corresponding callback, and contact person will only + # appear if the user configuration file has this information. + TOOLBAR_BUTTONS = ['screenshot', 'expand', + 'download_zip', 'contact_person'] + # List of container specific assets which should be copied # over to the ./assets folder in the generated webviz app. # This is typically custom JavaScript and/or CSS files. @@ -29,3 +50,66 @@ def layout(self): the main Webviz application. ''' pass + + @property + def _container_wrapper_id(self): + if not hasattr(self, '_container_wrapper_uuid'): + self._container_wrapper_uuid = uuid4() + return f'container-wrapper-{self._container_wrapper_uuid}' + + @property + def container_data_output(self): + self._add_download_button = True + return Output(self._container_wrapper_id, 'zip_base64') + + @property + def container_data_requested(self): + return Input(self._container_wrapper_id, 'data_requested') + + @staticmethod + def container_data_compress(content): + byte_io = io.BytesIO() + + with zipfile.ZipFile(byte_io, 'w') as zipped_data: + for data in content: + zipped_data.writestr(data['filename'], data['content']) + + byte_io.seek(0) + + return base64.b64encode(byte_io.read()).decode('ascii') + + def container_layout(self, app, contact_person=None): + '''This function returns (if the class constant SHOW_TOOLBAR is True, + the container layout wrapped into a common webviz config container + component, which provides some useful buttons like download of data, + show data contact person and download container content to png. + + CSV download button will only appear if the container class a property + `csv_string` which should return the appropriate csv data as a string. + + If TOOLBAR_BUTTONS is empty, this functions returns the same + dash layout as the container class provides directly. + ''' + + buttons = self.__class__.TOOLBAR_BUTTONS.copy() + + if contact_person is None: + contact_person = {} + else: + # Sanitize the configuration user input + for key in contact_person: + contact_person[key] = bleach.clean(contact_person[key]) + + if 'download_zip' in buttons and \ + not hasattr(self, '_add_download_button'): + buttons.remove('download_zip') + + if buttons: + return wcc.WebvizContainerPlaceholder( + id=self._container_wrapper_id, + buttons=buttons, + contact_person=contact_person, + children=[self.layout] + ) + else: + return self.layout diff --git a/webviz_config/containers/_example_data_download.py b/webviz_config/containers/_example_data_download.py new file mode 100644 index 00000000..2cbcbb34 --- /dev/null +++ b/webviz_config/containers/_example_data_download.py @@ -0,0 +1,22 @@ +import dash_html_components as html +from . import WebvizContainer + + +class ExampleDataDownload(WebvizContainer): + + def __init__(self, app, title: str): + self.title = title + self.set_callbacks(app) + + @property + def layout(self): + return html.H1(self.title) + + def set_callbacks(self, app): + @app.callback(self.container_data_output, + [self.container_data_requested]) + def _user_download_data(data_requested): + return WebvizContainer.container_data_compress( + [{'filename': 'some_file.txt', + 'content': 'Some download data'}] + ) if data_requested else '' diff --git a/webviz_config/containers/_table_plotter.py b/webviz_config/containers/_table_plotter.py index e116a050..d2b9fb82 100644 --- a/webviz_config/containers/_table_plotter.py +++ b/webviz_config/containers/_table_plotter.py @@ -239,6 +239,14 @@ def plot_input_callbacks(self): return inputs def set_callbacks(self, app): + @app.callback(self.container_data_output, + [self.container_data_requested]) + def _user_download_data(data_requested): + return WebvizContainer.container_data_compress( + [{'filename': 'table_plotter.csv', + 'content': get_data(self.csv_file).to_csv()}] + ) if data_requested else '' + @app.callback( self.plot_output_callbacks, self.plot_input_callbacks) diff --git a/webviz_config/templates/webviz_template.py.jinja2 b/webviz_config/templates/webviz_template.py.jinja2 index ce2eee81..e4b974a4 100644 --- a/webviz_config/templates/webviz_template.py.jinja2 +++ b/webviz_config/templates/webviz_template.py.jinja2 @@ -93,7 +93,7 @@ app.layout = dcc.Tabs(parent_className="layoutWrapper", {%- if content is string -%} dcc.Markdown(r'''{{ content }}''') {%- else -%} - {{ content._call_signature }}.layout + {{ content._call_signature }} {%- endif -%} {{- '' if loop.last else ','}} {% endfor -%}