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

Highlight selected page #91

Merged
merged 24 commits into from
Nov 30, 2024
Merged
Show file tree
Hide file tree
Changes from all 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: 68 additions & 17 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,24 @@
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


@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 +100,13 @@ 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'), ...
# ]),
# [MenuEntrySpec('Label', 'icon', 'page_name'),
# SubMenuEntrySpec('Submenu label', 'icon', None, [
# MenuEntrySpec('Label 2', 'icon', 'page_name2'),
# ...]
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 +182,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 +218,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 +229,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 +318,35 @@ 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)
body = await template.render_async(
title=page.title,
segments=page.segments,
menu=self.ui_menu_entries,
active_items=self._get_active_menu_items(page.name),
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 _get_active_menu_items(self, page_name: str) -> List[MenuEntrySpec]:
"""Return the menu item where the current page matches page_name/target link."""
result: List[MenuEntrySpec] = []
for item in self.ui_menu_entries:
if isinstance(item, SubMenuEntrySpec):
for sub_item in item.submenus:
if page_name == sub_item.page_name:
result.append(item)
result.append(sub_item)
else:
if page_name == item.page_name:
result.append(item)

return result

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="{% if item in active_items %}activated {% endif %}item" 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="{% if item in active_items %}activated {% endif %}item">
{% 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="{% if sub_item in active_items %}selected {% endif %}item"
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 {% if item in active_items %}activated {% endif %}item"
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 {% if item in active_items %}activated {% endif %}item">
{% 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="{% if sub_item in active_items %}selected{% endif %} item"
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
33 changes: 33 additions & 0 deletions web_ui_src/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,39 @@ body.pushable>.pusher, body:not(.pushable) {
}
}

/* **************************
* Menu things
*
* Fomantic-UI active class for menus conflicts w/ the submenu active class usage.
* Thus we define our own `activated` class. Below is a adjusted copy from the Fomantic-UI project.
* See https://github.com/mhthies/smarthomeconnect/pull/91 for details
* ***************************/

.ui.menu .ui.dropdown .menu > .activated.item {
background: rgba(0, 0, 0, 0.03) !important;
font-weight: bold !important;
color: rgba(0, 0, 0, 0.95) !important;
}

.ui.vertical.menu .dropdown.activated.item {
box-shadow: none;
}
/* --------------
Active
--------------- */
.ui.menu .activated.item {
background: rgba(50, 50, 50, 0.8);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Made this is a little bit lighter as w/ the values from fomatic it was really hard to see the difference.

font-weight: normal;
box-shadow: none;
}
.ui.menu .activated.item > i.icon {
opacity: 1;
}

.ui.menu .ui.dropdown .menu > .selected.item {
background: rgba(0, 0, 0, 0.15) !important;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changed transparency as w/ the values from fomantic it was really hard to see the difference.

}

/* Fix divided lists in divided lists and segments */
.ui.divided.list .item .divided.list>.item {
border-top: 1px solid rgba(34,36,38,.15);
Expand Down