From ff247c00a661a2f85db1de3a37db16d13ce12984 Mon Sep 17 00:00:00 2001 From: Edan Bainglass Date: Mon, 9 Dec 2024 07:24:03 +0000 Subject: [PATCH] Implement in-app guide mechanism --- src/aiidalab_qe/app/configuration/__init__.py | 56 ++++++++++++++++- .../app/configuration/advanced/advanced.py | 19 ++++-- .../app/configuration/basic/workflow.py | 22 +++++++ src/aiidalab_qe/app/main.py | 12 ++++ src/aiidalab_qe/app/static/styles/custom.css | 7 --- src/aiidalab_qe/app/static/styles/infobox.css | 39 +++++++++--- .../app/static/templates/guide.jinja | 3 + src/aiidalab_qe/app/structure/__init__.py | 52 ++++++++++++++++ src/aiidalab_qe/app/submission/__init__.py | 20 +++++++ src/aiidalab_qe/app/wrapper.py | 25 ++++++-- src/aiidalab_qe/common/infobox.py | 60 +++++++++++++++++++ src/aiidalab_qe/plugins/bands/setting.py | 14 +++++ src/aiidalab_qe/plugins/pdos/setting.py | 14 +++++ tests/test_infobox.py | 16 ++++- tests/test_wrapper.py | 12 ++-- 15 files changed, 340 insertions(+), 31 deletions(-) diff --git a/src/aiidalab_qe/app/configuration/__init__.py b/src/aiidalab_qe/app/configuration/__init__.py index b9775ea0b..2adb18c3c 100644 --- a/src/aiidalab_qe/app/configuration/__init__.py +++ b/src/aiidalab_qe/app/configuration/__init__.py @@ -10,6 +10,7 @@ from aiidalab_qe.app.parameters import DEFAULT_PARAMETERS from aiidalab_qe.app.utils import get_entry_items +from aiidalab_qe.common.infobox import InAppGuide from aiidalab_qe.common.panel import ( ConfigurationSettingsModel, ConfigurationSettingsPanel, @@ -114,10 +115,39 @@ def render(self): children=[ ipw.VBox( children=[ + InAppGuide( + children=[ + ipw.HTML(""" +
+ Here we select the properties to calculate. +
+ Select Electronic band structure and + Projected density of states (PDOS) +
+
+ """) + ], + ), *self.property_children, ] ), - self.tabs, + ipw.VBox( + children=[ + InAppGuide( + children=[ + ipw.HTML(""" +
+ Here we can customize the calculation parameters. +
+ Click on each tab to customize its settings. +
+
+ """) + ], + ), + self.tabs, + ], + ), ], layout=ipw.Layout(margin="10px 2px"), selected_index=None, @@ -141,6 +171,30 @@ def render(self): self.confirm_button.on_click(self.confirm) self.children = [ + InAppGuide( + children=[ + ipw.HTML(""" +
+ In this step, we define the workflow tasks, including structure + relaxation and which properties to compute, and select the + parameters of the calculations. +
+

Tasks

+
    +
  1. Select Full geometry relaxation
  2. +
  3. Open Step 2.1 to select properties
  4. +
  5. Open Step 2.2 to customize parameters
  6. +
  7. Click Confirm to proceed
  8. +
+
+
+ Note: Changes after confirmation will unconfirm this + step and reset the following steps. +
+
+ """) + ], + ), self.structure_set_message, ipw.HTML("""
diff --git a/src/aiidalab_qe/app/configuration/advanced/advanced.py b/src/aiidalab_qe/app/configuration/advanced/advanced.py index 5779d707d..e2b7e8a96 100644 --- a/src/aiidalab_qe/app/configuration/advanced/advanced.py +++ b/src/aiidalab_qe/app/configuration/advanced/advanced.py @@ -5,6 +5,7 @@ import ipywidgets as ipw +from aiidalab_qe.common.infobox import InAppGuide from aiidalab_qe.common.panel import ConfigurationSettingsPanel from .hubbard import ( @@ -274,11 +275,19 @@ def render(self): self.pseudos.render() self.children = [ - ipw.HTML(""" -
-

Advanced Settings

-
- """), + InAppGuide( + children=[ + ipw.HTML(""" +
+ The Advanced settings allow you to finely tune the calculation. +
+ In this walkthrough, we will not modify advanced settings + and proceed with the defaults. +
+
+ """) + ], + ), ipw.HBox( children=[ self.clean_workdir, diff --git a/src/aiidalab_qe/app/configuration/basic/workflow.py b/src/aiidalab_qe/app/configuration/basic/workflow.py index 4e4147b25..75fdce849 100644 --- a/src/aiidalab_qe/app/configuration/basic/workflow.py +++ b/src/aiidalab_qe/app/configuration/basic/workflow.py @@ -6,6 +6,7 @@ import ipywidgets as ipw from aiidalab_qe.app.configuration.basic.model import BasicConfigurationSettingsModel +from aiidalab_qe.common.infobox import InAppGuide from aiidalab_qe.common.panel import ConfigurationSettingsPanel @@ -60,6 +61,27 @@ def render(self): ) self.children = [ + InAppGuide( + children=[ + ipw.HTML(""" +
+ The basic settings panel provides top-level calculation + settings including the electronic and magnetic properties of + the material. It also provides a choice of three protocols + that pre-configure many calculation settings, balancing + speed with accuracy. +
+ Select the fast protocol +
+
+ Note: Due to the limited resources provided for the + demo server, we select the fast protocol to + reduce the cost of the calculation. +
+
+ """) + ], + ), ipw.HTML("""
Below you can indicate both if the material should be treated as an diff --git a/src/aiidalab_qe/app/main.py b/src/aiidalab_qe/app/main.py index 4dbd02f9e..2cc5fc12c 100644 --- a/src/aiidalab_qe/app/main.py +++ b/src/aiidalab_qe/app/main.py @@ -17,6 +17,7 @@ from aiidalab_qe.app.structure.model import StructureStepModel from aiidalab_qe.app.submission import SubmitQeAppWorkChainStep from aiidalab_qe.app.submission.model import SubmissionStepModel +from aiidalab_qe.common.infobox import InAppGuide from aiidalab_qe.common.widgets import LoadingWidget from aiidalab_widgets_base import WizardAppWidget @@ -109,6 +110,17 @@ def __init__(self, qe_auto_setup=True): super().__init__( children=[ + InAppGuide( + children=[ + ipw.HTML(""" +
+ You've activated an in-app guide. Follow along below to learn + how to use the Quantum ESPRESSO app. +
+ """) + ], + classes=["guide-warning"], + ), self.new_workchain_button, self._process_loading_message, self._wizard_app_widget, diff --git a/src/aiidalab_qe/app/static/styles/custom.css b/src/aiidalab_qe/app/static/styles/custom.css index 053cf8b92..7cd2171b9 100644 --- a/src/aiidalab_qe/app/static/styles/custom.css +++ b/src/aiidalab_qe/app/static/styles/custom.css @@ -31,13 +31,6 @@ outline: none !important; } -.guide ol { - list-style: none; -} -.guide p:not(:last-of-type) { - margin-bottom: 0.5em; -} - .loading { margin: 0 auto; padding: 5px; diff --git a/src/aiidalab_qe/app/static/styles/infobox.css b/src/aiidalab_qe/app/static/styles/infobox.css index a25861e42..7d7b3ccc9 100644 --- a/src/aiidalab_qe/app/static/styles/infobox.css +++ b/src/aiidalab_qe/app/static/styles/infobox.css @@ -2,14 +2,39 @@ 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; + border-left: 3px solid #add8e6; + background-color: #d9edf7; } .info-box p { line-height: 24px; } +.info-box.in-app-guide.show { + display: flex !important; +} +.guide ol { + list-style: none; +} +.guide p:not(:last-of-type) { + margin-bottom: 0.5em; +} +.guide-warning { + background-color: #fcf8e3; + border-color: #ffe4c4; + margin-bottom: 1em; +} +.in-app-guide .alert { + color: black; + margin: 10px 0; + padding: 10px 14px; + border-width: 0; + border-left-width: 3px; +} +.in-app-guide .alert.alert-warning { + border-color: #ffe4c4; +} +.in-app-guide .alert.alert-success { + border-color: #8fbc8f; +} +.in-app-guide h4 { + font-weight: bold; +} diff --git a/src/aiidalab_qe/app/static/templates/guide.jinja b/src/aiidalab_qe/app/static/templates/guide.jinja index d72d7beef..0a1a5bcf1 100644 --- a/src/aiidalab_qe/app/static/templates/guide.jinja +++ b/src/aiidalab_qe/app/static/templates/guide.jinja @@ -42,5 +42,8 @@ For a more in-depth dive into the app's features, please refer to the how-to guides.

+ +

+ Alternatively, you can select one of our in-app guides below to walk through an example use-case.

diff --git a/src/aiidalab_qe/app/structure/__init__.py b/src/aiidalab_qe/app/structure/__init__.py index 998be5cf2..ebc5bfd19 100644 --- a/src/aiidalab_qe/app/structure/__init__.py +++ b/src/aiidalab_qe/app/structure/__init__.py @@ -14,6 +14,7 @@ LazyLoadedOptimade, LazyLoadedStructureBrowser, ) +from aiidalab_qe.common.infobox import InAppGuide from aiidalab_widgets_base import ( BasicCellEditor, BasicStructureEditor, @@ -151,6 +152,57 @@ def render(self): ) self.children = [ + InAppGuide( + children=[ + ipw.HTML(""" +
+ In this step, you can select a structure as follows: +
    +
  • + Upload file: + upload a structure file from your computer. +
  • +
  • + OPTIMADE: + search for structures in the OPTIMADE database. +
  • +
  • + AiiDA database: + search for structures in your AiiDA database. +
  • +
  • + From Examples: + select a structure from a list of example structures. +
  • +
+ Once selected, you may inspect the structure. You can also edit + the structure using the available structure editors. When done, + you can choose to modify the structure label and/or provide a + description. These will be attached to the input structure node + in your AiiDA database. When you are ready, click "Confirm" to + proceed to the next step. +
+
+

Tasks

+
    +
  1. Click on the From examples tab
  2. +
  3. Select Gold from the dropdown list
  4. +
  5. Click the Confirm button to proceed.
  6. +
+
+
+ Warning: If the confirmed structure is not yet stored + in the AiiDA database, it will be stored automatically when + you proceed to the next step. +
+
+ Warning: Changes after confirmation will unconfirm + this step and reset the following steps. +
+
+ """), + ], + ), ipw.HTML("""

Select a structure from one of the following sources and then diff --git a/src/aiidalab_qe/app/submission/__init__.py b/src/aiidalab_qe/app/submission/__init__.py index 344535f3a..cb9e8420e 100644 --- a/src/aiidalab_qe/app/submission/__init__.py +++ b/src/aiidalab_qe/app/submission/__init__.py @@ -11,6 +11,7 @@ from aiidalab_qe.app.parameters import DEFAULT_PARAMETERS from aiidalab_qe.app.utils import get_entry_items from aiidalab_qe.common.code import PluginCodes, PwCodeModel +from aiidalab_qe.common.infobox import InAppGuide from aiidalab_qe.common.panel import PluginResourceSettingsModel, ResourceSettingsPanel from aiidalab_qe.common.setup_codes import QESetupWidget from aiidalab_qe.common.setup_pseudos import PseudosInstallWidget @@ -158,6 +159,25 @@ def render(self): ) self.children = [ + InAppGuide( + children=[ + ipw.HTML(""" +

+ In this step, we define the resources to be used in the + calculation. The global resources are used to define resources + across all workflow calculations. Optionally, you can override + the resource settings for specific calculations. +
+

Tasks

+
    +
  1. Select resources
  2. +
  3. Click Submit to proceed
  4. +
+
+
+ """) + ], + ), ipw.HTML("""

Codes

diff --git a/src/aiidalab_qe/app/wrapper.py b/src/aiidalab_qe/app/wrapper.py index a22248fbb..14ce07d74 100644 --- a/src/aiidalab_qe/app/wrapper.py +++ b/src/aiidalab_qe/app/wrapper.py @@ -1,7 +1,8 @@ from __future__ import annotations import ipywidgets as ipw -import traitlets +import traitlets as tl +from IPython.display import display from aiidalab_qe.common.widgets import LoadingWidget @@ -56,7 +57,10 @@ def enable_toggles(self) -> None: def _on_guide_toggle(self, change: dict): """Toggle the guide section.""" if change["new"]: - self._view.info_container.children = [self._view.guide] + self._view.info_container.children = [ + self._view.guide, + self._view.guide_selection, + ] self._view.info_container.layout.display = "flex" self._view.job_history_toggle.value = False else: @@ -89,14 +93,21 @@ def _on_job_history_toggle(self, change: dict): else: self._view.main.children = self._old_view + def _on_guide_select(self, change: dict): + """Sets the current active guide.""" + from aiidalab_qe.common.infobox import guide_manager + + guide_manager.active_guide = change["new"] + 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") self._view.job_history_toggle.observe(self._on_job_history_toggle, "value") + self._view.guide_selection.observe(self._on_guide_select, "value") -class AppWrapperModel(traitlets.HasTraits): +class AppWrapperModel(tl.HasTraits): """An MVC model for `AppWrapper`.""" def __init__(self): @@ -114,7 +125,7 @@ def __init__(self) -> None: from datetime import datetime from importlib_resources import files - from IPython.display import Image, display + from IPython.display import Image from jinja2 import Environment from aiidalab_qe.app.static import templates @@ -184,6 +195,12 @@ def __init__(self) -> None: self.guide = ipw.HTML(env.from_string(guide_template).render()) self.about = ipw.HTML(env.from_string(about_template).render()) + self.guide_selection = ipw.RadioButtons( + options=["none", "basic"], + description="Guides:", + value="none", + ) + self.job_history = QueryInterface() self.info_container = InfoBox() diff --git a/src/aiidalab_qe/common/infobox.py b/src/aiidalab_qe/common/infobox.py index 86a7eb26b..a665991f3 100644 --- a/src/aiidalab_qe/common/infobox.py +++ b/src/aiidalab_qe/common/infobox.py @@ -1,6 +1,7 @@ from __future__ import annotations import ipywidgets as ipw +import traitlets as tl class InfoBox(ipw.VBox): @@ -20,3 +21,62 @@ def __init__(self, classes: list[str] | None = None, **kwargs): for custom_class in custom_classes.split(" "): if custom_class: self.add_class(custom_class) + + +class GuideManager(tl.HasTraits): + active_guide = tl.Unicode("none") + + +guide_manager = GuideManager() + + +class InAppGuide(InfoBox): + """The `InfoAppGuide` is used to set up in-app guides that may be toggle in unison.""" + + def __init__( + self, + guide_class: str = "qe-app", + classes: list[str] | None = None, + **kwargs, + ): + """`InAppGuide` constructor. + + Parameters + ---------- + `guide_class` : `str`, optional + The identifier used to toggle the guide. + The default `qe-app` identifies built-in guide sections. + `classes` : `list[str]`, optional + One or more CSS classes. + """ + + self.guide_class = guide_class + + super().__init__( + classes=[ + "in-app-guide", + *(classes or []), + ], + **kwargs, + ) + + guide_manager.observe( + self._on_active_guide_change, + "active_guide", + ) + + # This manual toggle call is necessary because the guide + # may be contained in a component that was not yet rendered + # when a guide was selected. + self._toggle_guide() + + def _on_active_guide_change(self, _): + self._toggle_guide() + + def _toggle_guide(self): + active_guide = guide_manager.active_guide + not_generic = self.guide_class != "qe-app" + if active_guide == "none" or (not_generic and active_guide != self.guide_class): + self.layout.display = "none" + else: + self.layout.display = "flex" diff --git a/src/aiidalab_qe/plugins/bands/setting.py b/src/aiidalab_qe/plugins/bands/setting.py index efbbdb37b..4b70cdff3 100644 --- a/src/aiidalab_qe/plugins/bands/setting.py +++ b/src/aiidalab_qe/plugins/bands/setting.py @@ -2,6 +2,7 @@ import ipywidgets as ipw +from aiidalab_qe.common.infobox import InAppGuide from aiidalab_qe.common.panel import ConfigurationSettingsPanel from aiidalab_qe.plugins.bands.model import BandsConfigurationSettingsModel @@ -26,6 +27,19 @@ def render(self): ) self.children = [ + InAppGuide( + children=[ + ipw.HTML(""" +
+ Here we configure the settings for computing the band + structure. +
+ Check Fat bands calculation +
+
+ """) + ], + ), ipw.HTML("""

Settings

diff --git a/src/aiidalab_qe/plugins/pdos/setting.py b/src/aiidalab_qe/plugins/pdos/setting.py index 462f3aa66..e08c361a1 100644 --- a/src/aiidalab_qe/plugins/pdos/setting.py +++ b/src/aiidalab_qe/plugins/pdos/setting.py @@ -2,6 +2,7 @@ import ipywidgets as ipw +from aiidalab_qe.common.infobox import InAppGuide from aiidalab_qe.common.panel import ConfigurationSettingsPanel from .model import PdosConfigurationSettingsModel @@ -107,6 +108,19 @@ def render(self): ) self.children = [ + InAppGuide( + children=[ + ipw.HTML(""" +
+ Here we configure the settings for computing the projected + density of states, or PDOS. +
+ ??? +
+
+ """) + ], + ), ipw.HTML("""

Settings

diff --git a/tests/test_infobox.py b/tests/test_infobox.py index 892335da9..314d7928d 100644 --- a/tests/test_infobox.py +++ b/tests/test_infobox.py @@ -1,4 +1,4 @@ -from aiidalab_qe.common.infobox import InfoBox +from aiidalab_qe.common.infobox import InAppGuide, InfoBox def test_infobox_classes(): @@ -14,3 +14,17 @@ def test_infobox_classes(): "custom-3", ) ) + + +def test_in_app_guide(): + """Test `InAppGuide` class.""" + guide_class = "some_guide" + in_app_guide = InAppGuide(guide_class=guide_class) + assert all( + css_class in in_app_guide._dom_classes + for css_class in ( + "info-box", + "in-app-guide", + f"{guide_class}-guide-identifier", + ) + ) diff --git a/tests/test_wrapper.py b/tests/test_wrapper.py index 609fa40d8..e4eed6a1c 100644 --- a/tests/test_wrapper.py +++ b/tests/test_wrapper.py @@ -18,7 +18,7 @@ def test_guide_toggle(self): self.controller._on_guide_toggle({"new": True}) self._assert_guide_is_on() self.controller._on_guide_toggle({"new": False}) - self._assert_no_guide_info() + self._assert_no_info() def test_about_toggle(self): """Test about_toggle method.""" @@ -27,13 +27,13 @@ def test_about_toggle(self): self.controller._on_about_toggle({"new": True}) self._assert_about_is_on() self.controller._on_about_toggle({"new": False}) - self._assert_no_guide_info() + self._assert_no_info() def test_toggle_switch(self): """Test toggle_switch method.""" self._instansiate_mvc_components() self.controller.enable_toggles() - self._assert_no_guide_info() + self._assert_no_info() self.controller._on_guide_toggle({"new": True}) self._assert_guide_is_on() self.controller._on_about_toggle({"new": True}) @@ -41,11 +41,11 @@ def test_toggle_switch(self): self.controller._on_guide_toggle({"new": True}) self._assert_guide_is_on() self.controller._on_guide_toggle({"new": False}) - self._assert_no_guide_info() + self._assert_no_info() def _assert_guide_is_on(self): """Assert guide is on.""" - assert len(self.view.info_container.children) == 1 + assert len(self.view.info_container.children) == 2 assert self.view.guide in self.view.info_container.children def _assert_about_is_on(self): @@ -53,7 +53,7 @@ def _assert_about_is_on(self): assert len(self.view.info_container.children) == 1 assert self.view.about in self.view.info_container.children - def _assert_no_guide_info(self): + def _assert_no_info(self): """Assert no info is shown.""" assert len(self.view.info_container.children) == 0