Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 33 additions & 79 deletions options_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,58 +21,7 @@
_WEEKDAYS = [CONF_MONDAY, CONF_TUESDAY, CONF_WEDNESDAY, CONF_THURSDAY, CONF_FRIDAY, CONF_SATURDAY, CONF_SUNDAY]
_NTH_OCCURRENCES = ["1", "2", "3", "4", "last"]

# Step 1 (init) – common task settings that apply regardless of repeat mode
_STEP_INIT_SCHEMA = vol.Schema(
{
vol.Optional(CONF_ACTIVE): bool,
vol.Optional(CONF_ACTIVE_OVERRIDE): selector({
"entity": {
"domain": "input_boolean",
}
}),
vol.Required(CONF_REPEAT_MODE, default=CONF_REPEAT_AFTER): selector({
CONF_SELECT: {
CONF_OPTIONS: [CONF_REPEAT_AFTER, CONF_REPEAT_EVERY],
CONF_MODE: CONF_DROPDOWN,
"translation_key": "repeat_mode",
}
}),
vol.Optional(CONF_ICON, default="mdi:calendar-question"): str,
vol.Optional(CONF_TAGS): str,
vol.Optional(CONF_TODO_LISTS): selector({
"entity": {
"domain": "todo",
"multiple": True,
}
}),
vol.Optional(CONF_DUE_SOON_DAYS, default=0): int,
vol.Optional(CONF_DUE_SOON_OVERRIDE): selector({
"entity": {
"domain": "input_number",
}
}),
vol.Optional(CONF_NOTIFICATION_INTERVAL, default=1): int,
}
)

# Step 2a – repeat_after interval settings
_STEP_REPEAT_AFTER_SCHEMA = vol.Schema(
{
vol.Required(CONF_TASK_INTERVAL_VALUE, default=7): int,
vol.Required(CONF_TASK_INTERVAL_TYPE): selector({
CONF_SELECT: {
CONF_OPTIONS: [CONF_DAY, CONF_WEEK, CONF_MONTH, CONF_YEAR],
CONF_MODE: CONF_DROPDOWN,
"translation_key": "task_interval",
}
}),
vol.Optional(CONF_TASK_INTERVAL_OVERRIDE): selector({
"entity": {
"domain": "input_number",
}
}),
}
)

# Step 2b – repeat_every sub-type selection
_STEP_REPEAT_EVERY_SCHEMA = vol.Schema(
Expand Down Expand Up @@ -141,7 +90,7 @@
)

# ---------------------------------------------------------------------------
# Common optional fields shared by all combined options steps for repeat_every.
# Common optional fields shared by all combined options steps.
# Split into "head" (active/active_override – always shown first) and "tail"
# (icon, tags, … – shown after the mode-specific fields).
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -172,6 +121,26 @@
vol.Optional(CONF_NOTIFICATION_INTERVAL, default=1): int,
}

# Combined options steps for repeat_after mode.
# Field order: active / active_override → interval fields → remaining common fields.
_STEP_OPTIONS_REPEAT_AFTER_SCHEMA = vol.Schema({
**_REPEAT_EVERY_HEAD_OPTIONS,
vol.Required(CONF_TASK_INTERVAL_VALUE, default=7): int,
vol.Required(CONF_TASK_INTERVAL_TYPE): selector({
CONF_SELECT: {
CONF_OPTIONS: [CONF_DAY, CONF_WEEK, CONF_MONTH, CONF_YEAR],
CONF_MODE: CONF_DROPDOWN,
"translation_key": "task_interval",
}
}),
vol.Optional(CONF_TASK_INTERVAL_OVERRIDE): selector({
"entity": {
"domain": "input_number",
}
}),
**_REPEAT_EVERY_TAIL_OPTIONS,
})

# Combined options steps for repeat_every modes.
# Field order: active / active_override → mode-specific fields → remaining common fields.
_STEP_OPTIONS_REPEAT_EVERY_WEEKDAY_SCHEMA = vol.Schema({
Expand Down Expand Up @@ -244,16 +213,12 @@ def __init__(self) -> None:
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Step 1 – common settings and repeat mode selection.

For ``repeat_every`` entries the init form is skipped entirely: the
flow jumps directly to the mode-specific combined options step, which
shows both the mode-specific fields and the common fields in one page.
The repeat mode itself is therefore not user-editable in options for
``repeat_every`` tasks.
"""Step 1 – options entry point; routes to the appropriate combined step.

For ``repeat_after`` entries the existing two-step flow
(init → repeat_after) is kept exactly as it was.
For all modes the init form is skipped entirely: the flow jumps directly
to the mode-specific combined options step, which shows both the
mode-specific fields and the common fields in one page. The repeat mode
(and, for ``repeat_every`` tasks, the sub-type) are preserved as-is.
"""
# For repeat_every entries: skip the init form entirely and go straight
# to the combined mode-specific step.
Expand All @@ -270,30 +235,19 @@ async def async_step_init(
return await self.async_step_options_repeat_every_days_before_end_of_month()
return await self.async_step_options_repeat_every_weekday()

# repeat_after: existing flow unchanged.
if user_input is None:
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
_STEP_INIT_SCHEMA, self.config_entry.options
),
)

self._accumulated_options.update(user_input)

if user_input[CONF_REPEAT_MODE] == CONF_REPEAT_EVERY:
return await self.async_step_repeat_every()
return await self.async_step_repeat_after()
# repeat_after: skip the init form, go straight to the combined step.
self._accumulated_options[CONF_REPEAT_MODE] = CONF_REPEAT_AFTER
return await self.async_step_options_repeat_after()

async def async_step_repeat_after(
async def async_step_options_repeat_after(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Step 2a – interval settings for repeat_after mode."""
"""Combined options step for repeat_after mode."""
if user_input is None:
return self.async_show_form(
step_id="repeat_after",
step_id="options_repeat_after",
data_schema=self.add_suggested_values_to_schema(
_STEP_REPEAT_AFTER_SCHEMA, self.config_entry.options
_STEP_OPTIONS_REPEAT_AFTER_SCHEMA, self.config_entry.options
),
)

Expand Down
72 changes: 31 additions & 41 deletions strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,46 +98,6 @@
"invalid_days_before_end": "Days before month end must be between 0 and 30"
},
"step": {
"init": {
"title": "Task Tracker",
"description": "Configure task.",
"data": {
"repeat_mode": "Repeat Mode",
"notification_interval": "Notification Interval (days)",
"todo_lists": "Todo-Lists",
"due_soon_days": "Due soon (days)",
"due_soon_override": "Due soon Override (days)",
"tags": "Tags",
"icon": "Icon",
"active": "Active",
"active_override": "Active Override"
},
"data_description": {
"repeat_mode": "How the next due date is calculated when the task is completed. 'Repeat after completion' sets the next due date relative to when the task was completed. 'Repeat every (fixed schedule)' keeps the task on its original schedule regardless of when it was completed.",
"notification_interval": "The time in days after which notifications are sent again.",
"todo_lists": "Todo-Lists, to which this task will be added.",
"due_soon_days": "The number of days before the task is due to show it as due soon.",
"due_soon_override": "Select an input_number (value in days) to override the Due soon threshold.",
"tags": "Tags to categorize the task, separated by commas.",
"icon": "Icon to represent the task.",
"active": "Enable or disable the task.",
"active_override": "Select an input_boolean to override the Active setting."
}
},
"repeat_after": {
"title": "Repeat After Completion",
"description": "The task becomes due again after a fixed interval from the last completion.",
"data": {
"task_interval_value": "Task Interval",
"task_interval_type": "Task Interval Unit",
"task_interval_override": "Task Interval Override"
},
"data_description": {
"task_interval_value": "Time after which the task becomes due again.",
"task_interval_type": "The unit of time for the task interval.",
"task_interval_override": "Select an input_number (value in days) to override the Task Interval."
}
},
"repeat_every": {
"title": "Repeat on a Fixed Schedule",
"description": "Choose the type of schedule for this task.",
Expand Down Expand Up @@ -299,6 +259,36 @@
"due_soon_override": "Select an input_number (value in days) to override the Due soon threshold.",
"notification_interval": "The time in days after which notifications are sent again."
}
},
"options_repeat_after": {
"title": "Repeat After Completion – Options",
"description": "Update the interval and task settings.",
"data": {
"active": "Active",
"active_override": "Active Override",
"task_interval_value": "Task Interval",
"task_interval_type": "Task Interval Unit",
"task_interval_override": "Task Interval Override",
"icon": "Icon",
"tags": "Tags",
"todo_lists": "Todo-Lists",
"due_soon_days": "Due soon (days)",
"due_soon_override": "Due soon Override (days)",
"notification_interval": "Notification Interval (days)"
},
"data_description": {
"active": "Enable or disable the task.",
"active_override": "Select an input_boolean to override the Active setting.",
"task_interval_value": "Time after which the task becomes due again.",
"task_interval_type": "The unit of time for the task interval.",
"task_interval_override": "Select an input_number (value in days) to override the Task Interval.",
"icon": "Icon to represent the task.",
"tags": "Tags to categorize the task, separated by commas.",
"todo_lists": "Todo-Lists to which this task will be added.",
"due_soon_days": "The number of days before the task is due to show it as due soon.",
"due_soon_override": "Select an input_number (value in days) to override the Due soon threshold.",
"notification_interval": "The time in days after which notifications are sent again."
}
}
}
},
Expand Down Expand Up @@ -527,4 +517,4 @@
}
}
}
}
}
102 changes: 46 additions & 56 deletions tests/unit_tests/test_options_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,12 @@ def _make_flow(config_entry: ConfigEntry) -> TaskTrackerOptionsFlow:
class TestOptionsFlowInitRouting(unittest.IsolatedAsyncioTestCase):
"""async_step_init should branch on the stored repeat_mode."""

async def test_repeat_after_shows_init_form(self):
"""For repeat_after entries the existing init form is shown."""
async def test_repeat_after_routes_to_options_repeat_after(self):
"""For repeat_after entries the combined options_repeat_after form is shown."""
flow = _make_flow(_make_repeat_after_entry())
result = await flow.async_step_init(user_input=None)
self.assertEqual(result["type"], "form")
self.assertEqual(result["step_id"], "init")
self.assertEqual(result["step_id"], "options_repeat_after")

async def test_repeat_every_weekday_skips_init_form(self):
"""For repeat_every_weekday entries the init form is bypassed."""
Expand Down Expand Up @@ -138,43 +138,37 @@ async def test_repeat_mode_and_type_pre_seeded_for_day_of_month(self):


# ---------------------------------------------------------------------------
# Tests: repeat_after options flow unchanged
# Tests: repeat_after combined options step
# ---------------------------------------------------------------------------

class TestOptionsFlowRepeatAfterUnchanged(unittest.IsolatedAsyncioTestCase):
"""The repeat_after options flow must be exactly preserved."""
class TestOptionsFlowRepeatAfterCombined(unittest.IsolatedAsyncioTestCase):
"""The repeat_after options flow uses a single combined step (options_repeat_after)."""

async def _start(self, **extra_options):
flow = _make_flow(_make_repeat_after_entry(**extra_options))
# Submit the init form with repeat_after mode
await flow.async_step_init(user_input={
CONF_ACTIVE: True,
CONF_REPEAT_MODE: CONF_REPEAT_AFTER,
CONF_ICON: "mdi:calendar",
CONF_TAGS: "",
CONF_TODO_LISTS: [],
CONF_DUE_SOON_DAYS: 0,
CONF_NOTIFICATION_INTERVAL: 1,
})
return flow
def _make_flow(self):
return _make_flow(_make_repeat_after_entry())

async def test_init_routes_to_repeat_after_step(self):
flow = _make_flow(_make_repeat_after_entry())
result = await flow.async_step_init(user_input={
async def test_init_routes_to_options_repeat_after(self):
flow = self._make_flow()
result = await flow.async_step_init(user_input=None)
self.assertEqual(result["type"], "form")
self.assertEqual(result["step_id"], "options_repeat_after")

async def test_shows_form_on_no_input(self):
flow = self._make_flow()
result = await flow.async_step_options_repeat_after(user_input=None)
self.assertEqual(result["type"], "form")
self.assertEqual(result["step_id"], "options_repeat_after")

async def test_creates_entry_with_all_fields(self):
flow = self._make_flow()
flow._accumulated_options[CONF_REPEAT_MODE] = CONF_REPEAT_AFTER
result = await flow.async_step_options_repeat_after(user_input={
CONF_ACTIVE: True,
CONF_REPEAT_MODE: CONF_REPEAT_AFTER,
CONF_ICON: "mdi:calendar",
CONF_TAGS: "",
CONF_TODO_LISTS: [],
CONF_DUE_SOON_DAYS: 0,
CONF_NOTIFICATION_INTERVAL: 1,
})
self.assertEqual(result["type"], "form")
self.assertEqual(result["step_id"], "repeat_after")

async def test_repeat_after_creates_entry(self):
flow = await self._start()
result = await flow.async_step_repeat_after(user_input={
CONF_TASK_INTERVAL_VALUE: 14,
CONF_TASK_INTERVAL_TYPE: CONF_DAY,
})
Expand All @@ -183,33 +177,31 @@ async def test_repeat_after_creates_entry(self):
self.assertEqual(opts[CONF_TASK_INTERVAL_VALUE], 14)
self.assertEqual(opts[CONF_REPEAT_MODE], CONF_REPEAT_AFTER)

async def test_repeat_after_shows_form_on_no_input(self):
flow = await self._start()
result = await flow.async_step_repeat_after(user_input=None)
self.assertEqual(result["type"], "form")
self.assertEqual(result["step_id"], "repeat_after")
async def test_repeat_mode_pre_seeded_from_init(self):
"""init pre-seeds CONF_REPEAT_MODE so validate_options sees it."""
flow = self._make_flow()
await flow.async_step_init(user_input=None)
self.assertEqual(flow._accumulated_options[CONF_REPEAT_MODE], CONF_REPEAT_AFTER)


# ---------------------------------------------------------------------------
# Tests: validation in non-combined options steps (switching from repeat_after
# to repeat_every in the options flow)
# Tests: validation in non-combined options steps (repeat_every sub-type
# validation in the options flow)
# ---------------------------------------------------------------------------

class TestOptionsFlowRepeatEveryDayOfMonthValidation(unittest.IsolatedAsyncioTestCase):
"""Validation tests for async_step_repeat_every_day_of_month in options flow."""

async def _start(self):
"""Submit init with repeat_every mode from a repeat_after entry."""
"""Set up a flow with repeat_every/day_of_month accumulated options."""
flow = _make_flow(_make_repeat_after_entry())
await flow.async_step_init(user_input={
CONF_ACTIVE: True,
CONF_REPEAT_MODE: CONF_REPEAT_EVERY,
CONF_ICON: "mdi:calendar",
CONF_TAGS: "",
CONF_TODO_LISTS: [],
CONF_DUE_SOON_DAYS: 0,
CONF_NOTIFICATION_INTERVAL: 1,
})
flow._accumulated_options[CONF_ACTIVE] = True
flow._accumulated_options[CONF_REPEAT_MODE] = CONF_REPEAT_EVERY
flow._accumulated_options[CONF_ICON] = "mdi:calendar"
flow._accumulated_options[CONF_TAGS] = ""
flow._accumulated_options[CONF_TODO_LISTS] = []
flow._accumulated_options[CONF_DUE_SOON_DAYS] = 0
flow._accumulated_options[CONF_NOTIFICATION_INTERVAL] = 1
await flow.async_step_repeat_every(
user_input={CONF_REPEAT_EVERY_TYPE: CONF_REPEAT_EVERY_DAY_OF_MONTH}
)
Expand Down Expand Up @@ -237,15 +229,13 @@ class TestOptionsFlowRepeatEveryDaysBeforeEndValidation(unittest.IsolatedAsyncio

async def _start(self):
flow = _make_flow(_make_repeat_after_entry())
await flow.async_step_init(user_input={
CONF_ACTIVE: True,
CONF_REPEAT_MODE: CONF_REPEAT_EVERY,
CONF_ICON: "mdi:calendar",
CONF_TAGS: "",
CONF_TODO_LISTS: [],
CONF_DUE_SOON_DAYS: 0,
CONF_NOTIFICATION_INTERVAL: 1,
})
flow._accumulated_options[CONF_ACTIVE] = True
flow._accumulated_options[CONF_REPEAT_MODE] = CONF_REPEAT_EVERY
flow._accumulated_options[CONF_ICON] = "mdi:calendar"
flow._accumulated_options[CONF_TAGS] = ""
flow._accumulated_options[CONF_TODO_LISTS] = []
flow._accumulated_options[CONF_DUE_SOON_DAYS] = 0
flow._accumulated_options[CONF_NOTIFICATION_INTERVAL] = 1
await flow.async_step_repeat_every(
user_input={CONF_REPEAT_EVERY_TYPE: CONF_REPEAT_EVERY_DAYS_BEFORE_END_OF_MONTH}
)
Expand Down
Loading
Loading