Skip to content

Commit

Permalink
Add auto-detected home warning message mechanism (#183)
Browse files Browse the repository at this point in the history
This PR adds a hard-coded path for displaying a notification widget between the home controls and the app accordion. The mechanism detects if `/home/jovyan/.aiidalab/home_app_warning.md` is present, and if so, displays its contents in a `Markdown` widget.

As mentioned, the present mechanism is hard-coded in this implementation. However, a more flexible system (see #54) is soon to be discussed to expand the concept of user notifications beyond the home app and with a more general scope.

Thanks to @danielhollas for assisting with authoring a test :)
---------

Co-authored-by: Daniel Hollas <[email protected]>
  • Loading branch information
edan-bainglass and danielhollas authored Dec 5, 2024
1 parent e05a322 commit cdc403a
Show file tree
Hide file tree
Showing 5 changed files with 92 additions and 28 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ jobs:
run: echo "JUPYTER_TOKEN=$(openssl rand -hex 32)" >> $GITHUB_ENV

- name: Run pytest
run: pytest -v --driver ${{ matrix.browser }} tests_notebooks
run: pytest -v --driver ${{ matrix.browser }} tests_notebooks/
env:
TAG: edge

Expand Down
58 changes: 42 additions & 16 deletions home/start_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import json
from glob import glob
from os import path
from pathlib import Path

import ipywidgets as ipw
import traitlets
Expand All @@ -29,7 +30,7 @@ def create_app_widget_move_buttons(name):


class AiidaLabHome:
"""Class that mananges the appearance of the AiiDAlab home page."""
"""Class that manages the appearance of the AiiDAlab home page."""

def __init__(self):
self.config_fn = ".launcher.json"
Expand All @@ -40,16 +41,16 @@ def _create_app_widget(self, name):
"""Create the widget representing the app on the home screen."""
config = self.read_config()
app = AiidaLabApp(name, None, AIIDALAB_APPS)

if name == "home":
app_widget = AppWidget(app, allow_move=False, allow_manage=False)
else:
app_widget = CollapsableAppWidget(app, allow_move=True)
app_widget.hidden = name in config["hidden"]
app_widget.observe(self._on_app_widget_change_hidden, names=["hidden"])

app_widget = CollapsableAppWidget(app, allow_move=True)
app_widget.hidden = name in config["hidden"]
app_widget.observe(self._on_app_widget_change_hidden, names=["hidden"])
return app_widget

def _create_home_widget(self):
"""Create the home app widget."""
app = AiidaLabApp("home", None, AIIDALAB_APPS)
return AppWidget(app, allow_move=False, allow_manage=False)

def _on_app_widget_change_hidden(self, change):
"""Record whether a app widget is hidden on the home screen in the config file."""
config = self.read_config()
Expand All @@ -72,16 +73,28 @@ def read_config(self):
def render(self):
"""Rendering all apps."""

displayed_apps = []
home = self._create_home_widget()
children = [home]

config_dir = Path.home() / ".aiidalab"
warning_file = config_dir / "home_app_warning.md"

if warning_file.exists():
content = warning_file.read_text()
notification = self._create_notification(content)
children.append(notification)

apps = self.load_apps()

for name in apps:
# Create app widget if it has not been created yet.
if name not in self._app_widgets:
self._app_widgets[name] = self._create_app_widget(name)

displayed_apps.append(self._app_widgets[name])
self.output.children = displayed_apps
children.append(self._app_widgets[name])

self.output.children = children

return self.output

def load_apps(self):
Expand All @@ -98,7 +111,7 @@ def load_apps(self):
apps.sort(key=lambda x: order.index(x) if x in order else -1)
config["order"] = apps
self.write_config(config)
return ["home", *apps]
return apps

def move_updown(self, name, delta):
"""Move the app up/down on the start page."""
Expand All @@ -111,6 +124,19 @@ def move_updown(self, name, delta):
config["order"] = order
self.write_config(config)

def _create_notification(self, content):
from IPython.display import Markdown, display
from jinja2 import Environment

env = Environment()
notification = env.from_string(content).render()
output = ipw.Output()
notification_widget = ipw.VBox(children=[output])
notification_widget.add_class("home-notification")
with output:
display(Markdown(notification))
return notification_widget


class AppWidget(ipw.VBox):
"""Widget that represents an app as part of the home page."""
Expand All @@ -119,7 +145,7 @@ def __init__(self, app, allow_move=False, allow_manage=True):
self.app = app

launcher = load_widget(app.name)
launcher.layout = ipw.Layout(width="900px")
launcher.layout.flex = "1" # fill available space

header_items = []
footer_items = []
Expand All @@ -128,7 +154,7 @@ def __init__(self, app, allow_move=False, allow_manage=True):
app_status_info = AppStatusInfoWidget()
for trait in ("detached", "compatible", "remote_update_status"):
ipw.dlink((app, trait), (app_status_info, trait))
app_status_info.layout.margin = "0px 0px 0px 800px"
app_status_info.layout.margin = "0px 0px 0px auto"
header_items.append(app_status_info)

footer_items.append(
Expand All @@ -150,7 +176,7 @@ def __init__(self, app, allow_move=False, allow_manage=True):

footer = ipw.HTML(" ".join(footer_items), layout={"width": "initial"})
footer.layout.margin = (
"0px 0px 0px 700px" if allow_manage else "0px 0px 20px 0px"
"0px 0px 0px auto" if allow_manage else "0px 0px 20px 0px"
)

super().__init__(children=[header, body, footer])
Expand Down
8 changes: 7 additions & 1 deletion start.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@
" .output_subarea {\n",
" max-width: none !important;\n",
" }\n",
" .home-notification {\n",
" background-color: antiquewhite;\n",
" margin: 2px;\n",
" padding: 8px;\n",
" border: 1px solid red;\n",
" }\n",
"</style>\n"
]
},
Expand Down Expand Up @@ -64,7 +70,7 @@
" home.move_updown(parsed_url[\"move_up\"][0], -1)\n",
"elif \"move_down\" in parsed_url:\n",
" home.move_updown(parsed_url[\"move_down\"][0], +1)\n",
"display(home.render())"
"home.render()"
]
},
{
Expand Down
41 changes: 31 additions & 10 deletions tests_notebooks/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,25 +41,39 @@ def docker_compose(docker_services):


@pytest.fixture(scope="session")
def aiidalab_exec(docker_compose):
def execute(command, user=None, **kwargs):
workdir = "/home/jovyan/apps/home"
if user:
command = f"exec --workdir {workdir} -T --user={user} aiidalab {command}"
else:
command = f"exec --workdir {workdir} -T aiidalab {command}"
def aiidalab_exec(docker_compose, nb_user):
"""Execute command inside the AiiDAlab test container"""

def execute(command, user=None, **kwargs):
workdir = f"/home/{nb_user}/apps/home"
if user is None:
user = nb_user
command = (
f"exec --workdir {workdir} -T --user={user} aiidalab bash -c '{command}'"
)
return docker_compose.execute(command, **kwargs)

return execute


@pytest.fixture(scope="session")
def nb_user():
return "jovyan"


@pytest.fixture
def create_warning_file(nb_user, aiidalab_exec):
config_folder = f"/home/{nb_user}/.aiidalab"
aiidalab_exec(f"mkdir -p {config_folder}")
aiidalab_exec(f"echo Warning! > {config_folder}/home_app_warning.md")


@pytest.fixture(scope="session", autouse=True)
def notebook_service(docker_ip, docker_services, aiidalab_exec):
def notebook_service(docker_ip, docker_services, aiidalab_exec, nb_user):
"""Ensure that HTTP service is up and responsive."""
# Directory ~/apps/home/ is mounted by docker,
# make it writeable for jovyan user, needed for `pip install`
aiidalab_exec("chmod -R a+rw /home/jovyan/apps/home", user="root")
aiidalab_exec(f"chmod -R a+rw /home/{nb_user}/apps/home", user="root")

aiidalab_exec("pip install --no-cache-dir .")

Expand All @@ -75,6 +89,12 @@ def notebook_service(docker_ip, docker_services, aiidalab_exec):

@pytest.fixture(scope="function")
def selenium_driver(selenium, notebook_service):
"""This is the main fixture to be used in tests.
We're already guaranteed that the container is up and responding to HTTP requests.
(via `notebook_service` fixture).
"""

def _selenium_driver(nb_path, url_params=None):
url, token = notebook_service
url_with_token = urljoin(url, f"apps/apps/home/{nb_path}?token={token}")
Expand Down Expand Up @@ -109,8 +129,9 @@ def _selenium_driver(nb_path, url_params=None):
@pytest.fixture
def final_screenshot(request, screenshot_dir, selenium):
"""Take screenshot at the end of the test.
Screenshot name is generated from the test function name
by stripping the 'test_' prefix
by stripping the 'test_' prefix.
"""
screenshot_name = f"{request.function.__name__[5:]}.png"
screenshot_path = Path.joinpath(screenshot_dir, screenshot_name)
Expand Down
11 changes: 11 additions & 0 deletions tests_notebooks/test_notification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from selenium.webdriver.common.by import By


def test_home_notification(selenium_driver, create_warning_file, final_screenshot):
selenium = selenium_driver("start.ipynb")
selenium.set_window_size(1000, 941)
notifications = selenium.find_elements(By.CLASS_NAME, "home-notification")
assert len(notifications) == 1
home_warning = notifications[0]
content_element = home_warning.find_element(By.TAG_NAME, "p")
assert content_element.text == "Warning!"

0 comments on commit cdc403a

Please sign in to comment.