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:
Erik Montnemery 2024-06-25 11:02:00 +02:00 committed by GitHub
parent 3d1ff72a88
commit 0545ed8082
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 222 additions and 1 deletions

View file

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

View file

@ -0,0 +1,11 @@
{
"options": {
"step": {
"options_1": {
"section": {
"section_1": "mdi:robot"
}
}
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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