Compare commits

...

3 Commits

Author SHA1 Message Date
Paul Bottein
53edbacef3 Use panel id instead of preference id 2025-10-24 11:10:55 +02:00
Paul Bottein
7e15923dfc Fix test 2025-10-23 18:42:13 +02:00
Paul Bottein
80e57a8394 Add show in sidebar option to frontend panels 2025-10-23 18:15:56 +02:00
5 changed files with 524 additions and 5 deletions

View File

@@ -36,6 +36,10 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_integration, bind_hass
from homeassistant.util.hass_dict import HassKey
from .panel_preferences import (
async_get_panel_preferences,
async_setup_panel_preferences,
)
from .storage import async_setup_frontend_storage
_LOGGER = logging.getLogger(__name__)
@@ -51,6 +55,7 @@ CONF_EXTRA_MODULE_URL = "extra_module_url"
CONF_EXTRA_JS_URL_ES5 = "extra_js_url_es5"
CONF_FRONTEND_REPO = "development_repo"
CONF_JS_VERSION = "javascript_version"
CONF_SHOW_IN_SIDEBAR = "show_in_sidebar"
DEFAULT_THEME_COLOR = "#2980b9"
@@ -272,6 +277,9 @@ class Panel:
# If the panel should only be visible to admins
require_admin = False
# If the panel should be shown in the sidebar by default
show_in_sidebar: bool = True
# If the panel is a configuration panel for a integration
config_panel_domain: str | None = None
@@ -284,6 +292,7 @@ class Panel:
config: dict[str, Any] | None,
require_admin: bool,
config_panel_domain: str | None,
show_in_sidebar: bool | None = None,
) -> None:
"""Initialize a built-in panel."""
self.component_name = component_name
@@ -293,6 +302,11 @@ class Panel:
self.config = config
self.require_admin = require_admin
self.config_panel_domain = config_panel_domain
# Default to True if title is provided, False otherwise
if show_in_sidebar is None:
self.show_in_sidebar = sidebar_title is not None
else:
self.show_in_sidebar = show_in_sidebar
@callback
def to_response(self) -> PanelResponse:
@@ -305,6 +319,7 @@ class Panel:
"url_path": self.frontend_url_path,
"require_admin": self.require_admin,
"config_panel_domain": self.config_panel_domain,
"show_in_sidebar": self.show_in_sidebar,
}
@@ -321,6 +336,7 @@ def async_register_built_in_panel(
*,
update: bool = False,
config_panel_domain: str | None = None,
show_in_sidebar: bool | None = None,
) -> None:
"""Register a built-in panel."""
panel = Panel(
@@ -331,6 +347,7 @@ def async_register_built_in_panel(
config,
require_admin,
config_panel_domain,
show_in_sidebar,
)
panels = hass.data.setdefault(DATA_PANELS, {})
@@ -395,6 +412,7 @@ def _frontend_root(dev_repo_path: str | None) -> pathlib.Path:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the serving of the frontend."""
await async_setup_frontend_storage(hass)
await async_setup_panel_preferences(hass)
websocket_api.async_register_command(hass, websocket_get_icons)
websocket_api.async_register_command(hass, websocket_get_panels)
websocket_api.async_register_command(hass, websocket_get_themes)
@@ -769,12 +787,26 @@ def websocket_get_panels(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle get panels command."""
user_is_admin = connection.user.is_admin
panels = {
panel_key: panel.to_response()
for panel_key, panel in connection.hass.data[DATA_PANELS].items()
if user_is_admin or not panel.require_admin
}
panel_prefs = async_get_panel_preferences(hass)
panels = {}
for panel_key, panel in connection.hass.data[DATA_PANELS].items():
if not user_is_admin and panel.require_admin:
continue
panel_response = panel.to_response()
# Check if user has set a preference for this panel
if panel_key in panel_prefs and CONF_SHOW_IN_SIDEBAR in panel_prefs[panel_key]:
show_in_sidebar = panel_prefs[panel_key][CONF_SHOW_IN_SIDEBAR]
else:
# Use integration-defined default
show_in_sidebar = panel.show_in_sidebar
panel_response["show_in_sidebar"] = show_in_sidebar
panels[panel_key] = panel_response
connection.send_message(websocket_api.result_message(msg["id"], panels))
@@ -883,3 +915,4 @@ class PanelResponse(TypedDict):
url_path: str
require_admin: bool
config_panel_domain: str | None
show_in_sidebar: bool

View File

@@ -0,0 +1,105 @@
"""Panel preferences for the frontend."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.const import EVENT_PANELS_UPDATED
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import collection, storage
from homeassistant.helpers.typing import VolDictType
from homeassistant.util.hass_dict import HassKey
DOMAIN = "frontend"
CONF_SHOW_IN_SIDEBAR = "show_in_sidebar"
STORAGE_KEY = f"{DOMAIN}_panel_preferences"
STORAGE_VERSION = 1
DATA_PANEL_PREFERENCES: HassKey[PanelPreferencesCollection] = HassKey(
"frontend_panel_preferences"
)
PANEL_PREFERENCE_CREATE_FIELDS: VolDictType = {
vol.Required("panel_id"): str,
vol.Optional(CONF_SHOW_IN_SIDEBAR): bool,
}
PANEL_PREFERENCE_UPDATE_FIELDS: VolDictType = {
vol.Optional(CONF_SHOW_IN_SIDEBAR): bool,
}
async def async_setup_panel_preferences(hass: HomeAssistant) -> None:
"""Set up panel preferences."""
panel_prefs_collection = PanelPreferencesCollection(hass)
await panel_prefs_collection.async_load()
hass.data[DATA_PANEL_PREFERENCES] = panel_prefs_collection
collection.DictStorageCollectionWebsocket(
panel_prefs_collection,
"frontend/panel_preferences",
"panel",
PANEL_PREFERENCE_CREATE_FIELDS,
PANEL_PREFERENCE_UPDATE_FIELDS,
).async_setup(hass)
class PanelPreferencesCollection(collection.DictStorageCollection):
"""Panel preferences collection."""
CREATE_SCHEMA = vol.Schema(PANEL_PREFERENCE_CREATE_FIELDS)
UPDATE_SCHEMA = vol.Schema(PANEL_PREFERENCE_UPDATE_FIELDS)
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize panel preferences collection."""
super().__init__(
storage.Store(hass, STORAGE_VERSION, STORAGE_KEY),
)
async def _process_create_data(self, data: dict[str, Any]) -> dict[str, Any]:
"""Validate the config is valid."""
return self.CREATE_SCHEMA(data) # type: ignore[no-any-return]
@callback
def _get_suggested_id(self, info: dict[str, Any]) -> str:
"""Suggest an ID based on the panel_id."""
return str(info["panel_id"])
async def _update_data(
self, item: dict[str, Any], update_data: dict[str, Any]
) -> dict[str, Any]:
"""Return a new updated item."""
update_data = self.UPDATE_SCHEMA(update_data)
updated = {**item, **update_data}
# Fire panels updated event so frontend knows to refresh
self.hass.bus.async_fire(EVENT_PANELS_UPDATED)
return updated
def _create_item(self, item_id: str, data: dict[str, Any]) -> dict[str, Any]:
"""Create an item from its validated, serialized representation."""
# Fire panels updated event so frontend knows to refresh
self.hass.bus.async_fire(EVENT_PANELS_UPDATED)
return super()._create_item(item_id, data)
async def async_delete_item(self, item_id: str) -> None:
"""Delete a panel preference."""
await super().async_delete_item(item_id)
# Fire panels updated event so frontend knows to refresh
self.hass.bus.async_fire(EVENT_PANELS_UPDATED)
@callback
def async_get_panel_preferences(
hass: HomeAssistant,
) -> dict[str, dict[str, Any]]:
"""Get panel preferences."""
if DATA_PANEL_PREFERENCES not in hass.data:
return {}
collection_obj: PanelPreferencesCollection = hass.data[DATA_PANEL_PREFERENCES]
return collection_obj.data

View File

@@ -645,6 +645,7 @@ async def test_get_panels(
assert msg["result"]["map"]["icon"] == "mdi:tooltip-account"
assert msg["result"]["map"]["title"] == "Map"
assert msg["result"]["map"]["require_admin"] is True
assert msg["result"]["map"]["show_in_sidebar"] is True
async_remove_panel(hass, "map")

View File

@@ -0,0 +1,379 @@
"""Tests for frontend panel preferences."""
from typing import Any
import pytest
from homeassistant.components.frontend import (
EVENT_PANELS_UPDATED,
async_register_built_in_panel,
)
from homeassistant.components.frontend.panel_preferences import (
DATA_PANEL_PREFERENCES,
STORAGE_KEY,
)
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import async_capture_events
from tests.typing import WebSocketGenerator
@pytest.fixture(autouse=True)
async def setup_frontend(hass: HomeAssistant) -> None:
"""Set up the frontend integration."""
assert await async_setup_component(hass, "frontend", {})
await hass.async_block_till_done()
async def test_panel_preferences_collection_loaded(hass: HomeAssistant) -> None:
"""Test that panel preferences collection is loaded on setup."""
assert DATA_PANEL_PREFERENCES in hass.data
collection = hass.data[DATA_PANEL_PREFERENCES]
assert collection is not None
async def test_create_panel_preference(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test creating a panel preference."""
client = await hass_ws_client(hass)
# Register a test panel
async_register_built_in_panel(
hass,
"test_panel",
sidebar_title="Test Panel",
sidebar_icon="mdi:test",
)
# Create a panel preference
await client.send_json_auto_id(
{
"type": "frontend/panel_preferences/create",
"panel_id": "test_panel",
"show_in_sidebar": False,
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"]["panel_id"] == "test_panel"
assert msg["result"]["show_in_sidebar"] is False
async def test_create_panel_preference_without_field_uses_panel_auto_default(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test creating preference without field uses panel's auto-determined default."""
client = await hass_ws_client(hass)
# Register a panel with title (auto show_in_sidebar=True)
async_register_built_in_panel(
hass,
"test_panel_auto_true",
sidebar_title="Test Panel",
sidebar_icon="mdi:test",
)
# Create a panel preference without specifying show_in_sidebar
await client.send_json_auto_id(
{
"type": "frontend/panel_preferences/create",
"panel_id": "test_panel_auto_true",
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"]["panel_id"] == "test_panel_auto_true"
# When not specified, show_in_sidebar should not be in the preference
assert "show_in_sidebar" not in msg["result"]
# Get panels - should use panel's auto-determined default (True)
await client.send_json_auto_id({"type": "get_panels"})
msg = await client.receive_json()
assert msg["success"]
assert msg["result"]["test_panel_auto_true"]["show_in_sidebar"] is True
async def test_update_panel_preference(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test updating a panel preference."""
client = await hass_ws_client(hass)
# Create a panel preference
await client.send_json_auto_id(
{
"type": "frontend/panel_preferences/create",
"panel_id": "test_panel",
"show_in_sidebar": True,
}
)
msg = await client.receive_json()
assert msg["success"]
item_id = msg["result"]["id"]
# Update the preference
await client.send_json_auto_id(
{
"type": "frontend/panel_preferences/update",
"panel_id": item_id,
"show_in_sidebar": False,
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"]["show_in_sidebar"] is False
async def test_list_panel_preferences(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test listing panel preferences."""
client = await hass_ws_client(hass)
# Create two panel preferences
await client.send_json_auto_id(
{
"type": "frontend/panel_preferences/create",
"panel_id": "panel_1",
"show_in_sidebar": True,
}
)
await client.receive_json()
await client.send_json_auto_id(
{
"type": "frontend/panel_preferences/create",
"panel_id": "panel_2",
"show_in_sidebar": False,
}
)
await client.receive_json()
# List all preferences
await client.send_json_auto_id({"type": "frontend/panel_preferences/list"})
msg = await client.receive_json()
assert msg["success"]
assert len(msg["result"]) == 2
panel_ids = {item["panel_id"] for item in msg["result"]}
assert panel_ids == {"panel_1", "panel_2"}
async def test_delete_panel_preference(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test deleting a panel preference."""
client = await hass_ws_client(hass)
# Create a panel preference
await client.send_json_auto_id(
{
"type": "frontend/panel_preferences/create",
"panel_id": "test_panel",
"show_in_sidebar": False,
}
)
msg = await client.receive_json()
item_id = msg["result"]["id"]
# Delete the preference
await client.send_json_auto_id(
{
"type": "frontend/panel_preferences/delete",
"panel_id": item_id,
}
)
msg = await client.receive_json()
assert msg["success"]
# Verify it's deleted
await client.send_json_auto_id({"type": "frontend/panel_preferences/list"})
msg = await client.receive_json()
assert len(msg["result"]) == 0
async def test_get_panels_respects_preferences(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test that get_panels respects panel preferences."""
client = await hass_ws_client(hass)
# Register a panel with sidebar info
async_register_built_in_panel(
hass,
"test_panel",
sidebar_title="Test Panel",
sidebar_icon="mdi:test",
)
# Get panels - should show in sidebar by default
await client.send_json_auto_id({"type": "get_panels"})
msg = await client.receive_json()
assert msg["success"]
assert msg["result"]["test_panel"]["title"] == "Test Panel"
assert msg["result"]["test_panel"]["icon"] == "mdi:test"
assert msg["result"]["test_panel"]["show_in_sidebar"] is True
# Create preference to hide from sidebar
await client.send_json_auto_id(
{
"type": "frontend/panel_preferences/create",
"panel_id": "test_panel",
"show_in_sidebar": False,
}
)
await client.receive_json()
# Get panels again - show_in_sidebar should be False
await client.send_json_auto_id({"type": "get_panels"})
msg = await client.receive_json()
assert msg["success"]
assert msg["result"]["test_panel"]["title"] == "Test Panel"
assert msg["result"]["test_panel"]["icon"] == "mdi:test"
assert msg["result"]["test_panel"]["show_in_sidebar"] is False
async def test_get_panels_no_sidebar_by_default(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test that panels without title are not shown in sidebar."""
client = await hass_ws_client(hass)
# Register a panel WITHOUT sidebar info
async_register_built_in_panel(hass, "test_panel_no_sidebar")
# Get panels - should not show in sidebar by default
await client.send_json_auto_id({"type": "get_panels"})
msg = await client.receive_json()
assert msg["success"]
assert msg["result"]["test_panel_no_sidebar"]["title"] is None
assert msg["result"]["test_panel_no_sidebar"]["icon"] is None
assert msg["result"]["test_panel_no_sidebar"]["show_in_sidebar"] is False
async def test_get_panels_preference_overrides_default(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test that user preference can override default sidebar visibility."""
client = await hass_ws_client(hass)
# Register a panel WITHOUT sidebar info (default show_in_sidebar=False)
async_register_built_in_panel(hass, "hidden_panel")
# Get panels - should not show in sidebar by default
await client.send_json_auto_id({"type": "get_panels"})
msg = await client.receive_json()
assert msg["success"]
assert msg["result"]["hidden_panel"]["show_in_sidebar"] is False
# Create preference to show it (even though it has no sidebar info by default)
await client.send_json_auto_id(
{
"type": "frontend/panel_preferences/create",
"panel_id": "hidden_panel",
"show_in_sidebar": True,
}
)
await client.receive_json()
# Get panels - preference is true, so show_in_sidebar should be True
# (even though panel has no sidebar info)
await client.send_json_auto_id({"type": "get_panels"})
msg = await client.receive_json()
assert msg["success"]
assert msg["result"]["hidden_panel"]["title"] is None
assert msg["result"]["hidden_panel"]["icon"] is None
assert msg["result"]["hidden_panel"]["show_in_sidebar"] is True
async def test_panel_preferences_fire_update_event(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test that panel preference changes fire update events."""
client = await hass_ws_client(hass)
events = async_capture_events(hass, EVENT_PANELS_UPDATED)
# Create a preference - should fire event
await client.send_json_auto_id(
{
"type": "frontend/panel_preferences/create",
"panel_id": "test_panel",
"show_in_sidebar": False,
}
)
msg = await client.receive_json()
item_id = msg["result"]["id"]
await hass.async_block_till_done()
assert len(events) == 1
# Update a preference - should fire event
await client.send_json_auto_id(
{
"type": "frontend/panel_preferences/update",
"panel_id": item_id,
"show_in_sidebar": True,
}
)
await client.receive_json()
await hass.async_block_till_done()
assert len(events) == 2
# Delete a preference - should fire event
await client.send_json_auto_id(
{
"type": "frontend/panel_preferences/delete",
"panel_id": item_id,
}
)
await client.receive_json()
await hass.async_block_till_done()
assert len(events) == 3
async def test_panel_preferences_storage_persistence(
hass: HomeAssistant,
hass_storage: dict[str, Any],
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test that panel preferences are persisted to storage."""
client = await hass_ws_client(hass)
# Create a panel preference
await client.send_json_auto_id(
{
"type": "frontend/panel_preferences/create",
"panel_id": "persisted_panel",
"show_in_sidebar": False,
}
)
await client.receive_json()
await hass.async_block_till_done()
# Manually flush the storage
collection_obj = hass.data[DATA_PANEL_PREFERENCES]
await collection_obj.store.async_save(collection_obj._data_to_save())
# Check storage
assert STORAGE_KEY in hass_storage
stored_data = hass_storage[STORAGE_KEY]
assert stored_data["version"] == 1
items = stored_data["data"]["items"]
# Items is stored as a list
assert len(items) == 1
item = items[0] if isinstance(items, list) else list(items.values())[0]
assert item["panel_id"] == "persisted_panel"
assert item["show_in_sidebar"] is False

View File

@@ -252,6 +252,7 @@ async def test_setup_api_panel(
"component_name": "custom",
"icon": None,
"title": None,
"show_in_sidebar": False,
"url_path": "hassio",
"require_admin": True,
"config_panel_domain": None,