Section support for data entry flows (#118369)
* Add expandable support for data entry form flows * Update config_validation.py * optional options * Adjust * Correct translations of data within sections * Update homeassistant/components/kitchen_sink/config_flow.py Co-authored-by: Robert Resch <robert@resch.dev> * Add missing import * Update tests/components/kitchen_sink/test_config_flow.py Co-authored-by: Robert Resch <robert@resch.dev> * Format code * Match frontend when serializing * Move section class to data_entry_flow * Correct serializing * Fix import in kitchen_sink * Move and update test --------- Co-authored-by: Bram Kragten <mail@bramkragten.nl> Co-authored-by: Robert Resch <robert@resch.dev>
This commit is contained in:
parent
3d1ff72a88
commit
0545ed8082
9 changed files with 222 additions and 1 deletions
|
@ -4,16 +4,36 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant import data_entry_flow
|
||||||
|
from homeassistant.config_entries import (
|
||||||
|
ConfigEntry,
|
||||||
|
ConfigFlow,
|
||||||
|
ConfigFlowResult,
|
||||||
|
OptionsFlowWithConfigEntry,
|
||||||
|
)
|
||||||
|
from homeassistant.core import callback
|
||||||
|
|
||||||
from . import DOMAIN
|
from . import DOMAIN
|
||||||
|
|
||||||
|
CONF_BOOLEAN = "bool"
|
||||||
|
CONF_INT = "int"
|
||||||
|
|
||||||
|
|
||||||
class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN):
|
class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
"""Kitchen Sink configuration flow."""
|
"""Kitchen Sink configuration flow."""
|
||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@callback
|
||||||
|
def async_get_options_flow(
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
) -> OptionsFlowHandler:
|
||||||
|
"""Get the options flow for this handler."""
|
||||||
|
return OptionsFlowHandler(config_entry)
|
||||||
|
|
||||||
async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
|
async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
|
||||||
"""Set the config entry up from yaml."""
|
"""Set the config entry up from yaml."""
|
||||||
if self._async_current_entries():
|
if self._async_current_entries():
|
||||||
|
@ -30,3 +50,50 @@ class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
if user_input is None:
|
if user_input is None:
|
||||||
return self.async_show_form(step_id="reauth_confirm")
|
return self.async_show_form(step_id="reauth_confirm")
|
||||||
return self.async_abort(reason="reauth_successful")
|
return self.async_abort(reason="reauth_successful")
|
||||||
|
|
||||||
|
|
||||||
|
class OptionsFlowHandler(OptionsFlowWithConfigEntry):
|
||||||
|
"""Handle options."""
|
||||||
|
|
||||||
|
async def async_step_init(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Manage the options."""
|
||||||
|
return await self.async_step_options_1()
|
||||||
|
|
||||||
|
async def async_step_options_1(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Manage the options."""
|
||||||
|
if user_input is not None:
|
||||||
|
self.options.update(user_input)
|
||||||
|
return await self._update_options()
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="options_1",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("section_1"): data_entry_flow.section(
|
||||||
|
vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(
|
||||||
|
CONF_BOOLEAN,
|
||||||
|
default=self.config_entry.options.get(
|
||||||
|
CONF_BOOLEAN, False
|
||||||
|
),
|
||||||
|
): bool,
|
||||||
|
vol.Optional(
|
||||||
|
CONF_INT,
|
||||||
|
default=self.config_entry.options.get(CONF_INT, 10),
|
||||||
|
): int,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{"collapsed": False},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _update_options(self) -> ConfigFlowResult:
|
||||||
|
"""Update config entry options."""
|
||||||
|
return self.async_create_entry(title="", data=self.options)
|
||||||
|
|
11
homeassistant/components/kitchen_sink/icons.json
Normal file
11
homeassistant/components/kitchen_sink/icons.json
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"options_1": {
|
||||||
|
"section": {
|
||||||
|
"section_1": "mdi:robot"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,6 +6,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {}
|
||||||
|
},
|
||||||
|
"options_1": {
|
||||||
|
"section": {
|
||||||
|
"section_1": {
|
||||||
|
"data": {
|
||||||
|
"bool": "Optional boolean",
|
||||||
|
"int": "Numeric input"
|
||||||
|
},
|
||||||
|
"description": "This section allows input of some extra data",
|
||||||
|
"name": "Collapsible section"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": "Save!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"device": {
|
"device": {
|
||||||
"n_ch_power_strip": {
|
"n_ch_power_strip": {
|
||||||
"name": "Power strip with {number_of_sockets} sockets"
|
"name": "Power strip with {number_of_sockets} sockets"
|
||||||
|
|
|
@ -906,6 +906,33 @@ class FlowHandler(Generic[_FlowResultT, _HandlerT]):
|
||||||
self.__progress_task = progress_task
|
self.__progress_task = progress_task
|
||||||
|
|
||||||
|
|
||||||
|
class SectionConfig(TypedDict, total=False):
|
||||||
|
"""Class to represent a section config."""
|
||||||
|
|
||||||
|
collapsed: bool
|
||||||
|
|
||||||
|
|
||||||
|
class section:
|
||||||
|
"""Data entry flow section."""
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional("collapsed", default=False): bool,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, schema: vol.Schema, options: SectionConfig | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Initialize."""
|
||||||
|
self.schema = schema
|
||||||
|
self.options: SectionConfig = self.CONFIG_SCHEMA(options or {})
|
||||||
|
|
||||||
|
def __call__(self, value: Any) -> Any:
|
||||||
|
"""Validate input."""
|
||||||
|
return self.schema(value)
|
||||||
|
|
||||||
|
|
||||||
# These can be removed if no deprecated constant are in this module anymore
|
# These can be removed if no deprecated constant are in this module anymore
|
||||||
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
|
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
|
||||||
__dir__ = partial(
|
__dir__ = partial(
|
||||||
|
|
|
@ -1037,6 +1037,7 @@ def key_dependency(
|
||||||
|
|
||||||
def custom_serializer(schema: Any) -> Any:
|
def custom_serializer(schema: Any) -> Any:
|
||||||
"""Serialize additional types for voluptuous_serialize."""
|
"""Serialize additional types for voluptuous_serialize."""
|
||||||
|
from .. import data_entry_flow # pylint: disable=import-outside-toplevel
|
||||||
from . import selector # pylint: disable=import-outside-toplevel
|
from . import selector # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
if schema is positive_time_period_dict:
|
if schema is positive_time_period_dict:
|
||||||
|
@ -1048,6 +1049,15 @@ def custom_serializer(schema: Any) -> Any:
|
||||||
if schema is boolean:
|
if schema is boolean:
|
||||||
return {"type": "boolean"}
|
return {"type": "boolean"}
|
||||||
|
|
||||||
|
if isinstance(schema, data_entry_flow.section):
|
||||||
|
return {
|
||||||
|
"type": "expandable",
|
||||||
|
"schema": voluptuous_serialize.convert(
|
||||||
|
schema.schema, custom_serializer=custom_serializer
|
||||||
|
),
|
||||||
|
"expanded": not schema.options["collapsed"],
|
||||||
|
}
|
||||||
|
|
||||||
if isinstance(schema, multi_select):
|
if isinstance(schema, multi_select):
|
||||||
return {"type": "multi_select", "options": schema.options}
|
return {"type": "multi_select", "options": schema.options}
|
||||||
|
|
||||||
|
|
|
@ -47,6 +47,19 @@ def ensure_not_same_as_default(value: dict) -> dict:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
DATA_ENTRY_ICONS_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
"step": {
|
||||||
|
str: {
|
||||||
|
"section": {
|
||||||
|
str: icon_value_validator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def icon_schema(integration_type: str, no_entity_platform: bool) -> vol.Schema:
|
def icon_schema(integration_type: str, no_entity_platform: bool) -> vol.Schema:
|
||||||
"""Create an icon schema."""
|
"""Create an icon schema."""
|
||||||
|
|
||||||
|
@ -73,6 +86,11 @@ def icon_schema(integration_type: str, no_entity_platform: bool) -> vol.Schema:
|
||||||
|
|
||||||
schema = vol.Schema(
|
schema = vol.Schema(
|
||||||
{
|
{
|
||||||
|
vol.Optional("config"): DATA_ENTRY_ICONS_SCHEMA,
|
||||||
|
vol.Optional("issues"): vol.Schema(
|
||||||
|
{str: {"fix_flow": DATA_ENTRY_ICONS_SCHEMA}}
|
||||||
|
),
|
||||||
|
vol.Optional("options"): DATA_ENTRY_ICONS_SCHEMA,
|
||||||
vol.Optional("services"): state_validator,
|
vol.Optional("services"): state_validator,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
@ -166,6 +166,13 @@ def gen_data_entry_schema(
|
||||||
vol.Optional("data_description"): {str: translation_value_validator},
|
vol.Optional("data_description"): {str: translation_value_validator},
|
||||||
vol.Optional("menu_options"): {str: translation_value_validator},
|
vol.Optional("menu_options"): {str: translation_value_validator},
|
||||||
vol.Optional("submit"): translation_value_validator,
|
vol.Optional("submit"): translation_value_validator,
|
||||||
|
vol.Optional("section"): {
|
||||||
|
str: {
|
||||||
|
vol.Optional("data"): {str: translation_value_validator},
|
||||||
|
vol.Optional("description"): translation_value_validator,
|
||||||
|
vol.Optional("name"): translation_value_validator,
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
vol.Optional("error"): {str: translation_value_validator},
|
vol.Optional("error"): {str: translation_value_validator},
|
||||||
|
|
|
@ -1,13 +1,28 @@
|
||||||
"""Test the Everything but the Kitchen Sink config flow."""
|
"""Test the Everything but the Kitchen Sink config flow."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from homeassistant import config_entries, setup
|
from homeassistant import config_entries, setup
|
||||||
from homeassistant.components.kitchen_sink import DOMAIN
|
from homeassistant.components.kitchen_sink import DOMAIN
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.data_entry_flow import FlowResultType
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def no_platforms() -> AsyncGenerator[None, None]:
|
||||||
|
"""Don't enable any platforms."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.kitchen_sink.COMPONENTS_WITH_DEMO_PLATFORM",
|
||||||
|
[],
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
async def test_import(hass: HomeAssistant) -> None:
|
async def test_import(hass: HomeAssistant) -> None:
|
||||||
"""Test that we can import a config entry."""
|
"""Test that we can import a config entry."""
|
||||||
|
@ -66,3 +81,26 @@ async def test_reauth(hass: HomeAssistant) -> None:
|
||||||
|
|
||||||
assert result["type"] is FlowResultType.ABORT
|
assert result["type"] is FlowResultType.ABORT
|
||||||
assert result["reason"] == "reauth_successful"
|
assert result["reason"] == "reauth_successful"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("no_platforms")
|
||||||
|
async def test_options_flow(hass: HomeAssistant) -> None:
|
||||||
|
"""Test config flow options."""
|
||||||
|
config_entry = MockConfigEntry(domain=DOMAIN)
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "options_1"
|
||||||
|
|
||||||
|
result = await hass.config_entries.options.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={"section_1": {"bool": True, "int": 15}},
|
||||||
|
)
|
||||||
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
assert config_entry.options == {"section_1": {"bool": True, "int": 15}}
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
|
@ -10,6 +10,7 @@ import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries, data_entry_flow
|
from homeassistant import config_entries, data_entry_flow
|
||||||
from homeassistant.core import Event, HomeAssistant, callback
|
from homeassistant.core import Event, HomeAssistant, callback
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.util.decorator import Registry
|
from homeassistant.util.decorator import Registry
|
||||||
|
|
||||||
from .common import (
|
from .common import (
|
||||||
|
@ -1075,3 +1076,25 @@ def test_deprecated_constants(
|
||||||
import_and_test_deprecated_constant_enum(
|
import_and_test_deprecated_constant_enum(
|
||||||
caplog, data_entry_flow, enum, "RESULT_TYPE_", "2025.1"
|
caplog, data_entry_flow, enum, "RESULT_TYPE_", "2025.1"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_section_in_serializer() -> None:
|
||||||
|
"""Test section with custom_serializer."""
|
||||||
|
assert cv.custom_serializer(
|
||||||
|
data_entry_flow.section(
|
||||||
|
vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional("option_1", default=False): bool,
|
||||||
|
vol.Required("option_2"): int,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{"collapsed": False},
|
||||||
|
)
|
||||||
|
) == {
|
||||||
|
"expanded": True,
|
||||||
|
"schema": [
|
||||||
|
{"default": False, "name": "option_1", "optional": True, "type": "boolean"},
|
||||||
|
{"name": "option_2", "required": True, "type": "integer"},
|
||||||
|
],
|
||||||
|
"type": "expandable",
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue