diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index 93c8a292ba9..c561ca29b8a 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -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) diff --git a/homeassistant/components/kitchen_sink/icons.json b/homeassistant/components/kitchen_sink/icons.json new file mode 100644 index 00000000000..85472996819 --- /dev/null +++ b/homeassistant/components/kitchen_sink/icons.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "options_1": { + "section": { + "section_1": "mdi:robot" + } + } + } + } +} diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json index ecfbe406aab..e67527d8468 100644 --- a/homeassistant/components/kitchen_sink/strings.json +++ b/homeassistant/components/kitchen_sink/strings.json @@ -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" diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index de45702ad95..155e64d259e 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -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( diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 295cd13fed4..0463bb07e11 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -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} diff --git a/script/hassfest/icons.py b/script/hassfest/icons.py index e7451dfd498..087d395afeb 100644 --- a/script/hassfest/icons.py +++ b/script/hassfest/icons.py @@ -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, } ) diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 7ffb5861bb4..965d1dc62b8 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -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}, diff --git a/tests/components/kitchen_sink/test_config_flow.py b/tests/components/kitchen_sink/test_config_flow.py index e530ed0e6f3..290167196cd 100644 --- a/tests/components/kitchen_sink/test_config_flow.py +++ b/tests/components/kitchen_sink/test_config_flow.py @@ -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() diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 782f349f9f2..967b2565206 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -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", + }