From 82f066b91c7c9e9e5fbfd7bfde5136b62cb74d8c Mon Sep 17 00:00:00 2001 From: Edan Bainglass Date: Thu, 11 Jul 2024 06:14:35 +0000 Subject: [PATCH] Implement `InfoBox`, `InAppGuide`, and `FirstTimeUserMessage` widgets --- aiidalab_widgets_base/infobox.py | 119 ++++++++++++++++++ .../static/styles/infobox.css | 62 +++++++++ .../static/styles/scss/infobox.scss | 76 +++++++++++ tests/test_infobox.py | 52 ++++++++ 4 files changed, 309 insertions(+) create mode 100644 aiidalab_widgets_base/infobox.py create mode 100644 aiidalab_widgets_base/static/styles/infobox.css create mode 100644 aiidalab_widgets_base/static/styles/scss/infobox.scss create mode 100644 tests/test_infobox.py diff --git a/aiidalab_widgets_base/infobox.py b/aiidalab_widgets_base/infobox.py new file mode 100644 index 000000000..592c7f7a4 --- /dev/null +++ b/aiidalab_widgets_base/infobox.py @@ -0,0 +1,119 @@ +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, **kwargs): + """`InfoBox` constructor.""" + custom_css = kwargs.pop("custom-css", "") + super().__init__(**kwargs) + self.add_class("info-box") + if custom_css: + self.add_class(custom_css) + + +class InAppGuide(InfoBox): + """The `InfoAppGuide` is used to set up in-app guides that may be toggle in unison.""" + + def __init__(self, guide_class="", **kwargs): + """`InAppGuide` constructor. + + parameters + ---------- + `guide_class` : `str` + A CSS class marking the widget as part of a guide collection. + May also be used for custom styling. + """ + super().__init__(**kwargs) + self.add_class("in-app-guide") + self.add_class(guide_class) + + +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") + + 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) diff --git a/aiidalab_widgets_base/static/styles/infobox.css b/aiidalab_widgets_base/static/styles/infobox.css new file mode 100644 index 000000000..586b6f5f8 --- /dev/null +++ b/aiidalab_widgets_base/static/styles/infobox.css @@ -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; +} diff --git a/aiidalab_widgets_base/static/styles/scss/infobox.scss b/aiidalab_widgets_base/static/styles/scss/infobox.scss new file mode 100644 index 000000000..8b2da93b9 --- /dev/null +++ b/aiidalab_widgets_base/static/styles/scss/infobox.scss @@ -0,0 +1,76 @@ +.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; + + p { + line-height: 24px; + } + + &.in-app-guide { + &.show { + display: flex !important; + } + } +} + +.first-time-users-infobox { + display: none; + max-width: 500px; + margin: 2px auto !important; + + button { + width: fit-content; + background: none; + color: black; + cursor: pointer !important; + + &:hover, + &:focus { + outline: none !important; + box-shadow: none !important; + color: orangered; + } + + &:active { + background: none; + color: darken($color: orangered, $amount: 10); + } + } + + .info-box { + display: none; + + button { + position: absolute; + top: 0; + right: 6px; + padding: 0; + font-size: large; + + &:hover, + &: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); + } + } + + .widget-html { + padding-right: 20px; + } + } + + .closing-message { + display: none; + } +} diff --git a/tests/test_infobox.py b/tests/test_infobox.py new file mode 100644 index 000000000..d348d6c0d --- /dev/null +++ b/tests/test_infobox.py @@ -0,0 +1,52 @@ +from pathlib import Path + +from aiidalab_widgets_base.infobox import FirstTimeUserMessage, InAppGuide, InfoBox + + +def test_infobox_classes(): + """Test `InfoBox` classes.""" + infobox = InfoBox() + assert "info-box" in infobox._dom_classes + infobox = InfoBox(**{"custom-css": "custom-info-box"}) + assert all( + css_class in infobox._dom_classes + for css_class in ( + "info-box", + "custom-info-box", + ) + ) + + +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", + guide_class, + ) + ) + + +def test_first_time_user_message(): + """Test `FirstTimeUserMessage` class.""" + message = "Hello, first-time user!" + widget = FirstTimeUserMessage(message=message) + assert "first-time-users-infobox" in widget._dom_classes + assert widget.message_box.children[0].value == message # type: ignore + assert widget.layout.display == "flex" + assert widget.message_box.layout.display == "flex" + assert widget.closing_message.layout.display == "none" + widget.close_button.click() + assert widget.message_box.layout.display == "none" + assert widget.closing_message.layout.display == "flex" + path = Path(".app-user-config") + assert path.exists() + assert path.read_text().find("existing-user") != -1 + widget.undo_button.click() + assert widget.message_box.layout.display == "flex" + assert widget.closing_message.layout.display == "none" + assert not path.exists()