diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 46311af94e0..080ef86fac6 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -21,6 +21,7 @@ RESULT_TYPE_EXTERNAL_STEP = "external" RESULT_TYPE_EXTERNAL_STEP_DONE = "external_done" RESULT_TYPE_SHOW_PROGRESS = "progress" RESULT_TYPE_SHOW_PROGRESS_DONE = "progress_done" +RESULT_TYPE_MENU = "menu" # Event that is fired when a flow is progressed via external or progress source. EVENT_DATA_ENTRY_FLOW_PROGRESSED = "data_entry_flow_progressed" @@ -82,6 +83,7 @@ class FlowResult(TypedDict, total=False): result: Any last_step: bool | None options: Mapping[str, Any] + menu_options: list[str] | Mapping[str, Any] @callback @@ -249,7 +251,15 @@ class FlowManager(abc.ABC): if cur_step.get("data_schema") is not None and user_input is not None: user_input = cur_step["data_schema"](user_input) - result = await self._async_handle_step(flow, cur_step["step_id"], user_input) + # Handle a menu navigation choice + if cur_step["type"] == RESULT_TYPE_MENU and user_input: + result = await self._async_handle_step( + flow, user_input["next_step_id"], None + ) + else: + result = await self._async_handle_step( + flow, cur_step["step_id"], user_input + ) if cur_step["type"] in (RESULT_TYPE_EXTERNAL_STEP, RESULT_TYPE_SHOW_PROGRESS): if cur_step["type"] == RESULT_TYPE_EXTERNAL_STEP and result["type"] not in ( @@ -343,6 +353,7 @@ class FlowManager(abc.ABC): RESULT_TYPE_EXTERNAL_STEP_DONE, RESULT_TYPE_SHOW_PROGRESS, RESULT_TYPE_SHOW_PROGRESS_DONE, + RESULT_TYPE_MENU, ): raise ValueError(f"Handler returned incorrect type: {result['type']}") @@ -352,6 +363,7 @@ class FlowManager(abc.ABC): RESULT_TYPE_EXTERNAL_STEP_DONE, RESULT_TYPE_SHOW_PROGRESS, RESULT_TYPE_SHOW_PROGRESS_DONE, + RESULT_TYPE_MENU, ): flow.cur_step = result return result @@ -507,6 +519,28 @@ class FlowHandler: "step_id": next_step_id, } + @callback + def async_show_menu( + self, + *, + step_id: str, + menu_options: list[str] | dict[str, str], + description_placeholders: dict | None = None, + ) -> FlowResult: + """Show a navigation menu to the user. + + Options dict maps step_id => i18n label + """ + return { + "type": RESULT_TYPE_MENU, + "flow_id": self.flow_id, + "handler": self.handler, + "step_id": step_id, + "data_schema": vol.Schema({"next_step_id": vol.In(menu_options)}), + "menu_options": menu_options, + "description_placeholders": description_placeholders, + } + @callback def _create_abort_data( diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index 07f5e640ea3..808207c7f30 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -6,6 +6,7 @@ from typing import Any from aiohttp import web import voluptuous as vol +import voluptuous_serialize from homeassistant import config_entries, data_entry_flow from homeassistant.components.http import HomeAssistantView @@ -32,11 +33,9 @@ class _BaseFlowManagerView(HomeAssistantView): data.pop("data") return data - if result["type"] != data_entry_flow.RESULT_TYPE_FORM: + if "data_schema" not in result: return result - import voluptuous_serialize # pylint: disable=import-outside-toplevel - data = result.copy() if (schema := data["data_schema"]) is None: diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index d174f238217..b721b4d708d 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -110,6 +110,7 @@ def gen_data_entry_schema( step_title_class("title"): cv.string_with_no_html, vol.Optional("description"): cv.string_with_no_html, vol.Optional("data"): {str: cv.string_with_no_html}, + vol.Optional("menu_options"): {str: cv.string_with_no_html}, } }, vol.Optional("error"): {str: cv.string_with_no_html}, diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index b4b40b6b6c6..18d5469a162 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -487,3 +487,46 @@ async def test_abort_raises_unknown_flow_if_not_in_progress(manager): """Test abort raises UnknownFlow if the flow is not in progress.""" with pytest.raises(data_entry_flow.UnknownFlow): await manager.async_abort("wrong_flow_id") + + +@pytest.mark.parametrize( + "menu_options", + (["target1", "target2"], {"target1": "Target 1", "target2": "Target 2"}), +) +async def test_show_menu(hass, manager, menu_options): + """Test show menu.""" + manager.hass = hass + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + data = None + task_one_done = False + + async def async_step_init(self, user_input=None): + return self.async_show_menu( + step_id="init", + menu_options=menu_options, + description_placeholders={"name": "Paulus"}, + ) + + async def async_step_target1(self, user_input=None): + return self.async_show_form(step_id="target1") + + async def async_step_target2(self, user_input=None): + return self.async_show_form(step_id="target2") + + result = await manager.async_init("test") + assert result["type"] == data_entry_flow.RESULT_TYPE_MENU + assert result["menu_options"] == menu_options + assert result["description_placeholders"] == {"name": "Paulus"} + assert len(manager.async_progress()) == 1 + assert len(manager.async_progress_by_handler("test")) == 1 + assert manager.async_get(result["flow_id"])["handler"] == "test" + + # Mimic picking a step + result = await manager.async_configure( + result["flow_id"], {"next_step_id": "target1"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "target1"