Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions example/ui_showcase.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,17 @@ async def do_magic(_v, _o) -> None:

#############################################################################################
# Overview page #
# Here we show the ImageMap and HideRows #
# Here we show the ImageMap, HideRows, WebPages and submenus. #
#############################################################################################

overview_page = web_server.page('overview', "Overview", menu_entry='Some Submenu', menu_icon='tachometer alternate',
menu_sub_label="Overview")
menu_sub_label="Overview", menu_sub_icon='tachometer alternate')

submenu_page2 = web_server.page('empty-page', "Nothing here", menu_entry='Some Submenu', menu_sub_icon='couch',
menu_sub_label="Empty Submenu")

# adding sub menu entry to `Some Submenu` pointing the page above
web_server.add_menu_entry('empty-page', label="Some Submenu", sub_icon='motorcycle', sub_label="Empty Submenu 2")

# ImageMap supports all the different Buttons as items, as well as the special ImageMapLabel
# The optional fourth entry of each item is a list of WebPageItems (everything we have shown so far – even an ImageMap))
Expand Down
85 changes: 69 additions & 16 deletions shc/web/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import os
import pathlib
import weakref
from dataclasses import dataclass, field
from json import JSONDecodeError
from typing import Dict, Iterable, Union, List, Set, Any, Optional, Tuple, Generic, Type, Callable

Expand All @@ -42,6 +43,26 @@
LastWillT = Tuple["WebApiObject[T]", T]


@dataclass
class MenuEntrySpec:
"""Specification of one menu entry within the :class:`WebServer`"""
# The name of the page (link target)
page_name: Optional[str] = None
# The label of the entry in the main menu
label: Optional[str] = None
# If given, the menu entry is prepended with the named icon
icon: Optional[str] = None
# Flag whether this menu item is active
is_active: bool = False


@dataclass
class SubMenuEntrySpec(MenuEntrySpec):
"""Specification of a sub menu entry within the :class:`WebServer`"""
# List of submenus if any
submenus: List[MenuEntrySpec] = field(default_factory=list)


class WebServer(AbstractInterface):
"""
A SHC interface to provide the web user interface and a REST+websocket API for interacting with Connectable objects.
Expand Down Expand Up @@ -81,14 +102,14 @@ def __init__(self, host: str, port: int, index_name: Optional[str] = None, root_
self._associated_tasks: weakref.WeakSet[asyncio.Task] = weakref.WeakSet()
# last will (object, value) per API websocket client (if set)
self._api_ws_last_will: Dict[aiohttp.web.WebSocketResponse, LastWillT] = {}
# data structure of the user interface's main menu
# using class `MenuEntrySpec` and `SubMenuEntrySpec` as data structure for the user interface's main menu
# The structure looks as follows:
# [('Label', 'icon', 'page_name'),
# ('Submenu label', None, [
# ('Label 2', 'icon', 'page_name2'), ...
# [('Label', 'icon', 'page_name', 'is_active'),
# ('Submenu label', 'icon', None, 'is_active' [
# ('Label 2', 'icon', 'page_name2', 'is_active'), ...
Copy link
Owner

Choose a reason for hiding this comment

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

This comment should be adjusted for the new classes, which replace the old tuple-based structure. I.f.

        # The structure looks as follows:
        # [MenuEntrySpec('Label', 'icon', 'page_name', false),
        #  SubMenuEntrySpec('Submenu label', 'icon', None, true, [
        #     MenuEntrySpec('Label 2', 'icon', 'page_name2', true), ...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

you're so right. Fixed.

# ]),
# ...]
self.ui_menu_entries: List[Tuple[str, Optional[str], Union[str, List[Tuple[str, Optional[str], str]]]]] = []
self.ui_menu_entries: List[MenuEntrySpec] = []
# List of all static js URLs to be included in the user interface pages
self._js_files = [
"/static/pack/main.js",
Expand Down Expand Up @@ -164,6 +185,9 @@ def page(self, name: str, title: Optional[str] = None, menu_entry: Union[bool, s
Create a new WebPage with a given name.

If there is already a page with that name existing, it will be returned.
For convenience you may create a menu entry pointing to the new page by specifying the
`menu_entry` attribute. See :meth:`add_menu_entry` for creating plain menu entries.


:param name: The `name` of the page, which is used in the page's URL to identify it.
:param title: The title/heading of the page. If not given, the name is used.
Expand Down Expand Up @@ -197,6 +221,7 @@ def add_menu_entry(self, page_name: str, label: str, icon: Optional[str] = None,
Create an entry for a named web UI page in the web UI's main navigation menu.

The existence of the page is not checked, so menu entries can be created before the page has been created.
See :meth:`page` for creating pages.

:param page_name: The name of the page (link target)
:param label: The label of the entry (or the submenu to place the entry in) in the main menu
Expand All @@ -207,21 +232,26 @@ def add_menu_entry(self, page_name: str, label: str, icon: Optional[str] = None,
:raises ValueError: If there is already a menu entry with the same label (or a submenu entry with the same two
labels)
"""
existing_entry = next((e for e in self.ui_menu_entries if e[0] == label), None)
existing_entry: Optional[MenuEntrySpec] = next((e for e in self.ui_menu_entries if e.label == label), None)
if not sub_label:
if existing_entry:
raise ValueError("UI main menu entry with label {} exists already. Contents: {}"
.format(label, existing_entry[2]))
self.ui_menu_entries.append((label, icon, page_name))
.format(label, existing_entry.page_name))
self.ui_menu_entries.append(MenuEntrySpec(label=label, icon=icon, page_name=page_name))

elif existing_entry:
if not isinstance(existing_entry[2], list):
if not isinstance(existing_entry, SubMenuEntrySpec):
raise ValueError("Existing UI main menu entry with label {} is not a submenu but a link to page {}"
.format(label, existing_entry[2]))
existing_entry[2].append((sub_label, sub_icon, page_name))

.format(label, existing_entry.page_name))
existing_entry.submenus.append(MenuEntrySpec(label=sub_label, icon=sub_icon, page_name=page_name))
else:
self.ui_menu_entries.append((label, icon, [(sub_label, sub_icon, page_name)]))
self.ui_menu_entries.append(
SubMenuEntrySpec(
label=label,
icon=icon,
submenus=[MenuEntrySpec(label=sub_label, icon=sub_icon, page_name=page_name)],
)
)

def api(self, type_: Type, name: str) -> "WebApiObject":
"""
Expand Down Expand Up @@ -291,11 +321,34 @@ async def _page_handler(self, request: aiohttp.web.Request) -> aiohttp.web.Respo

html_title = self.title_formatter(page.title)
template = jinja_env.get_template('page.htm')
body = await template.render_async(title=page.title, segments=page.segments, menu=self.ui_menu_entries,
root_url=self.root_url, js_files=self._js_files, css_files=self._css_files,
server_token=id(self), html_title=html_title)
self._mark_active_menu_items(page.name)
body = await template.render_async(
title=page.title,
segments=page.segments,
menu=self.ui_menu_entries,
root_url=self.root_url,
js_files=self._js_files,
css_files=self._css_files,
server_token=id(self),
html_title=html_title,
SubMenuEntrySpec=SubMenuEntrySpec,
)
return aiohttp.web.Response(body=body, content_type="text/html", charset='utf-8')

def _mark_active_menu_items(self, page_name: str):
"""Set menu items is_active flag if current page matches page_name/target link."""
for item in self.ui_menu_entries:
if isinstance(item, SubMenuEntrySpec):
item.is_active = False
for sub_item in item.submenus:
if page_name == sub_item.page_name:
item.is_active = True
sub_item.is_active = True
else:
sub_item.is_active = False
else:
item.is_active = page_name == item.page_name
Copy link
Owner

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 that this method changes the is_active attribute of the MenuEntrySpecs in the (server-global) self.ui_menu_entries attribute, such that this is now a side-effect of the _page_handler() method.

This may even result in concurrency issues, considering, that template.render_async() may be executed concurrently for multiple concurrent requests. So, the is_active value should definitely only be changed in a local data structure within the _page_handler() method.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok, understood & fixed.


async def _ui_websocket_handler(self, request: aiohttp.web.Request) -> aiohttp.web.WebSocketResponse:
ws = aiohttp.web.WebSocketResponse()
await ws.prepare(request)
Expand Down
51 changes: 27 additions & 24 deletions shc/web/templates/base.htm
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,22 @@
<body>
{% if menu %}
<div class="ui sidebar inverted vertical menu main-menu">
{% for label, icon, link in menu %}
{% if link is string %}
<a class="item" href="{{ root_url }}/page/{{ link }}/">
{% if icon %}<i class="{{ icon }} icon"></i>{% endif %}
{{ label }}
{% for item in menu %}
{% if item.submenus is not defined %}
<a class="item{% if item.is_active %} activated{% endif %}" href="{{ root_url }}/page/{{ item.page_name }}/">
{% if item.icon %}<i class="{{ item.icon }} icon"></i>{% endif %}
{{ item.label }}
</a>
{% else %}
<div class="item">
{% if icon %}<i class="{{ icon }} icon"></i>{% endif %}
<div class="header">{{ label }}</div>
<div class="item{% if item.is_active %} activated{% endif %}">
{% if item.icon %}<i class="{{ item.icon }} icon"></i>{% endif %}
<div class="header">{{ item.label }}</div>
<div class="menu">
{% for sub_label, sub_icon, sub_link in link %}
<a class="item" href="{{ root_url }}/page/{{ sub_link }}/">
{% if sub_icon %}<i class="{{ sub_icon }} icon"></i>{% endif %}
{{ sub_label }}
{% for sub_item in item.submenus %}
<a class="item{% if sub_item.is_active %} selected{% endif %}"
href="{{ root_url }}/page/{{ sub_item.page_name }}/">
{% if sub_item.icon %}<i class="{{ sub_item.icon }} icon"></i>{% endif %}
{{ sub_item.label }}
</a>
{% endfor %}
</div>
Expand All @@ -59,22 +60,24 @@
{% if menu %}
<div class="ui large top inverted fixed menu main-menu">
<div class="ui container">
{% for label, icon, link in menu %}
{% if link is string %}
<a class="mobile hidden item" href="{{ root_url }}/page/{{ link }}/">
{% if icon %}<i class="{{ icon }} icon"></i>{% endif %}
{{ label }}
{% for item in menu %}
{% if item.submenus is not defined %}
<a class="mobile hidden item{% if item.is_active %} activated{% endif %}"
href="{{ root_url }}/page/{{ item.page_name }}/">
{% if item.icon %}<i class="{{ item.icon }} icon"></i>{% endif %}
{{ item.label }}
</a>
{% else %}
<div class="mobile hidden ui dropdown item">
{% if icon %}<i class="{{ icon }} icon"></i>{% endif %}
{{ label }}
<div class="mobile hidden ui dropdown item{% if item.is_active %} activated{% endif %}">
{% if item.icon %}<i class="{{ item.icon }} icon"></i>{% endif %}
{{ item.label }}
<i class="dropdown icon"></i>
<div class="menu">
{% for sub_label, sub_icon, sub_link in link %}
<a class="item" href="{{ root_url }}/page/{{ sub_link }}/">
{% if sub_icon %}<i class="{{ sub_icon }} icon"></i>{% endif %}
{{ sub_label }}
{% for sub_item in item.submenus %}
<a class="item{% if sub_item.is_active %} selected{% endif %}"
href="{{ root_url }}/page/{{ sub_item.page_name }}/">
{% if sub_item.icon %}<i class="{{ sub_item.icon }} icon"></i>{% endif %}
{{ sub_item.label }}
</a>
{% endfor %}
</div>
Expand Down
49 changes: 49 additions & 0 deletions test/test_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,55 @@ def test_main_menu(self) -> None:
self.assertEqual(submenu_entry_href.strip(), "http://localhost:42080/page/another_page/")
submenu_entry.find_element(By.CSS_SELECTOR, 'i.bars.icon')

def test_main_menu_selection(self) -> None:
"""Test that the clicked menu item is selected thus highlighted."""
self.server.page('index', menu_entry="Home", menu_icon='home')
self.server.page('overview', menu_entry="Foo", menu_icon='info')

# add some pages accessible via submenus
self.server.page('submenu1', "Sub1", menu_entry='Some Submenu', menu_icon='bell',
menu_sub_label="Overview")
self.server.page('submenu2', "Sub2", menu_entry='Some Submenu',
menu_sub_label="Empty Submenu")
self.server.page('submenu3', "Sub3", menu_entry='Some Submenu',
menu_sub_label="Empty Submenu 2")

self.server_runner.start()
self.driver.get("http://localhost:42080")

# test on startup only 1st item is selected
container = self.driver.find_element(By.CSS_SELECTOR, '.pusher')
selected_menus = container.find_elements(By.CLASS_NAME, 'activated')
self.assertEqual(len(selected_menus), 1)
self.assertIn("Home", selected_menus[0].text)

# test after click on foo only the clicked item is selected
foo_link = container.find_element(By.CSS_SELECTOR, 'i.info.icon').find_element(By.XPATH, '..')
foo_link.click()

container = self.driver.find_element(By.CSS_SELECTOR, '.pusher')
selected_menus = container.find_elements(By.CLASS_NAME, 'activated')
self.assertEqual(len(selected_menus), 1)
self.assertIn("Foo", selected_menus[0].text)

# click top level submenu item 1st to open submenu
submenu = container.find_element(By.CSS_SELECTOR, 'i.bell.icon').find_element(By.XPATH, '..')
submenu.click()

# now select submenu item
submenu_entry = submenu.find_element(By.XPATH, './/a[contains(@class, "item")]')
submenu_entry.click()

# test after selecting a submenu both are selected the submenu item and the menu item
container = self.driver.find_element(By.CSS_SELECTOR, '.pusher')
self.assertEqual(len(selected_menus), 1)
selected_menus = container.find_elements(By.CLASS_NAME, 'activated')
self.assertIn("Some Submenu", selected_menus[0].text)
selected_menus = container.find_elements(By.CLASS_NAME, 'selected')
self.assertEqual(len(selected_menus), 1)
submenu_href: str = str(selected_menus[0].get_attribute("href"))
self.assertTrue(submenu_href.endswith("/page/submenu1/"))


class MonitoringTest(unittest.TestCase):
def setUp(self) -> None:
Expand Down
3 changes: 3 additions & 0 deletions web_ui_src/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ body.pushable>.pusher, body:not(.pushable) {
line-height: 36px;
}

.item.activated {
background: url("prism.png") !important;
}

/* ************************** *
* Semantic UI extensions *
Expand Down