diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 9da1d270..64e06c5e 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,24 @@ +2026-04-28 +feat(on_call): PagerDuty-style escalation policy redesign (chunk 1) + - Added handoff_notifications_mode field to escalation_policies + (when_in_use_by_service / always / never; default when_in_use_by_service) + - Added OnCall.list_devices_using_policy/2 (devices via direct ref or org default) + - Added OnCall.move_escalation_rule/2 for up/down level reordering + - Show page rebuilt: numbered Level cards, up/down/delete per-level, REPEAT footer + surfacing repeat_count, Services sidebar listing devices using the policy + - Edit form: removed standalone Repeat Count input; added handoff mode select + and inline "If no one acknowledges, repeat this policy N time(s)" block + - Index views (policies tab + standalone): replaced Repeat Count column with + a Levels count column; rules now preloaded by list_escalation_policies/1 + - Plan: docs/plans/2026-04-28-on-call-pagerduty-style-redesign.md + Files: lib/towerops/on_call/escalation_policy.ex, lib/towerops/on_call.ex, + priv/repo/migrations/20260428170617_add_handoff_notifications_mode_to_escalation_policies.exs, + lib/towerops_web/live/escalation_policy_live/{show,form,index}.{ex,html.heex}, + lib/towerops_web/live/schedule_live/index.html.heex, + test/towerops/on_call_test.exs, + test/towerops/on_call/escalation_policy_test.exs, + test/towerops_web/live/escalation_policy_live_test.exs + 2026-04-04 perf: database performance and maintenance migrations - Added GiST index on geoip_blocks using int8range for IP range containment queries diff --git a/lib/towerops/on_call.ex b/lib/towerops/on_call.ex index 436c1e94..415e7b3a 100644 --- a/lib/towerops/on_call.ex +++ b/lib/towerops/on_call.ex @@ -5,6 +5,7 @@ defmodule Towerops.OnCall do import Ecto.Query + alias Towerops.Devices.Device alias Towerops.OnCall.EscalationPolicy alias Towerops.OnCall.EscalationRule alias Towerops.OnCall.EscalationTarget @@ -13,6 +14,7 @@ defmodule Towerops.OnCall do alias Towerops.OnCall.Override alias Towerops.OnCall.Resolver alias Towerops.OnCall.Schedule + alias Towerops.Organizations.Organization alias Towerops.Repo # --- Schedules --- @@ -113,6 +115,7 @@ defmodule Towerops.OnCall do |> where([p], p.organization_id == ^organization_id) |> order_by([p], asc: p.name) |> Repo.all() + |> Repo.preload(:rules) end def get_escalation_policy!(id) do @@ -173,6 +176,55 @@ defmodule Towerops.OnCall do Repo.delete(rule) end + @doc """ + Swaps a rule with its neighbour in the given direction (`:up` or `:down`). + Returns `{:ok, rule}` (unchanged when at the boundary), or `{:error, reason}`. + """ + def move_escalation_rule(%EscalationRule{} = rule, direction) when direction in [:up, :down] do + policy_rules = + EscalationRule + |> where([r], r.escalation_policy_id == ^rule.escalation_policy_id) + |> order_by([r], asc: r.position) + |> Repo.all() + + case find_neighbour(policy_rules, rule, direction) do + nil -> + {:ok, rule} + + neighbour -> + case swap_rule_positions(rule, neighbour) do + :ok -> {:ok, Repo.get!(EscalationRule, rule.id)} + {:error, _} = err -> err + end + end + end + + defp find_neighbour(rules, rule, :up), do: Enum.find(rules, &(&1.position == rule.position - 1)) + + defp find_neighbour(rules, rule, :down), do: Enum.find(rules, &(&1.position == rule.position + 1)) + + defp swap_rule_positions(a, b) do + a_pos = a.position + b_pos = b.position + + fn -> + {:ok, _} = + a + |> EscalationRule.changeset(%{position: b_pos}) + |> Repo.update() + + {:ok, _} = + b + |> EscalationRule.changeset(%{position: a_pos}) + |> Repo.update() + end + |> Repo.transaction() + |> case do + {:ok, _} -> :ok + {:error, reason} -> {:error, reason} + end + end + # --- Escalation Targets --- def create_escalation_target(attrs) do @@ -185,6 +237,28 @@ defmodule Towerops.OnCall do Repo.delete(target) end + @doc """ + Returns devices in `organization_id` whose effective escalation policy is + `policy_id` — either set directly on the device, or inherited from the + organization's default policy. + """ + def list_devices_using_policy(policy_id, organization_id) do + org_default_query = + from o in Organization, + where: o.id == ^organization_id and o.default_escalation_policy_id == ^policy_id, + select: o.id + + Device + |> where([d], d.organization_id == ^organization_id) + |> where( + [d], + d.escalation_policy_id == ^policy_id or + (is_nil(d.escalation_policy_id) and d.organization_id in subquery(org_default_query)) + ) + |> order_by([d], asc: d.name) + |> Repo.all() + end + # --- On-Call Resolution --- def who_is_on_call(schedule_id, datetime \\ DateTime.utc_now()) do diff --git a/lib/towerops/rate_limit.ex b/lib/towerops/rate_limit.ex index 0f35b1bc..5f90c9c6 100644 --- a/lib/towerops/rate_limit.ex +++ b/lib/towerops/rate_limit.ex @@ -153,16 +153,17 @@ defmodule Towerops.RateLimit do table = Keyword.get(opts, :table, @default_table) clean_period = Keyword.get(opts, :clean_period, @default_clean_period) - :ets.new(table, [ - :named_table, - :set, - :public, - {:read_concurrency, true}, - {:write_concurrency, true}, - {:decentralized_counters, true} - ]) + _ = + :ets.new(table, [ + :named_table, + :set, + :public, + {:read_concurrency, true}, + {:write_concurrency, true}, + {:decentralized_counters, true} + ]) - schedule_clean(clean_period) + _ = schedule_clean(clean_period) {:ok, %{table: table, clean_period: clean_period}} end diff --git a/lib/towerops_web/live/escalation_policy_live/form.html.heex b/lib/towerops_web/live/escalation_policy_live/form.html.heex index 8d3c5361..8bb938db 100644 --- a/lib/towerops_web/live/escalation_policy_live/form.html.heex +++ b/lib/towerops_web/live/escalation_policy_live/form.html.heex @@ -23,13 +23,24 @@
<.input - field={@form[:repeat_count]} - type="number" - label={t("Repeat Count")} - min="1" - max="10" + field={@form[:handoff_notifications_mode]} + type="select" + label={t("Send On-Call Handoff Notifications")} + options={[ + {t("When in use by a service"), "when_in_use_by_service"}, + {t("Always"), "always"}, + {t("Never"), "never"} + ]} />
+
+ <.icon name="hero-arrow-path" class="h-5 w-5 text-gray-500" /> + + {t("If no one acknowledges, repeat this policy")} + + <.input field={@form[:repeat_count]} type="number" min="1" max="10" class="w-20" /> + {t("time(s).")} +
<.button type="submit" phx-disable-with={t("Saving...")}> {t("Save Policy")} diff --git a/lib/towerops_web/live/escalation_policy_live/index.html.heex b/lib/towerops_web/live/escalation_policy_live/index.html.heex index a50137b4..42c6d896 100644 --- a/lib/towerops_web/live/escalation_policy_live/index.html.heex +++ b/lib/towerops_web/live/escalation_policy_live/index.html.heex @@ -46,7 +46,7 @@ {t("Name")} - {t("Repeat Count")} + {t("Levels")} @@ -60,7 +60,7 @@ {policy.name} - {policy.repeat_count} + {length(policy.rules)} <% end %> diff --git a/lib/towerops_web/live/escalation_policy_live/show.ex b/lib/towerops_web/live/escalation_policy_live/show.ex index 5008b25c..13d36486 100644 --- a/lib/towerops_web/live/escalation_policy_live/show.ex +++ b/lib/towerops_web/live/escalation_policy_live/show.ex @@ -12,6 +12,7 @@ defmodule ToweropsWeb.EscalationPolicyLive.Show do members = Organizations.list_organization_members(org_id) users = Enum.map(members, & &1.user) schedules = OnCall.list_schedules(org_id) + services = OnCall.list_devices_using_policy(policy.id, org_id) {:ok, socket @@ -20,9 +21,16 @@ defmodule ToweropsWeb.EscalationPolicyLive.Show do |> assign(:policy, policy) |> assign(:org_users, users) |> assign(:org_schedules, schedules) + |> assign(:services, services) |> assign(:show_add_rule, false)} end + @doc false + def handoff_mode_label("when_in_use_by_service"), do: "when in use by a service" + def handoff_mode_label("always"), do: "always" + def handoff_mode_label("never"), do: "never" + def handoff_mode_label(_), do: "when in use by a service" + @impl true def handle_event("delete", _params, socket) do case OnCall.delete_escalation_policy(socket.assigns.policy) do @@ -92,6 +100,17 @@ defmodule ToweropsWeb.EscalationPolicyLive.Show do end end + def handle_event("move_rule", %{"id" => id, "direction" => direction}, socket) when direction in ["up", "down"] do + rule = Enum.find(socket.assigns.policy.rules, &(&1.id == id)) + + if rule do + {:ok, _} = OnCall.move_escalation_rule(rule, String.to_existing_atom(direction)) + {:noreply, reload_policy(socket)} + else + {:noreply, socket} + end + end + def handle_event("delete_target", %{"id" => target_id}, socket) do target = socket.assigns.policy.rules @@ -109,7 +128,12 @@ defmodule ToweropsWeb.EscalationPolicyLive.Show do end defp reload_policy(socket) do + org_id = socket.assigns.current_scope.organization.id policy = OnCall.get_escalation_policy!(socket.assigns.policy.id) - assign(socket, :policy, policy) + services = OnCall.list_devices_using_policy(policy.id, org_id) + + socket + |> assign(:policy, policy) + |> assign(:services, services) end end diff --git a/lib/towerops_web/live/escalation_policy_live/show.html.heex b/lib/towerops_web/live/escalation_policy_live/show.html.heex index d944bd6e..f3ec54a4 100644 --- a/lib/towerops_web/live/escalation_policy_live/show.html.heex +++ b/lib/towerops_web/live/escalation_policy_live/show.html.heex @@ -3,7 +3,7 @@ current_scope={@current_scope} active_page="schedules" > -
+
<.link navigate={~p"/schedules?tab=escalation-policies"} @@ -11,12 +11,7 @@ > <.icon name="hero-arrow-left" class="h-5 w-5" /> -
-

{@policy.name}

-

- {t("Repeats %{count} times", count: @policy.repeat_count)} -

-
+

{@policy.name}

<.link @@ -37,92 +32,78 @@
+

+ {t("Send On-Call Handoff Notifications:")} + + {handoff_mode_label(@policy.handoff_notifications_mode)} + +

+ <%= if @policy.description do %>

{@policy.description}

<% end %> -
-
-

- {t("Escalation Rules")} -

- -
- - <%= if @show_add_rule do %> -
-
-
-
- - -
-
-
- <.button type="submit" phx-disable-with={t("Saving...")}> - {t("Add Rule")} - - -
-
-
- <% end %> - - <%= if Enum.empty?(@policy.rules) do %> -
-

- {t("No rules configured. Add a rule to define escalation steps.")} -

-
- <% else %> -
- <%= for rule <- Enum.sort_by(@policy.rules, & &1.position) do %> +
+ <%!-- LEFT: Levels timeline --%> +
+ <%= if Enum.empty?(@policy.rules) do %> +
+

+ {t("No levels configured. Add a level to define escalation steps.")} +

+
+ <% else %> + <% sorted_rules = Enum.sort_by(@policy.rules, & &1.position) %> + <% rule_count = length(sorted_rules) %> + <%= for {rule, idx} <- Enum.with_index(sorted_rules) do %>
-
-

- {t("Step %{position}", position: rule.position + 1)} +
+

+ {t("Level %{n}", n: idx + 1)}

-
- - {t("Escalate after %{minutes} min", minutes: rule.timeout_minutes)} - +
+ +
+

+ {t("Notify the following users immediately and escalate after")} + + {rule.timeout_minutes} + + {t("minutes.")} +

+ <%!-- Targets --%> -
-

- {t("Targets")} -

+
<%= if Enum.empty?(rule.targets) do %>

{t("No targets yet")} @@ -191,7 +172,86 @@

<% end %> + <% end %> + + <%!-- Add Level button + form --%> + <%= if @show_add_rule do %> +
+
+
+
+ + +
+
+
+ <.button type="submit" phx-disable-with={t("Saving...")}> + {t("Add Level")} + + +
+
+
+ <% else %> + + <% end %> + + <%!-- REPEAT footer --%> +
+ <.icon name="hero-arrow-path" class="h-5 w-5 text-gray-500" /> +

+ {t("If no one acknowledges, repeat this policy")} + {@policy.repeat_count} + {t("time(s).")} +

- <% end %> +
+ + <%!-- RIGHT: Services sidebar --%> +

diff --git a/lib/towerops_web/live/schedule_live/index.html.heex b/lib/towerops_web/live/schedule_live/index.html.heex index 734cb96e..da9a98d2 100644 --- a/lib/towerops_web/live/schedule_live/index.html.heex +++ b/lib/towerops_web/live/schedule_live/index.html.heex @@ -79,10 +79,7 @@ {t("Name")} - {t("Rules")} - - - {t("Repeat Count")} + {t("Levels")} @@ -96,10 +93,7 @@ {policy.name} - — - - - {policy.repeat_count} + {length(policy.rules)} <% end %> diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt index 7e8dd098..1526aa8b 100644 --- a/priv/static/changelog.txt +++ b/priv/static/changelog.txt @@ -1,3 +1,10 @@ +2026-04-28 — Escalation Policy Improvements +* Redesigned escalation policy detail page with numbered Level cards and inline reorder controls +* Added a "Services" sidebar showing the devices that use each policy +* Surfaced the on-call handoff notifications setting on policies (when in use by a service / always / never) +* Replaced the standalone Repeat Count field with an inline "If no one acknowledges, repeat this policy N time(s)" control on the policy timeline +* Policy list views now show the number of escalation levels instead of a separate repeat count column + 2026-04-04 — Performance Improvements * Significantly faster IP geolocation lookups * Reduced database maintenance overhead for background job processing diff --git a/test/towerops/on_call_test.exs b/test/towerops/on_call_test.exs index 3c50deb5..4dd98950 100644 --- a/test/towerops/on_call_test.exs +++ b/test/towerops/on_call_test.exs @@ -2,6 +2,7 @@ defmodule Towerops.OnCallTest do use Towerops.DataCase, async: true import Towerops.AccountsFixtures + import Towerops.DevicesFixtures import Towerops.OnCallFixtures import Towerops.OrganizationsFixtures @@ -191,6 +192,167 @@ defmodule Towerops.OnCallTest do end end + describe "list_devices_using_policy/2" do + test "returns devices that point at the policy directly", %{user: user} do + org = organization_fixture(user.id) + policy = escalation_policy_fixture(org.id) + site = site_fixture(org.id) + + direct = + device_fixture(org.id, site.id, %{ + escalation_policy_id: policy.id, + name: "router-direct" + }) + + _other = + device_fixture(org.id, site.id, %{ + escalation_policy_id: nil, + name: "router-no-policy" + }) + + assert [device] = OnCall.list_devices_using_policy(policy.id, org.id) + assert device.id == direct.id + end + + test "returns devices that inherit via the org default policy", %{user: user} do + org = organization_fixture(user.id) + policy = escalation_policy_fixture(org.id) + + {:ok, _} = + Towerops.Organizations.update_organization(org, %{default_escalation_policy_id: policy.id}) + + site = site_fixture(org.id) + + inherited = + device_fixture(org.id, site.id, %{ + escalation_policy_id: nil, + name: "router-inherited" + }) + + ids = + policy.id + |> OnCall.list_devices_using_policy(org.id) + |> Enum.map(& &1.id) + + assert inherited.id in ids + end + + test "scopes to the organization", %{user: user} do + org_a = organization_fixture(user.id) + org_b = organization_fixture(user.id) + policy = escalation_policy_fixture(org_a.id) + + site_a = site_fixture(org_a.id) + site_b = site_fixture(org_b.id) + + _in_org = + device_fixture(org_a.id, site_a.id, %{ + escalation_policy_id: policy.id, + name: "router-a" + }) + + _other_org = + device_fixture(org_b.id, site_b.id, %{ + escalation_policy_id: policy.id, + name: "router-b" + }) + + results = OnCall.list_devices_using_policy(policy.id, org_a.id) + assert length(results) == 1 + assert hd(results).name == "router-a" + end + end + + describe "move_escalation_rule/2" do + test "swaps positions with the neighbour above when direction is :up", %{organization: org} do + policy = escalation_policy_fixture(org.id) + + {:ok, r0} = + OnCall.create_escalation_rule(%{ + escalation_policy_id: policy.id, + position: 0, + timeout_minutes: 30 + }) + + {:ok, r1} = + OnCall.create_escalation_rule(%{ + escalation_policy_id: policy.id, + position: 1, + timeout_minutes: 30 + }) + + {:ok, _} = OnCall.move_escalation_rule(r1, :up) + + by_id = + policy.id + |> OnCall.get_escalation_policy!() + |> Map.fetch!(:rules) + |> Map.new(&{&1.id, &1.position}) + + assert by_id[r0.id] == 1 + assert by_id[r1.id] == 0 + end + + test "is a no-op when moving the top rule :up", %{organization: org} do + policy = escalation_policy_fixture(org.id) + + {:ok, r0} = + OnCall.create_escalation_rule(%{ + escalation_policy_id: policy.id, + position: 0, + timeout_minutes: 30 + }) + + assert {:ok, returned} = OnCall.move_escalation_rule(r0, :up) + assert returned.id == r0.id + assert returned.position == 0 + end + + test "is a no-op when moving the bottom rule :down", %{organization: org} do + policy = escalation_policy_fixture(org.id) + + {:ok, r0} = + OnCall.create_escalation_rule(%{ + escalation_policy_id: policy.id, + position: 0, + timeout_minutes: 30 + }) + + assert {:ok, returned} = OnCall.move_escalation_rule(r0, :down) + assert returned.id == r0.id + assert returned.position == 0 + end + + test "swaps with the neighbour below when direction is :down", %{organization: org} do + policy = escalation_policy_fixture(org.id) + + {:ok, r0} = + OnCall.create_escalation_rule(%{ + escalation_policy_id: policy.id, + position: 0, + timeout_minutes: 30 + }) + + {:ok, r1} = + OnCall.create_escalation_rule(%{ + escalation_policy_id: policy.id, + position: 1, + timeout_minutes: 30 + }) + + {:ok, _} = OnCall.move_escalation_rule(r0, :down) + + by_id = + policy.id + |> OnCall.get_escalation_policy!() + |> Map.fetch!(:rules) + |> Map.new(&{&1.id, &1.position}) + + assert by_id[r0.id] == 1 + assert by_id[r1.id] == 0 + end + end + describe "who_is_on_call/2" do test "resolves who is on call from schedule id", %{user: user, organization: org} do schedule = schedule_fixture(org.id) diff --git a/test/towerops_web/live/escalation_policy_live_test.exs b/test/towerops_web/live/escalation_policy_live_test.exs index e8c967a7..3c8eb78e 100644 --- a/test/towerops_web/live/escalation_policy_live_test.exs +++ b/test/towerops_web/live/escalation_policy_live_test.exs @@ -2,7 +2,9 @@ defmodule ToweropsWeb.EscalationPolicyLiveTest do use ToweropsWeb.ConnCase, async: true import Phoenix.LiveViewTest + import Towerops.DevicesFixtures import Towerops.OnCallFixtures + import Towerops.OrganizationsFixtures alias Towerops.OnCall @@ -23,12 +25,26 @@ defmodule ToweropsWeb.EscalationPolicyLiveTest do end test "lists escalation policies", %{conn: conn, organization: organization} do - policy = escalation_policy_fixture(organization.id, %{name: "Critical Alerts Policy"}) + _policy = escalation_policy_fixture(organization.id, %{name: "Critical Alerts Policy"}) {:ok, _view, html} = live(conn, ~p"/schedules?tab=escalation-policies") assert html =~ "Critical Alerts Policy" - assert html =~ "#{policy.repeat_count}" + end + + test "shows the level count instead of a Repeat Count column", %{ + conn: conn, + organization: organization + } do + policy = escalation_policy_fixture(organization.id, %{name: "p", repeat_count: 5}) + _ = escalation_rule_fixture(policy.id, %{position: 0}) + _ = escalation_rule_fixture(policy.id, %{position: 1}) + + {:ok, _view, html} = live(conn, ~p"/schedules?tab=escalation-policies") + + refute html =~ "Repeat Count" + assert html =~ "Levels" + assert html =~ ~r/]*>\s*2\s* element("button", "Add Rule") + |> element("button", "Add Level") |> render_click() assert html =~ "Escalate after (minutes)" end - test "can add a rule with timeout_minutes", %{conn: conn, organization: organization} do + test "can add a level with timeout_minutes", %{conn: conn, organization: organization} do policy = escalation_policy_fixture(organization.id) {:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}") - view |> element("button", "Add Rule") |> render_click() + view |> element("button", "Add Level") |> render_click() html = view |> form("form[phx-submit=\"save_rule\"]", %{"timeout_minutes" => "15"}) |> render_submit() - assert html =~ "Step 1" - assert html =~ "15 min" + assert html =~ "Level 1" + assert html =~ "15" + assert html =~ "minutes" end - test "can delete a rule", %{conn: conn, organization: organization} do + test "can delete a level", %{conn: conn, organization: organization} do policy = escalation_policy_fixture(organization.id) rule = escalation_rule_fixture(policy.id, %{timeout_minutes: 20}) {:ok, view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}") - assert html =~ "20 min" + assert html =~ "20" view |> element(~s|button[phx-click="delete_rule"][phx-value-id="#{rule.id}"]|) |> render_click() html = render(view) - assert html =~ "No rules configured" + assert html =~ "No levels configured" + end + + test "renders levels as numbered cards with escalate-after copy", %{ + conn: conn, + organization: organization, + user: user + } do + policy = escalation_policy_fixture(organization.id, %{name: "Net"}) + + {:ok, r0} = + OnCall.create_escalation_rule(%{ + escalation_policy_id: policy.id, + position: 0, + timeout_minutes: 15 + }) + + {:ok, _} = + OnCall.create_escalation_target(%{ + escalation_rule_id: r0.id, + target_type: "user", + user_id: user.id + }) + + {:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}") + + assert html =~ "Level 1" + assert html =~ "escalate after" + assert html =~ "15" + assert html =~ "minutes" + end + + test "surfaces repeat_count in the REPEAT footer", %{conn: conn, organization: organization} do + policy = escalation_policy_fixture(organization.id, %{repeat_count: 4}) + + {:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}") + + assert html =~ "If no one acknowledges, repeat this policy" + assert html =~ "4" + end + + test "shows the handoff notifications mode label", %{conn: conn, organization: organization} do + policy = + escalation_policy_fixture(organization.id, %{handoff_notifications_mode: "always"}) + + {:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}") + + assert html =~ "Send On-Call Handoff Notifications" + assert html =~ "always" + end + + test "lists devices using the policy in the Services sidebar", %{ + conn: conn, + organization: organization + } do + policy = escalation_policy_fixture(organization.id) + site = site_fixture(organization.id) + + device = + device_fixture(organization.id, site.id, %{ + escalation_policy_id: policy.id, + name: "core-router-7" + }) + + {:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}") + + assert html =~ "Services" + assert html =~ device.name + end + + test "Services sidebar shows empty state when nothing uses the policy", %{ + conn: conn, + organization: organization + } do + policy = escalation_policy_fixture(organization.id) + + {:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}") + + assert html =~ "No devices use this policy" + end + + test "can move a level up via the move_rule event", %{conn: conn, organization: organization} do + policy = escalation_policy_fixture(organization.id) + + {:ok, r0} = + OnCall.create_escalation_rule(%{ + escalation_policy_id: policy.id, + position: 0, + timeout_minutes: 30 + }) + + {:ok, r1} = + OnCall.create_escalation_rule(%{ + escalation_policy_id: policy.id, + position: 1, + timeout_minutes: 60 + }) + + {:ok, view, _html} = live(conn, ~p"/schedules/escalation-policies/#{policy.id}") + + view + |> element(~s|button[phx-click="move_rule"][phx-value-id="#{r1.id}"][phx-value-direction="up"]|) + |> render_click() + + reloaded = OnCall.get_escalation_policy!(policy.id) + by_id = Map.new(reloaded.rules, &{&1.id, &1.position}) + assert by_id[r0.id] == 1 + assert by_id[r1.id] == 0 end test "shows rule targets", %{conn: conn, organization: organization, user: user} do @@ -236,10 +360,31 @@ defmodule ToweropsWeb.EscalationPolicyLiveTest do assert html =~ "New Escalation Policy" assert html =~ "Name" assert html =~ "Description" - assert html =~ "Repeat Count" assert html =~ "Save Policy" end + test "no longer renders a standalone Repeat Count label", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/schedules/escalation-policies/new") + + refute html =~ ~r/]*>\s*Repeat Count\s* form("form", + escalation_policy: %{ + name: policy.name, + repeat_count: 5, + handoff_notifications_mode: "always" + } + ) + |> render_submit() + + updated = OnCall.get_escalation_policy!(policy.id) + assert updated.repeat_count == 5 + assert updated.handoff_notifications_mode == "always" + end + test "requires authentication", %{organization: organization} do policy = escalation_policy_fixture(organization.id) conn = build_conn()