User configuration for adding and customizing sidebar submenus (nested menus)

Hi community,

I’ve implemented the possibility to define submenus in the HA sidebar, like the following behavior:

image

This capability is intended for a better organization in a clearer way, giving the possibility to group and sort them according to the user preferences.

At this point, my question is that configuration: how it can be stored and retrieved for frontend layer?

My idea is to add into configuration.yaml file something like the following excerpt:

panel_nested:
  - menu_entry_name: Monitoring
    icon: mdi:icon
    items:
      - submenu_name: Chronograf
        icon: mdi:icon
        url_path: /url-for-chronograf-admin-panel
      - submenu_name: InfluxDB
        icon: mdi:icon
        url_path: /url-for-influxdb-admin-app
      - submenu_name: Grafana
        icon: mdi:icon
        url_path: /url-for-grafana-admin-dashboard-panel
  - menu_entry_name: Editors
    ...

But, if I add that block an error appears saying something like that “panel_nested” component was not found, like if I’d need a backend part (such as an integration written in Python). How could be the better approach in order to give to the user that functionality? How can I fetch that config block in order to render that nested menu?

Sorry if I’m mixing concepts, or even if I have a misunderstanding about some topic, but I’m trying to learn how HA works at a developer level (I have some gaps that I can’t find in the docs).

I’ll appreciate any help or clarification.

Thanks in advance.
Regards.

2 Likes

I presume you have created a custom javascript module to create this menu structure? The basic premise of HA is that the backend manages and controls data and storage etc and the frontend acceses that data via webservice or api calls.

As such, you would probably need some sort of backend to provide a webservice endpoint to then be able to store and fetch the data. If you want to set this up in config.yaml then the integration domain needs to exist in the backend to hold the menu items data and create the webservice endpoint foe the feontend to fetch and render.

Thanks for your response @msp1974

However, your answer bring me other doubts:

  • Which is the way to retrieve that data stored on the backend? Which should be the webservice payload or api endpoint that I have to consume to retrieve that? I’ve debugged the this.hass object, and I found some portion from configuration.yaml (for instance) when “panel_iframe” or “panel_custom” is defined, but if I add the my mine entry, it throws an error.
  • According to your sentence “you would probably need some sort of backend to provide a webservice endpoint to then be able to store and fetch the data” => so, a pure frontend module/extension, if it needs some backend/core data as in my example, it needs to implement its own backend (python) integration? Or could I use some existing generic WS/endpoint just to retrieve that custom menu config?

Thanks in advance!
Regards.

If you have a heading in config.yaml, it assumes a backend integration name that it must load. Therefore when you add your panel_nested heading, it is looking to load a backend integration called panel_nested, which doesnt exist, hence the error.

You cannot access config.yaml data as such from the front end, only data stored in the hass object or retrieved from a webservice call, all of which are done by the backend, hence why you would need a backend integration.

Therefore you would need to create your backend integration called panel_nested, which would hold the rest of the config listed and also create a new webservice endpoint. Your frontend javascript would then call that webservice endpoint to retrieve the data and use it to display your submenus.

To create a webservice endpoint in a backend integration is pretty simple to do, if you have the knowledge to build a custom integration. See the example below…

  @websocket_api.websocket_command(
        {
            vol.Required("type"): "panel_nested/data",
        }
    )
    @websocket_api.async_response
    async def websocket_example(hass, connection: ActiveConnection, msg: dict) -> None:
        """Example webservice endpoint"""
        # replace below with data from config.yaml config data
        output = {"menus": [{"name": "Chronograf", "icon": "mdi:icon", "url":"/url-for-chronograf-admin-panel"}]}
         connection.send_result(msg["id"], output)

async_register_command(hass, websocket_example)

You would then consume this in your frontend code like below which would then allow you to call get_menu_data to retrieve the output from above backend code.

export const get_menu_data = (hass: HomeAssistant): Promise<string[]> =>
  hass.callWS({
    type: "panel_nested/data",
  });

I write an integration that uses this for both a backend integration using an api to a heating system but also to support 2 custom cards that go with that. It is quite a big complicated integration but may help give you an idea of what to do. Links below. There are much simpler i tegrations to try and copy to get your backend up and running but probably not too many with their own webservice endpoints.

Backend integration which creates custom endpoints. See websockets file for code that adds these endpoints.

Card that calls custom webservice endpoint. See src/data for the code that is the function to get the data.

Feel free to have a go at the code and provide the link if you want some more help

Having a further think, the alternative (not pretty but simpler to do without writing a backend) would be to create a template sensor with each menu option as an attribute. You can then use the rest api to get the sensor state and attribute data and process this attribute data in your frontend code to produce the menus.

It is unlikely that you could store the whole menu config as the state (as json) as it will probably exceed the 255 char limit. Therefore each menu option a json data in an attribute. Eg

template:
  - sensor:
    - name: "panel_nested"
      state: "menu"
      attributes:
        Item1: "{'name':'Chronograf', 'icon':'mdi:icon, 'url':'urlpath'}"
        item2: etc etc

Then use rest api from frontend to call

/api/states/panel_nested

Which will return data like below…

{
   "attributes":{
      "item1": "{'name':'Chronograf', 'icon':'mdi:icon, 'url':'urlpath'}",
      "item2":"etcetc"
   },
   "entity_id":"panel_nested",
   "last_changed":"2016-05-30T21:43:29.204838+00:00",
   "last_updated":"2016-05-30T21:50:30.529465+00:00",
   "state":"menu"
}

As i said, not pretty and not really a way if you want to share with others.

Edit: And this may not work for multiple sub menu sets

So, had some time on my hands and saw this as a bit of a learning exercise. Below is the code you would need to be able to place your config excerpt above into config.yaml, create a webservice and therefore be able to consume this via hass.callWS.

To use in your HA environment (sorry if this is teaching you to suck eggs), create a directory called panel_nested under config/custom_components.

Create a file init.py with the below code

"""Panel Nested component."""
from __future__ import annotations

import logging

import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.websocket_api import ActiveConnection, async_register_command
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType

DOMAIN = "panel_nested"

MENU_DATA = "menu_data"
CONF_MENU_ENTRY = "menu_entry_name"
CONF_MENU_ITEMS = "items"
CONF_SUBMENU_NAME = "submenu_name"
CONF_ICON = "icon"
CONF_URL_PATH = "url_path"

_LOGGER = logging.getLogger(__name__)

MENU_ITEM_SCHEMA = vol.Schema(
    {
        vol.Required(CONF_SUBMENU_NAME): cv.string,
        vol.Optional(CONF_ICON): cv.string,
        vol.Required(CONF_URL_PATH): cv.string,
    }
)

PLATFORM_SCHEMA = vol.Schema(
    {
        vol.Required(CONF_MENU_ENTRY): cv.string,
        vol.Optional(CONF_ICON): cv.string,
        vol.Required(CONF_MENU_ITEMS): vol.All(cv.ensure_list, [MENU_ITEM_SCHEMA]),
    }
)


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
    """Set up the template integration."""
    hass.data.setdefault(DOMAIN, {})

    # Debug log for config data
    _LOGGER.debug(config[DOMAIN])

    # Store menu config data
    hass.data[DOMAIN][MENU_DATA] = config[DOMAIN]

    await register_webservice(hass)
    return True


async def register_webservice(hass: HomeAssistant) -> None:
    """Add webservice endpoint"""

    @websocket_api.websocket_command(
        {
            vol.Required("type"): f"{DOMAIN}/data",
        }
    )
    @websocket_api.async_response
    async def menu_data(
        hass: HomeAssistant, connection: ActiveConnection, msg: dict  # pylint: disable=unused-argument
    ) -> None:
        output = hass.data[DOMAIN][MENU_DATA]
        connection.send_result(msg["id"], output)

    async_register_command(hass, menu_data)

Create a manifest.json file with the below code (you will need to modifiy document link and code owner if you put on your github.

{
  "domain": "panel_nested",
  "name": "Nested Menu",
  "config_flow": false,
  "documentation": "https://github.com/github_user/panel_nested",
  "iot_class": "calculated",
  "version": "v1.0.0",
  "requirements": [],
  "dependencies": [],
  "codeowners": [
    "@github_user"
  ]
}

You can then use from your frontend or test in postman etc. Output from postman when called using an extended copy of your config excerpt.

{
    "id": 1,
    "type": "result",
    "success": true,
    "result": [
        {
            "menu_entry_name": "Monitoring",
            "icon": "mdi:icon",
            "items": [
                {
                    "submenu_name": "Chronograf",
                    "icon": "mdi:icon",
                    "url_path": "/url-for-chronograf-admin-panel"
                },
                {
                    "submenu_name": "InfluxDB",
                    "icon": "mdi:icon",
                    "url_path": "/url-for-influxdb-admin-app"
                },
                {
                    "submenu_name": "Grafana",
                    "icon": "mdi:icon",
                    "url_path": "/url-for-grafana-admin-dashboard-panel"
                }
            ]
        },
        {
            "menu_entry_name": "Editors",
            "icon": "mdi:icon",
            "items": [
                {
                    "submenu_name": "Menu1",
                    "icon": "mdi:icon",
                    "url_path": "/menu1"
                },
                {
                    "submenu_name": "Menu2",
                    "icon": "mdi:icon",
                    "url_path": "/menu2"
                },
                {
                    "submenu_name": "Menu3",
                    "icon": "mdi:icon",
                    "url_path": "/menu3"
                }
            ]
        }
    ]
}

Hi @msp1974 and thanks for your huge help! It’s incredible that you write in a few hours almost the same code that I in a couple of days :slight_smile:

I’ve discarded the sensor approach because it is not very related with that concept, and doesn’t fit well with the HA’s philosophy in terms of sensors (it’s more like a config than a sensor for reading values).

I’ve tried to implement different ways (thru webservice call and using a command). The first approach is very similar in my current implementation (trial and error :stuck_out_tongue: using the info of your previous reply) and in your last response. Now, I’m trying to implement the same behavior using a service because that receives a ServiceCall that allow me to check if the user is either an admin or not.

Why I’m not stopped with the first implementation? Because I’d like to implement a “requires_admin” flag, so, the admin could choose if any submenu is restricted to non-admin users. Is there a way to check it inside the function (because I’m filtering all the entries before send them to the frontend)?

One time more… lot of thanks for your time and support!! :muscle: :call_me_hand:

In the frontend hass object is a property that holds the user logged in and if admin. If you pass this in your webservice call, you can iterate your config and return only the non admin menus.

Again, apologies if i am teaching you to suck eggs but if you type $0.hass in your browser console you can see all the properties of the hass object. I think it is hass.user.is_admin.

Just add another key in your websocket definition like this…

@websocket_api.websocket_command(
        {
            vol.Required("type"): f"{DOMAIN}/data",
            Vol.Optional("is_admin", default=false): bool
        }
    )

And then access via msg[“is_admin”] in the function

So your webservice call function would be…

export const get_menu_data = (hass: HomeAssistant): Promise<string[]> =>
  hass.callWS({
    type: "panel_nested/data",
    is_admin: hass.user.is_admin
  });

So your whole function would be like this…

"""Panel Nested component."""
from __future__ import annotations

import logging

import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.websocket_api import ActiveConnection, async_register_command
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType

DOMAIN = "panel_nested"

MENU_DATA = "menu_data"
CONF_MENU_ENTRY = "menu_entry_name"
CONF_MENU_ITEMS = "items"
CONF_SUBMENU_NAME = "submenu_name"
CONF_ICON = "icon"
CONF_URL_PATH = "url_path"
CONF_REQUIRES_ADMIN = "requires_admin"
IS_ADMIN = "is_admin"

_LOGGER = logging.getLogger(__name__)

MENU_ITEM_SCHEMA = vol.Schema(
    {
        vol.Required(CONF_SUBMENU_NAME): cv.string,
        vol.Optional(CONF_ICON): cv.string,
        vol.Required(CONF_URL_PATH): cv.string,
    }
)

PLATFORM_SCHEMA = vol.Schema(
    {
        vol.Required(CONF_MENU_ENTRY): cv.string,
        vol.Optional(CONF_ICON): cv.string,
        vol.Optional(CONF_REQUIRES_ADMIN, default=False): bool,
        vol.Required(CONF_MENU_ITEMS): vol.All(cv.ensure_list, [MENU_ITEM_SCHEMA]),
    }
)


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
    """Set up the template integration."""
    hass.data.setdefault(DOMAIN, {})

    # Debug log for config data
    _LOGGER.debug(config[DOMAIN])

    # Store menu config data
    hass.data[DOMAIN][MENU_DATA] = config[DOMAIN]

    await register_webservice(hass)
    return True


async def register_webservice(hass: HomeAssistant) -> None:
    """Add webservice endpoint"""

    @websocket_api.websocket_command(
        {vol.Required("type"): f"{DOMAIN}/data", vol.Optional(IS_ADMIN, default=False): bool}
    )
    @websocket_api.async_response
    async def menu_data(
        hass: HomeAssistant, connection: ActiveConnection, msg: dict  # pylint: disable=unused-argument
    ) -> None:
        is_admin = msg[IS_ADMIN]
        output = filter_menu_output(hass.data[DOMAIN][MENU_DATA], is_admin)
        connection.send_result(msg["id"], output)

    async_register_command(hass, menu_data)


def filter_menu_output(menu_data: dict, is_admin: bool) -> dict:
    """Filter menu data for admin"""
    output = {}
    if not is_admin:
        for menu in menu_data:
            if not menu.get(CONF_REQUIRES_ADMIN, False):
                output.update(menu)
        return output
    return menu_data

I’m wondering about the point of sending the is_admin flag from the frontend… in that case, any non-admin user could write in the console the following instruction:

$0.hass.callWS({
  type: 'panel_nested/data',
  is_admin: true
})

That’s the reason the backend (core) has to check itself, avoiding to rely on the frontend (regarding to sensible data, like admin flag). I consider this point very critical in terms of security, because non legitimated user could get the URL for those “forbidden” menu entries (links).

I’ve been thinking about the “@websocket_api.require_admin” annotation, but I think that this will prevent the whole execution of the method, and it’s different from my purpose, because I want to go inside the method, and return more or less data according to the filter performed based on the admin.

Thanks a lot for your help, support and ideas!
Regards.

That decorator seems to get the user info from the connection parameter. As such, looks like you do not need to pass the is_admin in your webservice call but just test if

connection.user.is_admin

You learn something new everyday.


def require_admin(func: const.WebSocketCommandHandler) -> const.WebSocketCommandHandler:
    """Websocket decorator to require user to be an admin."""

    @wraps(func)
    def with_admin(
        hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
    ) -> None:
        """Check admin and call function."""
        user = connection.user

        if user is None or not user.is_admin:
            raise Unauthorized()

        func(hass, connection, msg)

    return with_admin

Just checking in to see how you got on with this. Did you get it working?

Hi @msp1974,

Thanks to your help, I could implement the following behavior:

HA Sidemenu Example

I’m working on some improvements in order to propose it to a contribution thru a PR to the frontend repo.

Thanks for your interest!!

Regards.

2 Likes

For all those who may be interested, this is the PR opened and all the discussion thread about the implementation and all changes needed/required to be able to be merged and integrated.

1 Like

Any news?
Could you put here a zip file with the files you`ve modified to be possible we test it now?