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 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
CONF_BOOLEAN = "bool"
CONF_INT = "int"
class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN):
"""Kitchen Sink configuration flow."""
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:
"""Set the config entry up from yaml."""
if self._async_current_entries():
@ -30,3 +50,50 @@ class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
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": {
"n_ch_power_strip": {
"name": "Power strip with {number_of_sockets} sockets"

View File

@ -906,6 +906,33 @@ class FlowHandler(Generic[_FlowResultT, _HandlerT]):
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
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
__dir__ = partial(

View File

@ -1037,6 +1037,7 @@ def key_dependency(
def custom_serializer(schema: Any) -> Any:
"""Serialize additional types for voluptuous_serialize."""
from .. import data_entry_flow # pylint: disable=import-outside-toplevel
from . import selector # pylint: disable=import-outside-toplevel
if schema is positive_time_period_dict:
@ -1048,6 +1049,15 @@ def custom_serializer(schema: Any) -> Any:
if schema is 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):
return {"type": "multi_select", "options": schema.options}

View File

@ -47,6 +47,19 @@ def ensure_not_same_as_default(value: dict) -> dict:
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:
"""Create an icon schema."""
@ -73,6 +86,11 @@ def icon_schema(integration_type: str, no_entity_platform: bool) -> 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,
}
)

View File

@ -166,6 +166,13 @@ def gen_data_entry_schema(
vol.Optional("data_description"): {str: translation_value_validator},
vol.Optional("menu_options"): {str: 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},

View File

@ -1,13 +1,28 @@
"""Test the Everything but the Kitchen Sink config flow."""
from collections.abc import AsyncGenerator
from unittest.mock import patch
import pytest
from homeassistant import config_entries, setup
from homeassistant.components.kitchen_sink import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
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:
"""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["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.core import Event, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.util.decorator import Registry
from .common import (
@ -1075,3 +1076,25 @@ def test_deprecated_constants(
import_and_test_deprecated_constant_enum(
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",
}