From deda2f86e7e138fd22f2853bc42ca72a6387976a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 28 Feb 2020 12:43:17 -0800 Subject: [PATCH] Allow managing Lovelace storage dashboards (#32241) * Allow managing Lovelace storage dashboards * Make sure we do not allow duplicate url paths * Allow setting sidebar to None * Fix tests * Delete storage file on delete * List all dashboards --- homeassistant/components/frontend/__init__.py | 4 +- homeassistant/components/lovelace/__init__.py | 139 +++++++++----- homeassistant/components/lovelace/const.py | 50 ++++- .../components/lovelace/dashboard.py | 87 +++++++-- .../components/lovelace/websocket.py | 15 ++ homeassistant/components/zone/__init__.py | 4 +- homeassistant/helpers/collection.py | 30 ++- homeassistant/helpers/storage.py | 7 + tests/common.py | 8 + tests/components/lovelace/test_dashboard.py | 177 +++++++++++++++++- tests/helpers/test_collection.py | 16 +- 11 files changed, 447 insertions(+), 90 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 5864c642fa9..1e3dea98619 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -171,6 +171,8 @@ def async_register_built_in_panel( frontend_url_path=None, config=None, require_admin=False, + *, + update=False, ): """Register a built-in panel.""" panel = Panel( @@ -184,7 +186,7 @@ def async_register_built_in_panel( panels = hass.data.setdefault(DATA_PANELS, {}) - if panel.frontend_url_path in panels: + if not update and panel.frontend_url_path in panels: raise ValueError(f"Overwriting panel {panel.frontend_url_path}") panels[panel.frontend_url_path] = panel diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index e7c309be719..65c3b11b369 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -1,65 +1,48 @@ """Support for the Lovelace UI.""" import logging -from typing import Any import voluptuous as vol from homeassistant.components import frontend -from homeassistant.const import CONF_FILENAME, CONF_ICON +from homeassistant.const import CONF_FILENAME +from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, config_validation as cv -from homeassistant.util import sanitize_filename, slugify +from homeassistant.util import sanitize_filename from . import dashboard, resources, websocket from .const import ( + CONF_ICON, + CONF_MODE, + CONF_REQUIRE_ADMIN, CONF_RESOURCES, + CONF_SIDEBAR, + CONF_TITLE, + CONF_URL_PATH, + DASHBOARD_BASE_CREATE_FIELDS, DOMAIN, - LOVELACE_CONFIG_FILE, MODE_STORAGE, MODE_YAML, RESOURCE_CREATE_FIELDS, RESOURCE_SCHEMA, RESOURCE_UPDATE_FIELDS, + STORAGE_DASHBOARD_CREATE_FIELDS, + STORAGE_DASHBOARD_UPDATE_FIELDS, + url_slug, ) _LOGGER = logging.getLogger(__name__) -CONF_MODE = "mode" - CONF_DASHBOARDS = "dashboards" -CONF_SIDEBAR = "sidebar" -CONF_TITLE = "title" -CONF_REQUIRE_ADMIN = "require_admin" -DASHBOARD_BASE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_REQUIRE_ADMIN, default=False): cv.boolean, - vol.Optional(CONF_SIDEBAR): { - vol.Required(CONF_ICON): cv.icon, - vol.Required(CONF_TITLE): cv.string, - }, - } -) - -YAML_DASHBOARD_SCHEMA = DASHBOARD_BASE_SCHEMA.extend( +YAML_DASHBOARD_SCHEMA = vol.Schema( { + **DASHBOARD_BASE_CREATE_FIELDS, vol.Required(CONF_MODE): MODE_YAML, vol.Required(CONF_FILENAME): vol.All(cv.string, sanitize_filename), } ) - -def url_slug(value: Any) -> str: - """Validate value is a valid url slug.""" - if value is None: - raise vol.Invalid("Slug should not be None") - str_value = str(value) - slg = slugify(str_value, separator="-") - if str_value == slg: - return str_value - raise vol.Invalid(f"invalid slug {value} (try {slg})") - - CONFIG_SCHEMA = vol.Schema( { vol.Optional(DOMAIN, default={}): vol.Schema( @@ -80,14 +63,13 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass, config): """Set up the Lovelace commands.""" - # Pass in default to `get` because defaults not set if loaded as dep mode = config[DOMAIN][CONF_MODE] yaml_resources = config[DOMAIN].get(CONF_RESOURCES) frontend.async_register_built_in_panel(hass, DOMAIN, config={"mode": mode}) if mode == MODE_YAML: - default_config = dashboard.LovelaceYAML(hass, None, LOVELACE_CONFIG_FILE) + default_config = dashboard.LovelaceYAML(hass, None, None) if yaml_resources is None: try: @@ -134,6 +116,10 @@ async def async_setup(hass, config): websocket.websocket_lovelace_resources ) + hass.components.websocket_api.async_register_command( + websocket.websocket_lovelace_dashboards + ) + hass.components.system_health.async_register_info(DOMAIN, system_health_info) hass.data[DOMAIN] = { @@ -142,34 +128,87 @@ async def async_setup(hass, config): "resources": resource_collection, } - if hass.config.safe_mode or CONF_DASHBOARDS not in config[DOMAIN]: + if hass.config.safe_mode: return True - for url_path, dashboard_conf in config[DOMAIN][CONF_DASHBOARDS].items(): + # Process YAML dashboards + for url_path, dashboard_conf in config[DOMAIN].get(CONF_DASHBOARDS, {}).items(): # For now always mode=yaml - config = dashboard.LovelaceYAML(hass, url_path, dashboard_conf[CONF_FILENAME]) + config = dashboard.LovelaceYAML(hass, url_path, dashboard_conf) hass.data[DOMAIN]["dashboards"][url_path] = config - kwargs = { - "hass": hass, - "component_name": DOMAIN, - "frontend_url_path": url_path, - "require_admin": dashboard_conf[CONF_REQUIRE_ADMIN], - "config": {"mode": dashboard_conf[CONF_MODE]}, - } - - if CONF_SIDEBAR in dashboard_conf: - kwargs["sidebar_title"] = dashboard_conf[CONF_SIDEBAR][CONF_TITLE] - kwargs["sidebar_icon"] = dashboard_conf[CONF_SIDEBAR][CONF_ICON] - try: - frontend.async_register_built_in_panel(**kwargs) + _register_panel(hass, url_path, MODE_YAML, dashboard_conf, False) except ValueError: _LOGGER.warning("Panel url path %s is not unique", url_path) + # Process storage dashboards + dashboards_collection = dashboard.DashboardsCollection(hass) + + async def storage_dashboard_changed(change_type, item_id, item): + """Handle a storage dashboard change.""" + url_path = item[CONF_URL_PATH] + + if change_type == collection.CHANGE_REMOVED: + frontend.async_remove_panel(hass, url_path) + await hass.data[DOMAIN]["dashboards"].pop(url_path).async_delete() + return + + if change_type == collection.CHANGE_ADDED: + existing = hass.data[DOMAIN]["dashboards"].get(url_path) + + if existing: + _LOGGER.warning( + "Cannot register panel at %s, it is already defined in %s", + url_path, + existing, + ) + return + + hass.data[DOMAIN]["dashboards"][url_path] = dashboard.LovelaceStorage( + hass, item + ) + + update = False + else: + update = True + + try: + _register_panel(hass, url_path, MODE_STORAGE, item, update) + except ValueError: + _LOGGER.warning("Failed to %s panel %s from storage", change_type, url_path) + + dashboards_collection.async_add_listener(storage_dashboard_changed) + await dashboards_collection.async_load() + + collection.StorageCollectionWebsocket( + dashboards_collection, + "lovelace/dashboards", + "dashboard", + STORAGE_DASHBOARD_CREATE_FIELDS, + STORAGE_DASHBOARD_UPDATE_FIELDS, + ).async_setup(hass, create_list=False) + return True async def system_health_info(hass): """Get info for the info page.""" return await hass.data[DOMAIN]["dashboards"][None].async_get_info() + + +@callback +def _register_panel(hass, url_path, mode, config, update): + """Register a panel.""" + kwargs = { + "frontend_url_path": url_path, + "require_admin": config[CONF_REQUIRE_ADMIN], + "config": {"mode": mode}, + "update": update, + } + + if CONF_SIDEBAR in config: + kwargs["sidebar_title"] = config[CONF_SIDEBAR][CONF_TITLE] + kwargs["sidebar_icon"] = config[CONF_SIDEBAR][CONF_ICON] + + frontend.async_register_built_in_panel(hass, DOMAIN, **kwargs) diff --git a/homeassistant/components/lovelace/const.py b/homeassistant/components/lovelace/const.py index 1e984b3d82d..de6aa99894a 100644 --- a/homeassistant/components/lovelace/const.py +++ b/homeassistant/components/lovelace/const.py @@ -1,13 +1,17 @@ """Constants for Lovelace.""" +from typing import Any + import voluptuous as vol -from homeassistant.const import CONF_TYPE, CONF_URL +from homeassistant.const import CONF_ICON, CONF_TYPE, CONF_URL from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv +from homeassistant.util import slugify DOMAIN = "lovelace" EVENT_LOVELACE_UPDATED = "lovelace_updated" +CONF_MODE = "mode" MODE_YAML = "yaml" MODE_STORAGE = "storage" @@ -35,6 +39,50 @@ RESOURCE_UPDATE_FIELDS = { vol.Optional(CONF_URL): cv.string, } +CONF_SIDEBAR = "sidebar" +CONF_TITLE = "title" +CONF_REQUIRE_ADMIN = "require_admin" + +SIDEBAR_FIELDS = { + vol.Required(CONF_ICON): cv.icon, + vol.Required(CONF_TITLE): cv.string, +} + +DASHBOARD_BASE_CREATE_FIELDS = { + vol.Optional(CONF_REQUIRE_ADMIN, default=False): cv.boolean, + vol.Optional(CONF_SIDEBAR): SIDEBAR_FIELDS, +} + + +DASHBOARD_BASE_UPDATE_FIELDS = { + vol.Optional(CONF_REQUIRE_ADMIN): cv.boolean, + vol.Optional(CONF_SIDEBAR): vol.Any(None, SIDEBAR_FIELDS), +} + + +STORAGE_DASHBOARD_CREATE_FIELDS = { + **DASHBOARD_BASE_CREATE_FIELDS, + vol.Required(CONF_URL_PATH): cv.string, + # For now we write "storage" as all modes. + # In future we can adjust this to be other modes. + vol.Optional(CONF_MODE, default=MODE_STORAGE): MODE_STORAGE, +} + +STORAGE_DASHBOARD_UPDATE_FIELDS = { + **DASHBOARD_BASE_UPDATE_FIELDS, +} + + +def url_slug(value: Any) -> str: + """Validate value is a valid url slug.""" + if value is None: + raise vol.Invalid("Slug should not be None") + str_value = str(value) + slg = slugify(str_value, separator="-") + if str_value == slg: + return str_value + raise vol.Invalid(f"invalid slug {value} (try {slg})") + class ConfigNotFound(HomeAssistantError): """When no config available.""" diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index 11cb3266755..cd0d4a6fea8 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -1,32 +1,53 @@ """Lovelace dashboard support.""" from abc import ABC, abstractmethod +import logging import os import time +import voluptuous as vol + +from homeassistant.const import CONF_FILENAME from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import storage +from homeassistant.helpers import collection, storage from homeassistant.util.yaml import load_yaml from .const import ( + CONF_SIDEBAR, + CONF_URL_PATH, DOMAIN, EVENT_LOVELACE_UPDATED, + LOVELACE_CONFIG_FILE, MODE_STORAGE, MODE_YAML, + STORAGE_DASHBOARD_CREATE_FIELDS, + STORAGE_DASHBOARD_UPDATE_FIELDS, ConfigNotFound, ) CONFIG_STORAGE_KEY_DEFAULT = DOMAIN +CONFIG_STORAGE_KEY = "lovelace.{}" CONFIG_STORAGE_VERSION = 1 +DASHBOARDS_STORAGE_KEY = f"{DOMAIN}_dashboards" +DASHBOARDS_STORAGE_VERSION = 1 +_LOGGER = logging.getLogger(__name__) class LovelaceConfig(ABC): """Base class for Lovelace config.""" - def __init__(self, hass, url_path): + def __init__(self, hass, url_path, config): """Initialize Lovelace config.""" self.hass = hass - self.url_path = url_path + if config: + self.config = {**config, CONF_URL_PATH: url_path} + else: + self.config = None + + @property + def url_path(self) -> str: + """Return url path.""" + return self.config[CONF_URL_PATH] if self.config else None @property @abstractmethod @@ -58,13 +79,16 @@ class LovelaceConfig(ABC): class LovelaceStorage(LovelaceConfig): """Class to handle Storage based Lovelace config.""" - def __init__(self, hass, url_path): + def __init__(self, hass, config): """Initialize Lovelace config based on storage helper.""" - super().__init__(hass, url_path) - if url_path is None: + if config is None: + url_path = None storage_key = CONFIG_STORAGE_KEY_DEFAULT else: - raise ValueError("Storage-based dashboards are not supported") + url_path = config[CONF_URL_PATH] + storage_key = CONFIG_STORAGE_KEY.format(url_path) + + super().__init__(hass, url_path, config) self._store = storage.Store(hass, CONFIG_STORAGE_VERSION, storage_key) self._data = None @@ -115,7 +139,9 @@ class LovelaceStorage(LovelaceConfig): if self.hass.config.safe_mode: raise HomeAssistantError("Deleting not supported in safe mode") - await self.async_save(None) + await self._store.async_remove() + self._data = None + self._config_updated() async def _load(self): """Load the config.""" @@ -126,10 +152,13 @@ class LovelaceStorage(LovelaceConfig): class LovelaceYAML(LovelaceConfig): """Class to handle YAML-based Lovelace config.""" - def __init__(self, hass, url_path, path): + def __init__(self, hass, url_path, config): """Initialize the YAML config.""" - super().__init__(hass, url_path) - self.path = hass.config.path(path) + super().__init__(hass, url_path, config) + + self.path = hass.config.path( + config[CONF_FILENAME] if config else LOVELACE_CONFIG_FILE + ) self._cache = None @property @@ -185,3 +214,39 @@ def _config_info(mode, config): "resources": len(config.get("resources", [])), "views": len(config.get("views", [])), } + + +class DashboardsCollection(collection.StorageCollection): + """Collection of dashboards.""" + + CREATE_SCHEMA = vol.Schema(STORAGE_DASHBOARD_CREATE_FIELDS) + UPDATE_SCHEMA = vol.Schema(STORAGE_DASHBOARD_UPDATE_FIELDS) + + def __init__(self, hass): + """Initialize the dashboards collection.""" + super().__init__( + storage.Store(hass, DASHBOARDS_STORAGE_VERSION, DASHBOARDS_STORAGE_KEY), + _LOGGER, + ) + + async def _process_create_data(self, data: dict) -> dict: + """Validate the config is valid.""" + if data[CONF_URL_PATH] in self.hass.data[DOMAIN]["dashboards"]: + raise vol.Invalid("Dashboard url path needs to be unique") + + return self.CREATE_SCHEMA(data) + + @callback + def _get_suggested_id(self, info: dict) -> str: + """Suggest an ID based on the config.""" + return info[CONF_URL_PATH] + + async def _update_data(self, data: dict, update_data: dict) -> dict: + """Return a new updated data object.""" + update_data = self.UPDATE_SCHEMA(update_data) + updated = {**data, **update_data} + + if CONF_SIDEBAR in updated and updated[CONF_SIDEBAR] is None: + updated.pop(CONF_SIDEBAR) + + return updated diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py index d80764f4ed9..a4e67fda929 100644 --- a/homeassistant/components/lovelace/websocket.py +++ b/homeassistant/components/lovelace/websocket.py @@ -4,6 +4,7 @@ from functools import wraps import voluptuous as vol from homeassistant.components import websocket_api +from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -96,3 +97,17 @@ async def websocket_lovelace_save_config(hass, connection, msg, config): async def websocket_lovelace_delete_config(hass, connection, msg, config): """Delete Lovelace UI configuration.""" await config.async_delete() + + +@websocket_api.websocket_command({"type": "lovelace/dashboards/list"}) +@callback +def websocket_lovelace_dashboards(hass, connection, msg): + """Delete Lovelace UI configuration.""" + connection.send_result( + msg["id"], + [ + dashboard.config + for dashboard in hass.data[DOMAIN]["dashboards"].values() + if dashboard.config + ], + ) diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index d14e31273b7..c71026ea79c 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -189,9 +189,7 @@ async def async_setup(hass: HomeAssistant, config: Dict) -> bool: storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS ).async_setup(hass) - async def _collection_changed( - change_type: str, item_id: str, config: Optional[Dict] - ) -> None: + async def _collection_changed(change_type: str, item_id: str, config: Dict) -> None: """Handle a collection change: clean up entity registry on removals.""" if change_type != collection.CHANGE_REMOVED: return diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index d03469e20bb..8234dd6ec87 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -31,8 +31,8 @@ ChangeListener = Callable[ str, # Item ID str, - # New config (None if removed) - Optional[dict], + # New or removed config + dict, ], Awaitable[None], ] @@ -104,9 +104,7 @@ class ObservableCollection(ABC): """ self.listeners.append(listener) - async def notify_change( - self, change_type: str, item_id: str, item: Optional[dict] - ) -> None: + async def notify_change(self, change_type: str, item_id: str, item: dict) -> None: """Notify listeners of a change.""" self.logger.debug("%s %s: %s", change_type, item_id, item) for listener in self.listeners: @@ -136,8 +134,8 @@ class YamlCollection(ObservableCollection): await self.notify_change(event, item_id, item) for item_id in old_ids: - self.data.pop(item_id) - await self.notify_change(CHANGE_REMOVED, item_id, None) + + await self.notify_change(CHANGE_REMOVED, item_id, self.data.pop(item_id)) class StorageCollection(ObservableCollection): @@ -219,10 +217,10 @@ class StorageCollection(ObservableCollection): if item_id not in self.data: raise ItemNotFound(item_id) - self.data.pop(item_id) + item = self.data.pop(item_id) self._async_schedule_save() - await self.notify_change(CHANGE_REMOVED, item_id, None) + await self.notify_change(CHANGE_REMOVED, item_id, item) @callback def _async_schedule_save(self) -> None: @@ -242,8 +240,8 @@ class IDLessCollection(ObservableCollection): async def async_load(self, data: List[dict]) -> None: """Load the collection. Overrides existing data.""" - for item_id in list(self.data): - await self.notify_change(CHANGE_REMOVED, item_id, None) + for item_id, item in list(self.data.items()): + await self.notify_change(CHANGE_REMOVED, item_id, item) self.data.clear() @@ -264,12 +262,10 @@ def attach_entity_component_collection( """Map a collection to an entity component.""" entities = {} - async def _collection_changed( - change_type: str, item_id: str, config: Optional[dict] - ) -> None: + async def _collection_changed(change_type: str, item_id: str, config: dict) -> None: """Handle a collection change.""" if change_type == CHANGE_ADDED: - entity = create_entity(cast(dict, config)) + entity = create_entity(config) await entity_component.async_add_entities([entity]) # type: ignore entities[item_id] = entity return @@ -294,9 +290,7 @@ def attach_entity_registry_cleaner( ) -> None: """Attach a listener to clean up entity registry on collection changes.""" - async def _collection_changed( - change_type: str, item_id: str, config: Optional[Dict] - ) -> None: + async def _collection_changed(change_type: str, item_id: str, config: Dict) -> None: """Handle a collection change: clean up entity registry on removals.""" if change_type != CHANGE_REMOVED: return diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index aed6da37518..1cad8eec473 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -210,3 +210,10 @@ class Store: async def _async_migrate_func(self, old_version, old_data): """Migrate to the new version.""" raise NotImplementedError + + async def async_remove(self): + """Remove all data.""" + try: + await self.hass.async_add_executor_job(os.unlink, self.path) + except FileNotFoundError: + pass diff --git a/tests/common.py b/tests/common.py index 4581c96b52a..8fdcc9b8f86 100644 --- a/tests/common.py +++ b/tests/common.py @@ -992,6 +992,10 @@ def mock_storage(data=None): # To ensure that the data can be serialized data[store.key] = json.loads(json.dumps(data_to_write, cls=store._encoder)) + async def mock_remove(store): + """Remove data.""" + data.pop(store.key, None) + with patch( "homeassistant.helpers.storage.Store._async_load", side_effect=mock_async_load, @@ -1000,6 +1004,10 @@ def mock_storage(data=None): "homeassistant.helpers.storage.Store._write_data", side_effect=mock_write_data, autospec=True, + ), patch( + "homeassistant.helpers.storage.Store.async_remove", + side_effect=mock_remove, + autospec=True, ): yield data diff --git a/tests/components/lovelace/test_dashboard.py b/tests/components/lovelace/test_dashboard.py index 1d385ba3bec..0b6d6806cb0 100644 --- a/tests/components/lovelace/test_dashboard.py +++ b/tests/components/lovelace/test_dashboard.py @@ -98,9 +98,7 @@ async def test_lovelace_from_storage_delete(hass, hass_ws_client, hass_storage): await client.send_json({"id": 7, "type": "lovelace/config/delete"}) response = await client.receive_json() assert response["success"] - assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == { - "config": None - } + assert dashboard.CONFIG_STORAGE_KEY_DEFAULT not in hass_storage # Fetch data await client.send_json({"id": 8, "type": "lovelace/config"}) @@ -212,8 +210,9 @@ async def test_dashboard_from_yaml(hass, hass_ws_client, url_path): "mode": "yaml", "filename": "bla.yaml", "sidebar": {"title": "Test Panel", "icon": "mdi:test-icon"}, + "require_admin": True, }, - "test-panel-no-sidebar": {"mode": "yaml", "filename": "bla.yaml"}, + "test-panel-no-sidebar": {"mode": "yaml", "filename": "bla2.yaml"}, } } }, @@ -225,6 +224,25 @@ async def test_dashboard_from_yaml(hass, hass_ws_client, url_path): client = await hass_ws_client(hass) + # List dashboards + await client.send_json({"id": 4, "type": "lovelace/dashboards/list"}) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]) == 2 + with_sb, without_sb = response["result"] + + assert with_sb["mode"] == "yaml" + assert with_sb["filename"] == "bla.yaml" + assert with_sb["sidebar"] == {"title": "Test Panel", "icon": "mdi:test-icon"} + assert with_sb["require_admin"] is True + assert with_sb["url_path"] == "test-panel" + + assert without_sb["mode"] == "yaml" + assert without_sb["filename"] == "bla2.yaml" + assert "sidebar" not in without_sb + assert without_sb["require_admin"] is False + assert without_sb["url_path"] == "test-panel-no-sidebar" + # Fetch data await client.send_json({"id": 5, "type": "lovelace/config", "url_path": url_path}) response = await client.receive_json() @@ -275,3 +293,154 @@ async def test_dashboard_from_yaml(hass, hass_ws_client, url_path): assert response["result"] == {"hello": "yo2"} assert len(events) == 1 + + +async def test_storage_dashboards(hass, hass_ws_client, hass_storage): + """Test we load lovelace config from storage.""" + assert await async_setup_component(hass, "lovelace", {}) + assert hass.data[frontend.DATA_PANELS]["lovelace"].config == {"mode": "storage"} + + client = await hass_ws_client(hass) + + # Fetch data + await client.send_json({"id": 5, "type": "lovelace/dashboards/list"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [] + + # Add a dashboard + await client.send_json( + { + "id": 6, + "type": "lovelace/dashboards/create", + "url_path": "created_url_path", + "require_admin": True, + "sidebar": {"title": "Updated Title", "icon": "mdi:map"}, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"]["require_admin"] is True + assert response["result"]["sidebar"] == { + "title": "Updated Title", + "icon": "mdi:map", + } + + dashboard_id = response["result"]["id"] + + assert "created_url_path" in hass.data[frontend.DATA_PANELS] + + await client.send_json({"id": 7, "type": "lovelace/dashboards/list"}) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]) == 1 + assert response["result"][0]["mode"] == "storage" + assert response["result"][0]["sidebar"] == { + "title": "Updated Title", + "icon": "mdi:map", + } + assert response["result"][0]["require_admin"] is True + + # Fetch config + await client.send_json( + {"id": 8, "type": "lovelace/config", "url_path": "created_url_path"} + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "config_not_found" + + # Store new config + events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED) + + await client.send_json( + { + "id": 9, + "type": "lovelace/config/save", + "url_path": "created_url_path", + "config": {"yo": "hello"}, + } + ) + response = await client.receive_json() + assert response["success"] + assert hass_storage[dashboard.CONFIG_STORAGE_KEY.format(dashboard_id)]["data"] == { + "config": {"yo": "hello"} + } + assert len(events) == 1 + assert events[0].data["url_path"] == "created_url_path" + + await client.send_json( + {"id": 10, "type": "lovelace/config", "url_path": "created_url_path"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"yo": "hello"} + + # Update a dashboard + await client.send_json( + { + "id": 11, + "type": "lovelace/dashboards/update", + "dashboard_id": dashboard_id, + "require_admin": False, + "sidebar": None, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"]["require_admin"] is False + assert "sidebar" not in response["result"] + + # Add dashboard with existing url path + await client.send_json( + {"id": 12, "type": "lovelace/dashboards/create", "url_path": "created_url_path"} + ) + response = await client.receive_json() + assert not response["success"] + + # Delete dashboards + await client.send_json( + {"id": 13, "type": "lovelace/dashboards/delete", "dashboard_id": dashboard_id} + ) + response = await client.receive_json() + assert response["success"] + + assert "created_url_path" not in hass.data[frontend.DATA_PANELS] + assert dashboard.CONFIG_STORAGE_KEY.format(dashboard_id) not in hass_storage + + +async def test_websocket_list_dashboards(hass, hass_ws_client): + """Test listing dashboards both storage + YAML.""" + assert await async_setup_component( + hass, + "lovelace", + { + "lovelace": { + "dashboards": { + "test-panel-no-sidebar": {"mode": "yaml", "filename": "bla.yaml"}, + } + } + }, + ) + + client = await hass_ws_client(hass) + + # Create a storage dashboard + await client.send_json( + {"id": 6, "type": "lovelace/dashboards/create", "url_path": "created_url_path"} + ) + response = await client.receive_json() + assert response["success"] + + # List dashboards + await client.send_json({"id": 7, "type": "lovelace/dashboards/list"}) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]) == 2 + with_sb, without_sb = response["result"] + + assert with_sb["mode"] == "yaml" + assert with_sb["filename"] == "bla.yaml" + assert with_sb["url_path"] == "test-panel-no-sidebar" + + assert without_sb["mode"] == "storage" + assert without_sb["url_path"] == "created_url_path" diff --git a/tests/helpers/test_collection.py b/tests/helpers/test_collection.py index 1cc600c3f01..f2224858bb5 100644 --- a/tests/helpers/test_collection.py +++ b/tests/helpers/test_collection.py @@ -133,7 +133,11 @@ async def test_yaml_collection(): "mock-3", {"id": "mock-3", "name": "Mock 3"}, ) - assert changes[4] == (collection.CHANGE_REMOVED, "mock-2", None,) + assert changes[4] == ( + collection.CHANGE_REMOVED, + "mock-2", + {"id": "mock-2", "name": "Mock 2"}, + ) async def test_yaml_collection_skipping_duplicate_ids(): @@ -370,4 +374,12 @@ async def test_storage_collection_websocket(hass, hass_ws_client): assert response["success"] assert len(changes) == 3 - assert changes[2] == (collection.CHANGE_REMOVED, "initial_name", None) + assert changes[2] == ( + collection.CHANGE_REMOVED, + "initial_name", + { + "id": "initial_name", + "immutable_string": "no-changes", + "name": "Updated name", + }, + )