diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 39cc7dcb..285a28ac 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,37 @@ +2026-04-28 +feat(on_call): deferred followups - drag-drop, restrictions, new-schedule UX + Followup A — Restrictions editor + - Resolver: added time_of_week support (weekday set + time-of-day window). + - OnCall.update_layer_restrictions/2 + clear_layer_restrictions/1 helpers. + - schedule_live/show: per-layer restrictions form (always-on / time of + day / time of week with weekday checkboxes), wired to the resolver. + - Tests: 3 resolver tests, 4 context tests. + + Followup B — Drag-and-drop reorder + - OnCall.reorder_layers/2 + reorder_escalation_rules/2 (atomic + transaction; validates id count + membership). + - assets/js/sortable_list.ts: minimal HTML5 drag-and-drop hook + (no external dep; mirrors the existing DeviceListReorder pattern) + registered as SortableList in app.ts. + - schedule_live/show + escalation_policy_live/show: layer/level + cards now have data-id + draggable + drag handle. Up/down arrow + buttons remain as a fallback. + - Tests: 4 context tests (layers + rules), 2 LV integration tests. + + Followup C — Unified new-schedule UX + - schedule_live/show pre-opens the Add Layer form when the + schedule has zero layers, so the new-schedule -> add-layers + flow lands ready to act on. + - Tests: 2 new + 2 existing tests updated for the new behaviour. + + Full suite 10,231 / 0 failures. Format/dialyzer clean. + Files: lib/towerops/on_call/{resolver.ex,on_call.ex}, + lib/towerops_web/live/schedule_live/{show.ex,show.html.heex}, + lib/towerops_web/live/escalation_policy_live/{show.ex,show.html.heex}, + assets/js/{sortable_list.ts,app.ts}, + test/towerops/on_call/{resolver_test.exs,on_call_test.exs}, + test/towerops_web/live/{schedule_live_test.exs,escalation_policy_live_test.exs} + 2026-04-28 feat(on_call): schedules list search/pagination + e2e refresh (chunk 4) - ScheduleLive.Index: name search input (phx-debounce 300ms) + standard diff --git a/assets/js/app.ts b/assets/js/app.ts index ff22ad17..66343c02 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -29,6 +29,7 @@ import type { ChartData, ChartDataset, ChartDataPoint } from "../vendor/chart" import cytoscape from "../vendor/cytoscape" import type { SensorChartHook } from "./types/liveview" import { DeviceListReorder } from "./device_list_reorder" +import { SortableList } from "./sortable_list" import { initCookieConsent } from "./cookie_consent" // Helper function to convert range string to milliseconds @@ -2401,7 +2402,7 @@ const WeathermapViewer = { const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 5000, params: { _csrf_token: csrfToken, timezone: userTimezone }, - hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, WeathermapViewer, SitesMap, LeafletMap, DeviceListReorder, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger, DynamicFavicon, StatusTitle, ThemeSelector, SidebarCollapse }, + hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, WeathermapViewer, SitesMap, LeafletMap, DeviceListReorder, SortableList, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger, DynamicFavicon, StatusTitle, ThemeSelector, SidebarCollapse }, }) // Show progress bar on live navigation and form submits diff --git a/assets/js/sortable_list.ts b/assets/js/sortable_list.ts new file mode 100644 index 00000000..7393640d --- /dev/null +++ b/assets/js/sortable_list.ts @@ -0,0 +1,114 @@ +/** + * SortableList — minimal HTML5 drag-and-drop hook for reorderable lists. + * + * Markup contract: + *
+ *
...
+ *
...
+ *
+ * + * On drop, the hook pushes one event named by `data-event` with payload + * `{ ordered_ids: [...] }` carrying the new order of every direct child + * with a `data-id` attribute. + * + * No external dependency — uses native HTML5 drag-and-drop. Mirrors the + * existing DeviceListReorder hook in style. + */ + +export const SortableList = { + draggedEl: null as HTMLElement | null, + handleDragStart: null as ((e: DragEvent) => void) | null, + handleDragEnd: null as ((e: DragEvent) => void) | null, + handleDragOver: null as ((e: DragEvent) => void) | null, + handleDrop: null as ((e: DragEvent) => void) | null, + + mounted(this: any) { + const container: HTMLElement = this.el + const eventName: string = container.dataset.event || "reorder" + + const itemFromTarget = (t: EventTarget | null): HTMLElement | null => { + const el = t as HTMLElement | null + return el ? (el.closest("[data-id]") || null) : null + } + + const itemIsDirectChild = (item: HTMLElement | null): item is HTMLElement => { + return !!item && item.parentElement === container + } + + this.handleDragStart = (e: DragEvent) => { + const item = itemFromTarget(e.target) + if (!itemIsDirectChild(item) || item.getAttribute("draggable") !== "true") return + this.draggedEl = item + item.classList.add("opacity-50") + e.dataTransfer!.effectAllowed = "move" + } + + this.handleDragEnd = (_e: DragEvent) => { + if (this.draggedEl) { + this.draggedEl.classList.remove("opacity-50") + this.draggedEl = null + } + container.querySelectorAll(".sortable-drop-target").forEach((el: Element) => { + el.classList.remove("sortable-drop-target", "border-t-4", "border-b-4", "border-blue-500") + }) + } + + this.handleDragOver = (e: DragEvent) => { + e.preventDefault() + if (!this.draggedEl) return + + const target = itemFromTarget(e.target) + if (!itemIsDirectChild(target) || target === this.draggedEl) return + + const rect = target.getBoundingClientRect() + const isBottomHalf = e.clientY > rect.top + rect.height / 2 + + container.querySelectorAll(".sortable-drop-target").forEach((el: Element) => { + el.classList.remove("sortable-drop-target", "border-t-4", "border-b-4", "border-blue-500") + }) + target.classList.add( + "sortable-drop-target", + "border-blue-500", + isBottomHalf ? "border-b-4" : "border-t-4" + ) + } + + this.handleDrop = (e: DragEvent) => { + e.preventDefault() + if (!this.draggedEl) return + + const target = itemFromTarget(e.target) + if (!itemIsDirectChild(target) || target === this.draggedEl) return + + const rect = target.getBoundingClientRect() + const isBottomHalf = e.clientY > rect.top + rect.height / 2 + + // Reorder DOM optimistically so the user sees the result + // immediately; the server-side patch will reconcile. + if (isBottomHalf) { + container.insertBefore(this.draggedEl, target.nextSibling) + } else { + container.insertBefore(this.draggedEl, target) + } + + const orderedIds = Array.from( + container.querySelectorAll(":scope > [data-id]") + ).map(el => el.dataset.id!) + + this.pushEvent(eventName, { ordered_ids: orderedIds }) + } + + container.addEventListener("dragstart", this.handleDragStart) + container.addEventListener("dragend", this.handleDragEnd) + container.addEventListener("dragover", this.handleDragOver) + container.addEventListener("drop", this.handleDrop) + }, + + destroyed(this: any) { + if (this.handleDragStart) this.el.removeEventListener("dragstart", this.handleDragStart) + if (this.handleDragEnd) this.el.removeEventListener("dragend", this.handleDragEnd) + if (this.handleDragOver) this.el.removeEventListener("dragover", this.handleDragOver) + if (this.handleDrop) this.el.removeEventListener("drop", this.handleDrop) + this.draggedEl = null + } +} diff --git a/lib/towerops/on_call.ex b/lib/towerops/on_call.ex index ec6f2e1b..b5ad5b36 100644 --- a/lib/towerops/on_call.ex +++ b/lib/towerops/on_call.ex @@ -84,6 +84,27 @@ defmodule Towerops.OnCall do Repo.delete(layer) end + @doc """ + Sets or clears the on-call restriction window for a layer. + + Pass `nil` for `restriction_type` (or any of the value pairs) to clear + restrictions and have the layer cover its full rotation continuously. + """ + def update_layer_restrictions(%Layer{} = layer, attrs) when is_map(attrs) do + layer + |> Layer.changeset(%{ + restriction_type: attrs[:restriction_type] || attrs["restriction_type"], + restrictions: attrs[:restrictions] || attrs["restrictions"] + }) + |> Repo.update() + end + + def clear_layer_restrictions(%Layer{} = layer) do + layer + |> Layer.changeset(%{restriction_type: nil, restrictions: nil}) + |> Repo.update() + end + @doc """ Swaps a layer with its neighbour in the given direction (`:up` or `:down`). Returns `{:ok, layer}` (unchanged when at the boundary), or `{:error, reason}`. @@ -111,6 +132,61 @@ defmodule Towerops.OnCall do defp find_neighbour_layer(layers, layer, :down), do: Enum.find(layers, &(&1.position == layer.position + 1)) + @doc """ + Persists a new ordering for all layers of `schedule_id`. + + `ordered_ids` must contain every layer id exactly once. Positions are + assigned 0..n-1 in the order given, in a single transaction. + """ + def reorder_layers(schedule_id, ordered_ids) when is_binary(schedule_id) and is_list(ordered_ids) do + layers = + Layer + |> where([l], l.schedule_id == ^schedule_id) + |> Repo.all() + + reorder_in_transaction(layers, ordered_ids, &Layer.changeset/2) + end + + @doc """ + Persists a new ordering for all rules of `policy_id`. Same contract as + `reorder_layers/2`. + """ + def reorder_escalation_rules(policy_id, ordered_ids) when is_binary(policy_id) and is_list(ordered_ids) do + rules = + EscalationRule + |> where([r], r.escalation_policy_id == ^policy_id) + |> Repo.all() + + reorder_in_transaction(rules, ordered_ids, &EscalationRule.changeset/2) + end + + defp reorder_in_transaction(records, ordered_ids, changeset_fn) do + by_id = Map.new(records, &{&1.id, &1}) + + cond do + length(ordered_ids) != length(records) -> {:error, :id_count_mismatch} + not Enum.all?(ordered_ids, &Map.has_key?(by_id, &1)) -> {:error, :unknown_id} + true -> persist_reorder(by_id, ordered_ids, changeset_fn) + end + end + + defp persist_reorder(by_id, ordered_ids, changeset_fn) do + fn -> Enum.each(Enum.with_index(ordered_ids), &assign_position(&1, by_id, changeset_fn)) end + |> Repo.transaction() + |> case do + {:ok, _} -> :ok + {:error, reason} -> {:error, reason} + end + end + + defp assign_position({id, idx}, by_id, changeset_fn) do + {:ok, _} = + by_id + |> Map.fetch!(id) + |> changeset_fn.(%{position: idx}) + |> Repo.update() + end + defp swap_layer_positions(a, b) do a_pos = a.position b_pos = b.position diff --git a/lib/towerops/on_call/resolver.ex b/lib/towerops/on_call/resolver.ex index a2b5198e..471be4dc 100644 --- a/lib/towerops/on_call/resolver.ex +++ b/lib/towerops/on_call/resolver.ex @@ -181,8 +181,37 @@ defmodule Towerops.OnCall.Resolver do end end + defp within_restriction?(%{restriction_type: "time_of_week", restrictions: restrictions}, datetime) do + days = restrictions["days"] || [] + start_time = parse_time(restrictions["start_time"]) + end_time = parse_time(restrictions["end_time"]) + weekday = Date.day_of_week(DateTime.to_date(datetime)) + current_time = DateTime.to_time(datetime) + + weekday_in_range?(days, weekday) and time_within_range?(current_time, start_time, end_time) + end + defp within_restriction?(_layer, _datetime), do: true + defp weekday_in_range?([], _weekday), do: false + + defp weekday_in_range?(days, weekday) when is_list(days) do + Enum.any?(days, fn d -> + case d do + d when is_integer(d) -> d == weekday + d when is_binary(d) -> String.to_integer(d) == weekday + end + end) + end + + defp time_within_range?(current, start_time, end_time) do + if Time.after?(start_time, end_time) do + Time.compare(current, start_time) in [:gt, :eq] or Time.before?(current, end_time) + else + Time.compare(current, start_time) in [:gt, :eq] and Time.before?(current, end_time) + end + end + defp compute_rotation(%{members: []}, _datetime), do: nil defp compute_rotation(layer, datetime) do diff --git a/lib/towerops_web/live/escalation_policy_live/show.ex b/lib/towerops_web/live/escalation_policy_live/show.ex index 13d36486..450e6797 100644 --- a/lib/towerops_web/live/escalation_policy_live/show.ex +++ b/lib/towerops_web/live/escalation_policy_live/show.ex @@ -100,6 +100,13 @@ defmodule ToweropsWeb.EscalationPolicyLive.Show do end end + def handle_event("reorder_rules", %{"ordered_ids" => ordered_ids}, socket) when is_list(ordered_ids) do + case OnCall.reorder_escalation_rules(socket.assigns.policy.id, ordered_ids) do + :ok -> {:noreply, reload_policy(socket)} + {:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to reorder levels"))} + 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)) 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 f3ec54a4..98010070 100644 --- a/lib/towerops_web/live/escalation_policy_live/show.html.heex +++ b/lib/towerops_web/live/escalation_policy_live/show.html.heex @@ -55,123 +55,137 @@ <% 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("Level %{n}", n: idx + 1)} -

-
- - - +
+ <%= for {rule, idx} <- Enum.with_index(sorted_rules) do %> +
+
+
+ <.icon name="hero-bars-3" class="h-4 w-4 text-gray-400" /> +

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

+
+
+ + + +
+
+ +

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

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

+ {t("No targets yet")} +

+ <% else %> +
+ <%= for target <- rule.targets do %> + + <%= case target.target_type do %> + <% "user" -> %> + <.icon name="hero-user" class="h-3 w-3 mr-0.5" /> + <% user = Enum.find(@org_users, &(&1.id == target.user_id)) %> + {if user, do: user.email, else: t("Unknown user")} + <% "schedule" -> %> + <.icon name="hero-calendar-days" class="h-3 w-3 mr-0.5" /> + <% sched = Enum.find(@org_schedules, &(&1.id == target.schedule_id)) %> + {if sched, do: sched.name, else: t("Unknown schedule")} + <% end %> + + + <% end %> +
+ <% end %> + + <%!-- Add target form --%> +
+ + + + +
- -

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

- - <%!-- Targets --%> -
- <%= if Enum.empty?(rule.targets) do %> -

- {t("No targets yet")} -

- <% else %> -
- <%= for target <- rule.targets do %> - - <%= case target.target_type do %> - <% "user" -> %> - <.icon name="hero-user" class="h-3 w-3 mr-0.5" /> - <% user = Enum.find(@org_users, &(&1.id == target.user_id)) %> - {if user, do: user.email, else: t("Unknown user")} - <% "schedule" -> %> - <.icon name="hero-calendar-days" class="h-3 w-3 mr-0.5" /> - <% sched = Enum.find(@org_schedules, &(&1.id == target.schedule_id)) %> - {if sched, do: sched.name, else: t("Unknown schedule")} - <% end %> - - - <% end %> -
- <% end %> - - <%!-- Add target form --%> -
- - - - -
-
-
- <% end %> + <% end %> +
<% end %> <%!-- Add Level button + form --%> diff --git a/lib/towerops_web/live/schedule_live/show.ex b/lib/towerops_web/live/schedule_live/show.ex index 5d0932d4..362ad664 100644 --- a/lib/towerops_web/live/schedule_live/show.ex +++ b/lib/towerops_web/live/schedule_live/show.ex @@ -29,7 +29,9 @@ defmodule ToweropsWeb.ScheduleLive.Show do |> assign(:on_call, on_call) |> assign(:timezone, socket.assigns.current_scope.timezone) |> assign(:org_users, users) - |> assign(:show_add_layer, false) + # Pre-open the Add Layer form for freshly-created schedules so the + # new-schedule -> add-layers flow lands in a state ready to act on. + |> assign(:show_add_layer, schedule.layers == []) |> assign(:show_add_override, false) |> assign(:new_layer_members, []) |> assign(:timeline_start, timeline_start) @@ -157,6 +159,24 @@ defmodule ToweropsWeb.ScheduleLive.Show do end end + def handle_event("reorder_layers", %{"ordered_ids" => ordered_ids}, socket) when is_list(ordered_ids) do + case OnCall.reorder_layers(socket.assigns.schedule.id, ordered_ids) do + :ok -> {:noreply, reload_schedule(socket)} + {:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to reorder layers"))} + end + end + + def handle_event("set_layer_restriction", params, socket) do + %{"layer_id" => layer_id, "restriction_type" => restriction_type} = params + layer = Enum.find(socket.assigns.schedule.layers, &(&1.id == layer_id)) + + if layer do + apply_layer_restriction(socket, layer, restriction_type, params) + else + {:noreply, socket} + end + end + def handle_event("delete_layer", %{"id" => layer_id}, socket) do layer = Enum.find(socket.assigns.schedule.layers, &(&1.id == layer_id)) @@ -338,4 +358,50 @@ defmodule ToweropsWeb.ScheduleLive.Show do } end) end + + # --- Layer restriction helpers --- + + defp apply_layer_restriction(socket, layer, restriction_type, params) do + restrictions = build_restrictions(restriction_type, params) + + result = + if is_nil(restrictions) do + OnCall.clear_layer_restrictions(layer) + else + OnCall.update_layer_restrictions(layer, %{ + restriction_type: restriction_type, + restrictions: restrictions + }) + end + + case result do + {:ok, _} -> {:noreply, reload_schedule(socket)} + {:error, _} -> {:noreply, put_flash(socket, :error, t("Unable to update restrictions"))} + end + end + + defp build_restrictions("", _params), do: nil + + defp build_restrictions("time_of_day", params) do + %{ + "start_time" => params["start_time"] || "09:00", + "end_time" => params["end_time"] || "17:00" + } + end + + defp build_restrictions("time_of_week", params) do + %{ + "start_time" => params["start_time"] || "09:00", + "end_time" => params["end_time"] || "17:00", + "days" => normalize_days(params["days"]) + } + end + + defp build_restrictions(_, _params), do: nil + + defp normalize_days(nil), do: [] + defp normalize_days(list) when is_list(list), do: list + defp normalize_days(map) when is_map(map), do: map |> Map.keys() |> Enum.filter(&(map[&1] == "true")) + defp normalize_days(other) when is_binary(other), do: [other] + defp normalize_days(_), do: [] end diff --git a/lib/towerops_web/live/schedule_live/show.html.heex b/lib/towerops_web/live/schedule_live/show.html.heex index 437a2180..acb4d3d3 100644 --- a/lib/towerops_web/live/schedule_live/show.html.heex +++ b/lib/towerops_web/live/schedule_live/show.html.heex @@ -340,26 +340,40 @@ <% else %> <% sorted_layers = Enum.sort_by(@schedule.layers, & &1.position) %> <% layer_count = length(sorted_layers) %> -
+
<%= for {layer, idx} <- Enum.with_index(sorted_layers) do %> -
-
-
-

{layer.name}

-

- {String.capitalize(layer.rotation_type)} rotation, every {layer.rotation_interval} - <%= case layer.rotation_type do %> - <% "daily" -> %> - {ngettext("day", "days", layer.rotation_interval)} - <% "weekly" -> %> - {ngettext("week", "weeks", layer.rotation_interval)} - <% _ -> %> - {ngettext("day", "days", layer.rotation_interval)} - <% end %> - · Handoff at {Calendar.strftime(layer.handoff_time, "%H:%M")} -

+
+
+
+ <.icon name="hero-bars-3" class="h-4 w-4 text-gray-400 mt-0.5 shrink-0" /> +
+

+ {layer.name} +

+

+ {String.capitalize(layer.rotation_type)} rotation, every {layer.rotation_interval} + <%= case layer.rotation_type do %> + <% "daily" -> %> + {ngettext("day", "days", layer.rotation_interval)} + <% "weekly" -> %> + {ngettext("week", "weeks", layer.rotation_interval)} + <% _ -> %> + {ngettext("day", "days", layer.rotation_interval)} + <% end %> + · Handoff at {Calendar.strftime(layer.handoff_time, "%H:%M")} +

+
-
+
+ + <%!-- Restrictions editor --%> +
+

+ {t("Restrict on-call shifts")} +

+
+ + + <%= if layer.restriction_type in ["time_of_day", "time_of_week"] do %> +
+ {t("From")} + + {t("to")} + +
+ <% end %> + <%= if layer.restriction_type == "time_of_week" do %> +
+ <% selected_days = + ((layer.restrictions || %{})["days"] || []) + |> Enum.map(fn d -> + case d do + d when is_integer(d) -> d + d when is_binary(d) -> String.to_integer(d) + end + end) %> + <%= for {label, day} <- [{"Mon", 1}, {"Tue", 2}, {"Wed", 3}, {"Thu", 4}, {"Fri", 5}, {"Sat", 6}, {"Sun", 7}] do %> + + <% end %> +
+ <% end %> +
+
<% end %>
diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt index 6ab86139..71110789 100644 --- a/priv/static/changelog.txt +++ b/priv/static/changelog.txt @@ -1,3 +1,8 @@ +2026-04-28 — Drag-and-drop reorder + restrictions + new-schedule polish +* Drag levels and layers to reorder them on escalation policy and schedule pages (up/down arrow buttons still work) +* Added on-call shift restrictions on schedule layers (time of day, time of week) +* Newly-created schedules now land with the Add Layer form already open + 2026-04-28 — Schedules List Search & Pagination * Search bar on the schedules list to filter by schedule name * Pagination on the schedules list (10 per page) for organizations with many schedules diff --git a/test/towerops/on_call/resolver_test.exs b/test/towerops/on_call/resolver_test.exs index 388aa67d..c99f0af5 100644 --- a/test/towerops/on_call/resolver_test.exs +++ b/test/towerops/on_call/resolver_test.exs @@ -302,6 +302,82 @@ defmodule Towerops.OnCall.ResolverTest do end end + describe "time_of_week restrictions" do + test "honors weekday and time-of-day restrictions", ctx do + schedule = schedule_fixture(ctx.organization.id) + + # Restrict to Mon-Fri (1-5), 09:00-17:00 + layer = + layer_fixture(schedule.id, %{ + rotation_type: "weekly", + rotation_interval: 1, + handoff_time: ~T[09:00:00], + handoff_day: 1, + start_date: ~U[2026-03-01 00:00:00Z], + position: 1, + restriction_type: "time_of_week", + restrictions: %{"days" => [1, 2, 3, 4, 5], "start_time" => "09:00", "end_time" => "17:00"} + }) + + layer_member_fixture(layer.id, ctx.user1.id, %{position: 0}) + schedule = preload_schedule(schedule) + + # 2026-03-02 is a Monday — at noon should be on call + assert Resolver.resolve(schedule, ~U[2026-03-02 12:00:00Z]).id == ctx.user1.id + + # 2026-03-02 at 18:00 — outside time window + assert Resolver.resolve(schedule, ~U[2026-03-02 18:00:00Z]) == nil + + # 2026-03-07 (Saturday) at noon — outside weekday set + assert Resolver.resolve(schedule, ~U[2026-03-07 12:00:00Z]) == nil + end + + test "treats empty days list as never-on-call", ctx do + schedule = schedule_fixture(ctx.organization.id) + + layer = + layer_fixture(schedule.id, %{ + rotation_type: "weekly", + rotation_interval: 1, + handoff_time: ~T[09:00:00], + handoff_day: 1, + start_date: ~U[2026-03-01 00:00:00Z], + position: 1, + restriction_type: "time_of_week", + restrictions: %{"days" => [], "start_time" => "00:00", "end_time" => "23:59"} + }) + + layer_member_fixture(layer.id, ctx.user1.id, %{position: 0}) + schedule = preload_schedule(schedule) + + assert Resolver.resolve(schedule, ~U[2026-03-02 12:00:00Z]) == nil + end + + test "accepts string-encoded day numbers (form payloads)", ctx do + schedule = schedule_fixture(ctx.organization.id) + + layer = + layer_fixture(schedule.id, %{ + rotation_type: "weekly", + rotation_interval: 1, + handoff_time: ~T[09:00:00], + handoff_day: 1, + start_date: ~U[2026-03-01 00:00:00Z], + position: 1, + restriction_type: "time_of_week", + restrictions: %{"days" => ["1", "2"], "start_time" => "00:00", "end_time" => "23:59"} + }) + + layer_member_fixture(layer.id, ctx.user1.id, %{position: 0}) + schedule = preload_schedule(schedule) + + # Monday + assert Resolver.resolve(schedule, ~U[2026-03-02 12:00:00Z]).id == ctx.user1.id + # Wednesday — not in the restricted set + assert Resolver.resolve(schedule, ~U[2026-03-04 12:00:00Z]) == nil + end + end + describe "resolve_range/3" do test "returns one segment when a single user covers the whole range", ctx do schedule = schedule_fixture(ctx.organization.id) diff --git a/test/towerops/on_call_test.exs b/test/towerops/on_call_test.exs index 50e11011..b315fcdf 100644 --- a/test/towerops/on_call_test.exs +++ b/test/towerops/on_call_test.exs @@ -353,6 +353,136 @@ defmodule Towerops.OnCallTest do end end + describe "update_layer_restrictions/2" do + test "sets a time_of_day restriction", %{organization: org} do + schedule = schedule_fixture(org.id) + {:ok, layer} = OnCall.create_layer(layer_attrs(schedule.id, 0)) + + {:ok, updated} = + OnCall.update_layer_restrictions(layer, %{ + restriction_type: "time_of_day", + restrictions: %{"start_time" => "09:00", "end_time" => "17:00"} + }) + + assert updated.restriction_type == "time_of_day" + assert updated.restrictions == %{"start_time" => "09:00", "end_time" => "17:00"} + end + + test "rejects unknown restriction types", %{organization: org} do + schedule = schedule_fixture(org.id) + {:ok, layer} = OnCall.create_layer(layer_attrs(schedule.id, 0)) + + assert {:error, changeset} = + OnCall.update_layer_restrictions(layer, %{ + restriction_type: "garbage", + restrictions: %{} + }) + + refute changeset.valid? + end + + test "accepts string-keyed attrs (LiveView form payload shape)", %{organization: org} do + schedule = schedule_fixture(org.id) + {:ok, layer} = OnCall.create_layer(layer_attrs(schedule.id, 0)) + + {:ok, updated} = + OnCall.update_layer_restrictions(layer, %{ + "restriction_type" => "time_of_day", + "restrictions" => %{"start_time" => "08:00", "end_time" => "16:00"} + }) + + assert updated.restriction_type == "time_of_day" + end + end + + describe "clear_layer_restrictions/1" do + test "removes any restriction set on the layer", %{organization: org} do + schedule = schedule_fixture(org.id) + + {:ok, layer} = + OnCall.create_layer( + Map.merge(layer_attrs(schedule.id, 0), %{ + restriction_type: "time_of_day", + restrictions: %{"start_time" => "09:00", "end_time" => "17:00"} + }) + ) + + {:ok, cleared} = OnCall.clear_layer_restrictions(layer) + assert cleared.restriction_type == nil + assert cleared.restrictions == nil + end + end + + describe "reorder_layers/2" do + test "applies the given order as 0..n-1 positions", %{organization: org} do + schedule = schedule_fixture(org.id) + {:ok, l0} = OnCall.create_layer(layer_attrs(schedule.id, 0)) + {:ok, l1} = OnCall.create_layer(layer_attrs(schedule.id, 1)) + {:ok, l2} = OnCall.create_layer(layer_attrs(schedule.id, 2)) + + assert :ok = OnCall.reorder_layers(schedule.id, [l2.id, l0.id, l1.id]) + + reloaded = OnCall.get_schedule!(schedule.id) + by_id = Map.new(reloaded.layers, &{&1.id, &1.position}) + assert by_id[l2.id] == 0 + assert by_id[l0.id] == 1 + assert by_id[l1.id] == 2 + end + + test "rejects when the id list does not cover every layer", %{organization: org} do + schedule = schedule_fixture(org.id) + {:ok, l0} = OnCall.create_layer(layer_attrs(schedule.id, 0)) + {:ok, _} = OnCall.create_layer(layer_attrs(schedule.id, 1)) + + assert {:error, :id_count_mismatch} = OnCall.reorder_layers(schedule.id, [l0.id]) + end + + test "rejects unknown ids", %{organization: org} do + schedule = schedule_fixture(org.id) + {:ok, l0} = OnCall.create_layer(layer_attrs(schedule.id, 0)) + {:ok, _l1} = OnCall.create_layer(layer_attrs(schedule.id, 1)) + + # Same length, but one id is bogus + assert {:error, :unknown_id} = + OnCall.reorder_layers(schedule.id, [l0.id, Ecto.UUID.generate()]) + end + end + + describe "reorder_escalation_rules/2" do + test "applies the given order as 0..n-1 positions", %{organization: org} do + policy = escalation_policy_fixture(org.id) + + {:ok, r0} = + OnCall.create_escalation_rule(%{ + escalation_policy_id: policy.id, + position: 0, + timeout_minutes: 10 + }) + + {:ok, r1} = + OnCall.create_escalation_rule(%{ + escalation_policy_id: policy.id, + position: 1, + timeout_minutes: 20 + }) + + {:ok, r2} = + OnCall.create_escalation_rule(%{ + escalation_policy_id: policy.id, + position: 2, + timeout_minutes: 30 + }) + + assert :ok = OnCall.reorder_escalation_rules(policy.id, [r2.id, r0.id, r1.id]) + + reloaded = OnCall.get_escalation_policy!(policy.id) + by_id = Map.new(reloaded.rules, &{&1.id, &1.position}) + assert by_id[r2.id] == 0 + assert by_id[r0.id] == 1 + assert by_id[r1.id] == 2 + end + end + describe "move_layer/2" do test "swaps positions with the neighbour above when direction is :up", %{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 3c8eb78e..86702f6c 100644 --- a/test/towerops_web/live/escalation_policy_live_test.exs +++ b/test/towerops_web/live/escalation_policy_live_test.exs @@ -217,6 +217,32 @@ defmodule ToweropsWeb.EscalationPolicyLiveTest do assert html =~ "No devices use this policy" end + test "reorder_rules event applies the new order", %{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}") + + render_hook(view, "reorder_rules", %{"ordered_ids" => [r1.id, r0.id]}) + reloaded = OnCall.get_escalation_policy!(policy.id) + by_id = Map.new(reloaded.rules, &{&1.id, &1.position}) + assert by_id[r1.id] == 0 + assert by_id[r0.id] == 1 + end + test "can move a level up via the move_rule event", %{conn: conn, organization: organization} do policy = escalation_policy_fixture(organization.id) diff --git a/test/towerops_web/live/schedule_live_test.exs b/test/towerops_web/live/schedule_live_test.exs index efec6518..fba7f77e 100644 --- a/test/towerops_web/live/schedule_live_test.exs +++ b/test/towerops_web/live/schedule_live_test.exs @@ -208,6 +208,40 @@ defmodule ToweropsWeb.ScheduleLiveTest do assert html =~ "America/New_York" end + test "auto-opens the Add Layer form when no layers exist", %{ + conn: conn, + organization: organization + } do + # Schedule with zero layers (just-created flow) + schedule = schedule_fixture(organization.id) + + {:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}") + + # The form fields are inline when show_add_layer is true + assert html =~ ~r/name="layer\[/ + end + + test "does not auto-open Add Layer when layers already exist", %{ + conn: conn, + organization: organization + } do + schedule = schedule_fixture(organization.id) + + _ = + layer_fixture(schedule.id, %{ + name: "Existing", + position: 0, + rotation_type: "weekly", + handoff_time: ~T[09:00:00], + start_date: ~U[2026-01-01 09:00:00Z] + }) + + {:ok, _view, html} = live(conn, ~p"/schedules/#{schedule.id}") + + # The form should not be visible inline by default + refute html =~ ~r/name="layer\[/ + end + test "shows description if present", %{conn: conn, organization: organization} do schedule = schedule_fixture(organization.id, %{ @@ -220,6 +254,36 @@ defmodule ToweropsWeb.ScheduleLiveTest do assert html =~ "Covers weekday shifts" end + test "reorder_layers event applies the new order", %{conn: conn, organization: organization} do + schedule = schedule_fixture(organization.id) + + l0 = + layer_fixture(schedule.id, %{ + name: "L0", + position: 0, + rotation_type: "weekly", + handoff_time: ~T[09:00:00], + start_date: ~U[2026-01-01 09:00:00Z] + }) + + l1 = + layer_fixture(schedule.id, %{ + name: "L1", + position: 1, + rotation_type: "weekly", + handoff_time: ~T[09:00:00], + start_date: ~U[2026-01-01 09:00:00Z] + }) + + {:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}") + + render_hook(view, "reorder_layers", %{"ordered_ids" => [l1.id, l0.id]}) + reloaded = OnCall.get_schedule!(schedule.id) + by_id = Map.new(reloaded.layers, &{&1.id, &1.position}) + assert by_id[l1.id] == 0 + assert by_id[l0.id] == 1 + end + test "can reorder layers via the up/down buttons", %{conn: conn, organization: organization} do schedule = schedule_fixture(organization.id) @@ -326,11 +390,24 @@ defmodule ToweropsWeb.ScheduleLiveTest do assert html =~ "No overrides configured" end - test "can toggle add layer form", %{conn: conn, organization: organization} do + test "can toggle add layer form once a layer exists", %{ + conn: conn, + organization: organization + } do schedule = schedule_fixture(organization.id) + _ = + layer_fixture(schedule.id, %{ + name: "Existing", + position: 0, + rotation_type: "weekly", + handoff_time: ~T[09:00:00], + start_date: ~U[2026-01-01 09:00:00Z] + }) + {:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}") + # With at least one layer the form is hidden by default refute has_element?(view, "form[phx-submit='save_layer']") view |> element("button", "Add Layer") |> render_click() @@ -338,13 +415,14 @@ defmodule ToweropsWeb.ScheduleLiveTest do assert has_element?(view, "form[phx-submit='save_layer']") end - test "can add a layer", %{conn: conn, organization: organization} do + test "can add a layer (form is pre-opened on a fresh schedule)", %{ + conn: conn, + organization: organization + } do schedule = schedule_fixture(organization.id) {:ok, view, _html} = live(conn, ~p"/schedules/#{schedule.id}") - view |> element("button", "Add Layer") |> render_click() - view |> form("form[phx-submit='save_layer']", layer: %{