From 2c22abb49b6cc7fdb75b08506698122d2b917696 Mon Sep 17 00:00:00 2001
From: Edan Bainglass <45081142+edan-bainglass@users.noreply.github.com>
Date: Tue, 17 Sep 2024 11:15:46 +0200
Subject: [PATCH] Redesign header with logo, toggled info sections, etc. (#751)
This PR refactors the main notebook as follows:
- Much of the notebook is refactored as an `AppWrapper` component holding the banner, main, and footer components
- The banner component (used to be the welcome message) is redesigned as follows:
- App logo (same as documentation logo) and subtitle
- Toggle buttons triggering an info box optionally showing one of the following:
- Guide section - the old welcome page plus links to online docs, tutorials, etc.
- About section - general information about the app and acknowledgements
- The actual app is now injected from the notebook into the main component.
- The footer component showing copyrights and version stays at the bottom of the page.
The PR also introduces the use of CSS, loading custom stylesheets in the notebook via the AWB CSS loader utility (aiidalab/aiidalab-widgets-base#624)
---
qe.ipynb | 67 +++----
src/aiidalab_qe/app/__init__.py | 6 -
src/aiidalab_qe/app/static/styles/custom.css | 40 ++++
src/aiidalab_qe/app/static/styles/infobox.css | 15 ++
.../app/static/templates/about.jinja | 10 +
.../app/static/templates/guide.jinja | 42 ++++
.../app/static/templates/welcome.jinja | 28 ---
.../static/templates/workflow_failure.jinja | 6 -
.../static/templates/workflow_summary.jinja | 6 -
src/aiidalab_qe/app/wrapper.py | 179 ++++++++++++++++++
src/aiidalab_qe/common/infobox.py | 22 +++
tests/test_infobox.py | 16 ++
tests/test_wrapper.py | 64 +++++++
13 files changed, 423 insertions(+), 78 deletions(-)
create mode 100644 src/aiidalab_qe/app/static/styles/infobox.css
create mode 100644 src/aiidalab_qe/app/static/templates/about.jinja
create mode 100644 src/aiidalab_qe/app/static/templates/guide.jinja
delete mode 100644 src/aiidalab_qe/app/static/templates/welcome.jinja
create mode 100644 src/aiidalab_qe/app/wrapper.py
create mode 100644 src/aiidalab_qe/common/infobox.py
create mode 100644 tests/test_infobox.py
create mode 100644 tests/test_wrapper.py
diff --git a/qe.ipynb b/qe.ipynb
index 7d8254bd7..58e173278 100644
--- a/qe.ipynb
+++ b/qe.ipynb
@@ -47,22 +47,24 @@
"\n",
" sys.modules[\"pybel\"] = __import__(\"openbabel\", globals(), locals(), [\"pybel\"]).pybel\n",
"except Exception:\n",
- " pass\n",
+ " pass"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from IPython.display import display\n",
"\n",
- "import urllib.parse as urlparse\n",
- "from datetime import datetime\n",
+ "from aiidalab_qe.app.wrapper import AppWrapperContoller, AppWrapperModel, AppWrapperView\n",
"\n",
- "import ipywidgets as ipw\n",
- "from importlib_resources import files\n",
- "from IPython.display import display\n",
- "from jinja2 import Environment\n",
+ "model = AppWrapperModel()\n",
+ "view = AppWrapperView()\n",
+ "controller = AppWrapperContoller(model, view)\n",
"\n",
- "from aiidalab_qe.app import App\n",
- "from aiidalab_qe.app.static import styles, templates\n",
- "from aiidalab_qe.version import __version__\n",
- "from aiidalab_widgets_base.bug_report import (\n",
- " install_create_github_issue_exception_handler,\n",
- ")"
+ "display(view)"
]
},
{
@@ -71,37 +73,38 @@
"metadata": {},
"outputs": [],
"source": [
- "env = Environment()\n",
+ "import urllib.parse as urlparse\n",
+ "\n",
+ "from aiidalab_qe.app.main import App\n",
+ "from aiidalab_widgets_base.bug_report import (\n",
+ " install_create_github_issue_exception_handler,\n",
+ ")\n",
"\n",
- "template = files(templates).joinpath(\"welcome.jinja\").read_text()\n",
- "style = files(styles).joinpath(\"style.css\").read_text()\n",
- "welcome_message = ipw.HTML(env.from_string(template).render(style=style))\n",
- "current_year = datetime.now().year\n",
- "footer = ipw.HTML(\n",
- " f'
Copyright (c) {current_year} AiiDAlab team Version: {__version__}
'\n",
+ "install_create_github_issue_exception_handler(\n",
+ " view.output,\n",
+ " url=\"https://github.com/aiidalab/aiidalab-qe/issues/new\",\n",
+ " labels=(\"bug\", \"automated-report\"),\n",
")\n",
"\n",
"url = urlparse.urlsplit(jupyter_notebook_url) # noqa F821\n",
"query = urlparse.parse_qs(url.query)\n",
"\n",
- "\n",
"app_with_work_chain_selector = App(qe_auto_setup=True)\n",
"# if a pk is provided in the query string, set it as the value of the work_chain_selector\n",
"if \"pk\" in query:\n",
" pk = int(query[\"pk\"][0])\n",
" app_with_work_chain_selector.work_chain_selector.value = pk\n",
"\n",
- "output = ipw.Output()\n",
- "install_create_github_issue_exception_handler(\n",
- " output,\n",
- " url=\"https://github.com/aiidalab/aiidalab-qe/issues/new\",\n",
- " labels=(\"bug\", \"automated-report\"),\n",
- ")\n",
- "\n",
- "with output:\n",
- " display(welcome_message, app_with_work_chain_selector, footer)\n",
- "\n",
- "display(output)"
+ "view.main.children = [app_with_work_chain_selector]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "controller.enable_toggles()"
]
}
],
diff --git a/src/aiidalab_qe/app/__init__.py b/src/aiidalab_qe/app/__init__.py
index ea2de96ed..8138fc07a 100644
--- a/src/aiidalab_qe/app/__init__.py
+++ b/src/aiidalab_qe/app/__init__.py
@@ -1,7 +1 @@
"""Package for the AiiDAlab QE app."""
-
-from .main import App
-
-__all__ = [
- "App",
-]
diff --git a/src/aiidalab_qe/app/static/styles/custom.css b/src/aiidalab_qe/app/static/styles/custom.css
index 24a625467..0cf6e78a3 100644
--- a/src/aiidalab_qe/app/static/styles/custom.css
+++ b/src/aiidalab_qe/app/static/styles/custom.css
@@ -6,3 +6,43 @@
max-width: none !important;
}
/* end override */
+
+.app-header {
+ margin-bottom: 1em;
+}
+
+.logo {
+ text-align: center;
+}
+
+#subtitle {
+ text-align: center;
+ font-style: italic;
+}
+
+.info-toggles {
+ margin: 0 auto;
+}
+.info-toggles button {
+ width: 100px;
+ margin: 1em 0.5em;
+}
+.info-toggles button:focus {
+ outline: none !important;
+}
+
+.guide ol {
+ list-style: none;
+}
+.guide p:not(:last-of-type) {
+ margin-bottom: 0.5em;
+}
+
+#loading {
+ text-align: center;
+ font-size: large;
+}
+
+footer {
+ text-align: right;
+}
diff --git a/src/aiidalab_qe/app/static/styles/infobox.css b/src/aiidalab_qe/app/static/styles/infobox.css
new file mode 100644
index 000000000..a25861e42
--- /dev/null
+++ b/src/aiidalab_qe/app/static/styles/infobox.css
@@ -0,0 +1,15 @@
+.info-box {
+ display: none;
+ margin: 2px;
+ padding: 1em;
+ border: 3px solid orangered;
+ background-color: #ffedcc;
+ border-radius: 1em;
+ -webkit-border-radius: 1em;
+ -moz-border-radius: 1em;
+ -ms-border-radius: 1em;
+ -o-border-radius: 1em;
+}
+.info-box p {
+ line-height: 24px;
+}
diff --git a/src/aiidalab_qe/app/static/templates/about.jinja b/src/aiidalab_qe/app/static/templates/about.jinja
new file mode 100644
index 000000000..0b617a231
--- /dev/null
+++ b/src/aiidalab_qe/app/static/templates/about.jinja
@@ -0,0 +1,10 @@
+
+
+ The Quantum ESPRESSO app
+ (or QE app for short) is a graphical front end for calculating materials properties using
+ Quantum ESPRESSO (QE). Each property is calculated by workflows powered by the
+ AiiDA engine , and maintained in the
+ aiida-quantumespresso plugin and many other plugins developed by the community.
+ for AiiDA.
+
+
diff --git a/src/aiidalab_qe/app/static/templates/guide.jinja b/src/aiidalab_qe/app/static/templates/guide.jinja
new file mode 100644
index 000000000..14b10f914
--- /dev/null
+++ b/src/aiidalab_qe/app/static/templates/guide.jinja
@@ -0,0 +1,42 @@
+
+
+ The QE app allows you to calculate properties in a simple 4-step process:
+
+
+
+
+ 🔍 Step 1: Select the structure you want to run.
+
+
+ ⚙️ Step 2: Select the properties you are interested in.
+
+
+ 💻 Step 3: Choose the computational resources you want to run on.
+
+
+ 🚀 Step 4: Submit your workflow.
+
+
+
+
+ New users can go straight to the first step and select their structure.
+
+
+
+ Completed workflows can be selected at the top of the app.
+
+
+
+ You can also check out the
+ basic tutorial to get started
+ with the Quantum ESPRESSO app, or try out the
+ advanced tutorial to learn
+ additional features offered by the app.
+
+
+
+ For a more in-depth dive into the app's features, please refer to the
+ how-to guides .
+
+
+
diff --git a/src/aiidalab_qe/app/static/templates/welcome.jinja b/src/aiidalab_qe/app/static/templates/welcome.jinja
deleted file mode 100644
index cdf8830a3..000000000
--- a/src/aiidalab_qe/app/static/templates/welcome.jinja
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-
-
-
-
-
-
Welcome to the AiiDAlab Quantum ESPRESSO app! 👋
-
- The
Quantum ESPRESSO app (or QE app for short) is a graphical front end for calculating materials properties using Quantum ESPRESSO (QE).
- Each property is calculated by workflows powered by the
AiiDA engine , and maintained in the
Quantum ESPRESSO plugin for AiiDA.
-
-
The QE app allows you to calculate properties in a simple 4-step process:
-
-
- 🔍 Step 1: Select the structure you want to run.
- ⚙️ Step 2: Select the properties you are interested in.
- 💻 Step 3: Choose the computational resources you want to run on.
- 🚀 Step 4: Submit your workflow.
-
-
-
New users can go straight to the first step and select their structure. Once you've already run some calculations, you can select the corresponding workflow using the dropdown below.
-
Happy computing! 🎉
-
-
-
diff --git a/src/aiidalab_qe/app/static/templates/workflow_failure.jinja b/src/aiidalab_qe/app/static/templates/workflow_failure.jinja
index fe4ea7e00..b7adb3a8a 100644
--- a/src/aiidalab_qe/app/static/templates/workflow_failure.jinja
+++ b/src/aiidalab_qe/app/static/templates/workflow_failure.jinja
@@ -1,9 +1,3 @@
-
-
-
-
diff --git a/src/aiidalab_qe/app/static/templates/workflow_summary.jinja b/src/aiidalab_qe/app/static/templates/workflow_summary.jinja
index 594e46150..01743dca4 100644
--- a/src/aiidalab_qe/app/static/templates/workflow_summary.jinja
+++ b/src/aiidalab_qe/app/static/templates/workflow_summary.jinja
@@ -1,9 +1,3 @@
-
-
-
-
diff --git a/src/aiidalab_qe/app/wrapper.py b/src/aiidalab_qe/app/wrapper.py
new file mode 100644
index 000000000..5c79ba36d
--- /dev/null
+++ b/src/aiidalab_qe/app/wrapper.py
@@ -0,0 +1,179 @@
+from __future__ import annotations
+
+import ipywidgets as ipw
+import traitlets
+
+
+def without_triggering(toggle: str):
+ """Decorator to prevent the other toggle from triggering its callback."""
+
+ def decorator(func):
+ def wrapper(self, change: dict):
+ """Toggle off other button without triggering its callback."""
+ view: AppWrapperView = self._view
+ button: ipw.ToggleButton = getattr(view, toggle)
+ callback = getattr(self, f"_on_{toggle}")
+ button.unobserve(callback, "value")
+ button.value = False
+ func(self, change)
+ button.observe(callback, "value")
+
+ return wrapper
+
+ return decorator
+
+
+class AppWrapperContoller:
+ """An MVC controller for `AppWrapper`."""
+
+ def __init__(
+ self,
+ model: AppWrapperModel,
+ view: AppWrapperView,
+ ) -> None:
+ """`AppWrapperController` constructor.
+
+ Parameters
+ ----------
+ `model` : `AppWrapperModel`
+ The associated model.
+ `view` : `AppWrapperView`
+ The associated view.
+ """
+ self._model = model
+ self._view = view
+ self._set_event_handlers()
+
+ def enable_toggles(self) -> None:
+ """Enable the toggle buttons."""
+ self._view.guide_toggle.disabled = False
+ self._view.about_toggle.disabled = False
+
+ @without_triggering("about_toggle")
+ def _on_guide_toggle(self, change: dict):
+ """Toggle the guide section."""
+ self._view.info_container.children = [self._view.guide] if change["new"] else []
+ self._view.info_container.layout.display = "flex" if change["new"] else "none"
+
+ @without_triggering("guide_toggle")
+ def _on_about_toggle(self, change: dict):
+ """Toggle the about section."""
+ self._view.info_container.children = [self._view.about] if change["new"] else []
+ self._view.info_container.layout.display = "flex" if change["new"] else "none"
+
+ def _set_event_handlers(self) -> None:
+ """Set up event handlers."""
+ self._view.guide_toggle.observe(self._on_guide_toggle, "value")
+ self._view.about_toggle.observe(self._on_about_toggle, "value")
+
+
+class AppWrapperModel(traitlets.HasTraits):
+ """An MVC model for `AppWrapper`."""
+
+ def __init__(self):
+ """`AppWrapperModel` constructor."""
+
+
+class AppWrapperView(ipw.VBox):
+ """An MVC view for `AppWrapper`."""
+
+ def __init__(self) -> None:
+ """`AppWrapperView` constructor."""
+
+ ################# LAZY LOADING #################
+
+ from datetime import datetime
+
+ from importlib_resources import files
+ from IPython.display import Image, display
+ from jinja2 import Environment
+
+ from aiidalab_qe.app.static import templates
+ from aiidalab_qe.common.infobox import InfoBox
+ from aiidalab_qe.version import __version__
+
+ #################################################
+
+ self.output = ipw.Output()
+
+ logo_img = Image(
+ filename="docs/source/_static/logo.png",
+ width="700",
+ )
+ logo = ipw.Output()
+ with logo:
+ display(logo_img)
+ logo.add_class("logo")
+
+ subtitle = ipw.HTML("
🎉 Happy computing 🎉 ")
+
+ self.guide_toggle = ipw.ToggleButton(
+ button_style="",
+ icon="question",
+ value=False,
+ description="Guide",
+ tooltip="Learn how to use the app",
+ disabled=True,
+ )
+
+ self.about_toggle = ipw.ToggleButton(
+ button_style="",
+ icon="info",
+ value=False,
+ description="About",
+ tooltip="Learn about the app",
+ disabled=True,
+ )
+
+ info_toggles = ipw.HBox(
+ children=[
+ self.guide_toggle,
+ self.about_toggle,
+ ]
+ )
+ info_toggles.add_class("info-toggles")
+
+ env = Environment()
+ guide_template = files(templates).joinpath("guide.jinja").read_text()
+ about_template = files(templates).joinpath("about.jinja").read_text()
+
+ self.guide = ipw.HTML(env.from_string(guide_template).render())
+ self.about = ipw.HTML(env.from_string(about_template).render())
+
+ self.info_container = InfoBox()
+
+ header = ipw.VBox(
+ children=[
+ logo,
+ subtitle,
+ info_toggles,
+ self.info_container,
+ ],
+ )
+ header.add_class("app-header")
+
+ loading = ipw.HTML("""
+
+ Loading the app
+
+ """)
+
+ self.main = ipw.VBox(children=[loading])
+
+ current_year = datetime.now().year
+ footer = ipw.HTML(f"""
+
+ Copyright (c) {current_year} AiiDAlab team
+ Version: {__version__}
+
+ """)
+
+ super().__init__(
+ layout={},
+ children=[
+ self.output,
+ header,
+ self.main,
+ footer,
+ ],
+ )
diff --git a/src/aiidalab_qe/common/infobox.py b/src/aiidalab_qe/common/infobox.py
new file mode 100644
index 000000000..86a7eb26b
--- /dev/null
+++ b/src/aiidalab_qe/common/infobox.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+import ipywidgets as ipw
+
+
+class InfoBox(ipw.VBox):
+ """The `InfoBox` component is used to provide additional info regarding a widget or an app."""
+
+ def __init__(self, classes: list[str] | None = None, **kwargs):
+ """`InfoBox` constructor.
+
+ Parameters
+ ----------
+ `classes` : `list[str]`, optional
+ One or more CSS classes.
+ """
+ super().__init__(**kwargs)
+ self.add_class("info-box")
+ for custom_classes in classes or []:
+ for custom_class in custom_classes.split(" "):
+ if custom_class:
+ self.add_class(custom_class)
diff --git a/tests/test_infobox.py b/tests/test_infobox.py
new file mode 100644
index 000000000..892335da9
--- /dev/null
+++ b/tests/test_infobox.py
@@ -0,0 +1,16 @@
+from aiidalab_qe.common.infobox import InfoBox
+
+
+def test_infobox_classes():
+ """Test `InfoBox` classes."""
+ custom_classes = ["custom-1", "custom-2 custom-3"]
+ infobox = InfoBox(classes=custom_classes)
+ assert all(
+ css_class in infobox._dom_classes
+ for css_class in (
+ "info-box",
+ "custom-1",
+ "custom-2",
+ "custom-3",
+ )
+ )
diff --git a/tests/test_wrapper.py b/tests/test_wrapper.py
new file mode 100644
index 000000000..609fa40d8
--- /dev/null
+++ b/tests/test_wrapper.py
@@ -0,0 +1,64 @@
+from aiidalab_qe.app.wrapper import AppWrapperContoller, AppWrapperModel, AppWrapperView
+
+
+class TestWrapper:
+ def test_enable_toggles(self):
+ """Test enable_toggles method."""
+ self._instansiate_mvc_components()
+ assert self.view.guide_toggle.disabled is True
+ assert self.view.about_toggle.disabled is True
+ self.controller.enable_toggles()
+ assert self.view.guide_toggle.disabled is False
+ assert self.view.about_toggle.disabled is False
+
+ def test_guide_toggle(self):
+ """Test guide_toggle method."""
+ self._instansiate_mvc_components()
+ self.controller.enable_toggles()
+ self.controller._on_guide_toggle({"new": True})
+ self._assert_guide_is_on()
+ self.controller._on_guide_toggle({"new": False})
+ self._assert_no_guide_info()
+
+ def test_about_toggle(self):
+ """Test about_toggle method."""
+ self._instansiate_mvc_components()
+ self.controller.enable_toggles()
+ self.controller._on_about_toggle({"new": True})
+ self._assert_about_is_on()
+ self.controller._on_about_toggle({"new": False})
+ self._assert_no_guide_info()
+
+ def test_toggle_switch(self):
+ """Test toggle_switch method."""
+ self._instansiate_mvc_components()
+ self.controller.enable_toggles()
+ self._assert_no_guide_info()
+ self.controller._on_guide_toggle({"new": True})
+ self._assert_guide_is_on()
+ self.controller._on_about_toggle({"new": True})
+ self._assert_about_is_on()
+ self.controller._on_guide_toggle({"new": True})
+ self._assert_guide_is_on()
+ self.controller._on_guide_toggle({"new": False})
+ self._assert_no_guide_info()
+
+ def _assert_guide_is_on(self):
+ """Assert guide is on."""
+ assert len(self.view.info_container.children) == 1
+ assert self.view.guide in self.view.info_container.children
+
+ def _assert_about_is_on(self):
+ """Assert about is on."""
+ assert len(self.view.info_container.children) == 1
+ assert self.view.about in self.view.info_container.children
+
+ def _assert_no_guide_info(self):
+ """Assert no info is shown."""
+ assert len(self.view.info_container.children) == 0
+
+ def _instansiate_mvc_components(self):
+ """Instansiate `AppWrapper` MVC components."""
+ self.model = AppWrapperModel()
+ self.view = AppWrapperView()
+ self.controller = AppWrapperContoller(self.model, self.view)