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
This commit is contained in:
Paulus Schoutsen 2020-02-28 12:43:17 -08:00 committed by GitHub
parent ede39454a2
commit deda2f86e7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 447 additions and 90 deletions

View file

@ -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

View file

@ -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)

View file

@ -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."""

View file

@ -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

View file

@ -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
],
)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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",
},
)