Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add info box widgets with CSS stylesheets #623

Closed
wants to merge 10 commits into from
39 changes: 25 additions & 14 deletions aiidalab_widgets_base/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""Reusable widgets for AiiDAlab applications."""

from aiida.manage import get_profile

_WARNING_TEMPLATE = """
<div style="background-color: #f7f7f7; border: 2px solid #e0e0e0; padding: 20px; border-radius: 5px;">
<p style="font-size: 16px; font-weight: bold; color: #ff5733;">Warning:</p>
Expand All @@ -13,6 +11,20 @@
"""


def load_default_profile():
"""Loads the default profile if none loaded and warn of deprecation."""
from aiida import load_profile

load_profile()

profile = get_profile()
assert profile is not None, "Failed to load the default profile"

# raise a deprecation warning
warning = HTML(_WARNING_TEMPLATE.format(profile=profile.name, version="v3.0.0"))
display(warning)


# We only detect profile and throw a warning if it is on the notebook
# It is not necessary to do this in the unit tests
def is_running_in_jupyter():
Expand All @@ -27,22 +39,21 @@ def is_running_in_jupyter():
return False


# load the default profile if no profile is loaded, and raise a deprecation warning
# this is a temporary solution to avoid breaking existing notebooks
# this will be removed in the next major release
if is_running_in_jupyter() and get_profile() is None:
# if no profile is loaded, load the default profile and raise a deprecation warning
from aiida import load_profile
if is_running_in_jupyter():
from aiida.manage import get_profile
from IPython.display import HTML, display

load_profile()
# load the default profile if no profile is loaded, and raise a deprecation warning
# this is a temporary solution to avoid breaking existing notebooks
# this will be removed in the next major release
if get_profile() is None:
load_default_profile()

profile = get_profile()
assert profile is not None, "Failed to load the default profile"
from .static import styles
from .utils.loaders import load_css_stylesheet

load_css_stylesheet(package=styles)

# raise a deprecation warning
warning = HTML(_WARNING_TEMPLATE.format(profile=profile.name, version="v3.0.0"))
display(warning)

from .computational_resources import (
ComputationalResourcesWidget,
Expand Down
126 changes: 126 additions & 0 deletions aiidalab_widgets_base/infobox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
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)


class InAppGuide(InfoBox):
"""The `InfoAppGuide` is used to set up in-app guides that may be toggle in unison."""

def __init__(self, guide_id: str, classes: list[str] | None = None, **kwargs):
"""`InAppGuide` constructor.

Parameters
----------
`guide_id` : `str`
The unique identifier for the guide.
`classes` : `list[str]`, optional
One or more CSS classes.
"""
classes = ["in-app-guide", *(classes or []), guide_id]
super().__init__(classes=classes, **kwargs)


class FirstTimeUserMessage(ipw.VBox):
"""The `FirstTimeUserMessage` is used to display a message to first time users."""

def __init__(self, message="", **kwargs):
"""`FirstTimeUserMessage` constructor."""

self.close_button = ipw.Button(
icon="times",
tooltip="Close",
)

self.message_box = InfoBox(
children=[
ipw.HTML(message),
self.close_button,
],
)

self.undo_button = ipw.Button(
icon="undo",
tooltip="Undo",
description="undo",
)

self.closing_message = ipw.HBox(
children=[
ipw.HTML("This message will not show next time"),
self.undo_button,
],
)
self.closing_message.add_class("closing-message")

super().__init__(
children=[
self.closing_message,
self.message_box,
],
**kwargs,
)

self.add_class("first-time-users-infobox")

self._check_if_first_time_user()

self._set_event_listeners()

def _check_if_first_time_user(self):
"""Add a message for first-time users."""
try:
with open(".app-user-config") as file:
first_time_user = file.read().find("existing-user") == -1
except FileNotFoundError:
first_time_user = True

if first_time_user:
self.layout.display = "flex"
self.message_box.layout.display = "flex"
self.closing_message.layout.display = "none"

def _on_close(self, _=None):
"""Hide the first time info box and write existing user token to file."""
self.message_box.layout.display = "none"
self.closing_message.layout.display = "flex"
self._write_existing_user_token_to_file()

def _write_existing_user_token_to_file(self):
"""Write a token to file marking the user as an existing user."""
with open(".app-user-config", "w") as file:
file.write("existing-user")
Comment on lines +107 to +110
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if I have two FirstTimeUserMessage in my app? They will share the same config file; thus, there is a risk that they are in conflict.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. This isn't a great approach. Username would be better, or some other uniquely identifying property. This was just a proof of concept. I was hoping someone with more knowledge of AiiDAlab could provide a better solution :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this even be a backend logic? For example, our aiidalab deployment is currently shared in the group, so you can't really identify new user this way.

Probably more canonical solution is to store a cookie in user's browser.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really don't like how I've implemented it. Just needed a proof of concept. Open to suggestions. A cookie to the user's browser may be a good approach.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@giovannipizzi suggested we keep this as a local config file and had some ideas for its structure. I'll let him explain it.


def _on_undo(self, _=None):
"""Undo the action of closing the first time user message."""
from contextlib import suppress
from pathlib import Path

self.message_box.layout.display = "flex"
self.closing_message.layout.display = "none"

with suppress(FileNotFoundError):
Path(".app-user-config").unlink()

def _set_event_listeners(self):
"""Set the event listeners."""
self.close_button.on_click(self._on_close)
self.undo_button.on_click(self._on_undo)
Empty file.
3 changes: 3 additions & 0 deletions aiidalab_widgets_base/static/styles/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Stylesheets for AiiDAlab Widgets Base

This folder contains `.css` stylesheets, which are loaded on any import from the AiiDAlab widgets base package.
Empty file.
Empty file.
62 changes: 62 additions & 0 deletions aiidalab_widgets_base/static/styles/infobox.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
.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;
}
.info-box.in-app-guide.show {
display: flex !important;
}

.first-time-users-infobox {
display: none;
max-width: 500px;
margin: 2px auto !important;
}
.first-time-users-infobox button {
width: fit-content;
background: none;
color: black;
cursor: pointer !important;
}
.first-time-users-infobox button:hover, .first-time-users-infobox button:focus {
outline: none !important;
box-shadow: none !important;
color: orangered;
}
.first-time-users-infobox button:active {
background: none;
color: #cc3700;
}
.first-time-users-infobox .info-box {
display: none;
}
.first-time-users-infobox .info-box button {
position: absolute;
top: 0;
right: 6px;
padding: 0;
font-size: large;
}
.first-time-users-infobox .info-box button:hover, .first-time-users-infobox .info-box button:focus {
transform: scale(1.1);
-webkit-transform: scale(1.1);
-moz-transform: scale(1.1);
-ms-transform: scale(1.1);
-o-transform: scale(1.1);
}
.first-time-users-infobox .info-box .widget-html {
padding-right: 20px;
}
.first-time-users-infobox .closing-message {
display: none;
}
52 changes: 52 additions & 0 deletions aiidalab_widgets_base/utils/loaders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from __future__ import annotations

from importlib.resources import Package, files
from pathlib import Path

from IPython.display import Javascript, display


def load_css_stylesheet(
package: Package | None = None,
css_path: str | Path = "",
filename: str = "",
):
"""Load a CSS stylesheet from a package and inject it into the DOM.

Parameters
----------
`package` : `Package`, optional
The package where the CSS file is located.
`css_path` : `str` | `Path`, optional
The path to the folder where the CSS file is located.
`filename` : `str`, optional
The name of the CSS file to load.
If not provided, all CSS files in the package/folder will be loaded.
"""
if package:
root = files(package)
filenames = (
[root / filename]
if filename
else [
root / path.name
for path in root.iterdir()
if path.is_file() and path.name.endswith(".css")
]
)
elif css_path:
path = Path(css_path)
filenames = [path / filename] if filename else [*path.glob("*.css")]
else:
raise ValueError("Either `package` or `path` must be provided.")

for fn in filenames:
stylesheet = fn.read_text()
display(
Javascript(f"""
var style = document.createElement('style');
style.type = 'text/css';
style.innerHTML = `{stylesheet}`;
document.head.appendChild(style);
""")
)
67 changes: 67 additions & 0 deletions notebooks/test_misc.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import ipywidgets as ipw"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from aiida import load_profile\n",
"\n",
"load_profile();"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from aiidalab_widgets_base.utils.loaders import load_css_stylesheet\n",
"\n",
"load_css_stylesheet(css_path=\"../tests_notebooks/static/styles\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"label = ipw.Label(\"Testing\")\n",
"label.add_class(\"red-text\")\n",
"display(label)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.9.13"
}
},
"nbformat": 4,
"nbformat_minor": 2
}
4 changes: 4 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ docs =
pydata-sphinx-theme~=0.15
myst-nb~=1.1


[options.package_data]
aiidalab_widgets_base.static.styles = *.css

[bumpver]
current_version = "v2.3.0a1"
version_pattern = "vMAJOR.MINOR.PATCH[PYTAGNUM]"
Expand Down
Loading
Loading