feat(on_call): drag-drop reorder + restrictions editor + new-schedule polish
Three deferred followups from the chunk-4 plan, all together: A. Restrictions editor - Resolver: time_of_week support (weekday set + time-of-day window), in addition to the existing time_of_day. - New OnCall.update_layer_restrictions/2 + clear_layer_restrictions/1. - schedule_live/show: per-layer Restrict on-call shifts form (Always on call / Time of day / Time of week with Mon-Sun checkboxes), changes patched live and persisted via the resolver. B. Drag-and-drop reorder - New 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 carry data-id + draggable + a visible drag handle. Up/down arrow buttons remain as a fallback for users on assistive tech. 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. Two existing tests updated to seed a layer first; two new tests verify the auto-open behaviour both ways. Full suite 10,231 / 0 failures. Format/dialyzer clean.
This commit is contained in:
parent
a7c0d362e5
commit
434386816e
14 changed files with 879 additions and 139 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
114
assets/js/sortable_list.ts
Normal file
114
assets/js/sortable_list.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* SortableList — minimal HTML5 drag-and-drop hook for reorderable lists.
|
||||
*
|
||||
* Markup contract:
|
||||
* <div phx-hook="SortableList" id="..." data-event="reorder_layers" data-group-key="schedule_id">
|
||||
* <div data-id="<uuid>" draggable="true">...</div>
|
||||
* <div data-id="<uuid>" draggable="true">...</div>
|
||||
* </div>
|
||||
*
|
||||
* 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<HTMLElement>("[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<HTMLElement>(":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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{t("Level %{n}", n: idx + 1)}
|
||||
</h3>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
phx-click="move_rule"
|
||||
phx-value-id={rule.id}
|
||||
phx-value-direction="up"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:hover:text-gray-400"
|
||||
disabled={idx == 0}
|
||||
title={t("Move up")}
|
||||
>
|
||||
<.icon name="hero-arrow-up" class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="move_rule"
|
||||
phx-value-id={rule.id}
|
||||
phx-value-direction="down"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:hover:text-gray-400"
|
||||
disabled={idx == rule_count - 1}
|
||||
title={t("Move down")}
|
||||
>
|
||||
<.icon name="hero-arrow-down" class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="delete_rule"
|
||||
phx-value-id={rule.id}
|
||||
data-confirm={t("Delete this level?")}
|
||||
class="text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors ml-1"
|
||||
title={t("Delete")}
|
||||
>
|
||||
<.icon name="hero-trash" class="h-4 w-4" />
|
||||
</button>
|
||||
<div
|
||||
id="level-list"
|
||||
class="space-y-3"
|
||||
phx-hook="SortableList"
|
||||
data-event="reorder_rules"
|
||||
>
|
||||
<%= for {rule, idx} <- Enum.with_index(sorted_rules) do %>
|
||||
<div
|
||||
data-id={rule.id}
|
||||
draggable="true"
|
||||
class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4 cursor-move"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<.icon name="hero-bars-3" class="h-4 w-4 text-gray-400" />
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{t("Level %{n}", n: idx + 1)}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
phx-click="move_rule"
|
||||
phx-value-id={rule.id}
|
||||
phx-value-direction="up"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:hover:text-gray-400"
|
||||
disabled={idx == 0}
|
||||
title={t("Move up")}
|
||||
>
|
||||
<.icon name="hero-arrow-up" class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="move_rule"
|
||||
phx-value-id={rule.id}
|
||||
phx-value-direction="down"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:hover:text-gray-400"
|
||||
disabled={idx == rule_count - 1}
|
||||
title={t("Move down")}
|
||||
>
|
||||
<.icon name="hero-arrow-down" class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="delete_rule"
|
||||
phx-value-id={rule.id}
|
||||
data-confirm={t("Delete this level?")}
|
||||
class="text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors ml-1"
|
||||
title={t("Delete")}
|
||||
>
|
||||
<.icon name="hero-trash" class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
{t("Notify the following users immediately and escalate after")}
|
||||
<span class="font-semibold text-gray-900 dark:text-white">
|
||||
{rule.timeout_minutes}
|
||||
</span>
|
||||
{t("minutes.")}
|
||||
</p>
|
||||
|
||||
<%!-- Targets --%>
|
||||
<div>
|
||||
<%= if Enum.empty?(rule.targets) do %>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mb-2">
|
||||
{t("No targets yet")}
|
||||
</p>
|
||||
<% else %>
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
<%= for target <- rule.targets do %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-gray-100 dark:bg-gray-800 pl-2.5 pr-1 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
<%= 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 %>
|
||||
<button
|
||||
phx-click="delete_target"
|
||||
phx-value-id={target.id}
|
||||
class="ml-0.5 rounded-full p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Add target form --%>
|
||||
<form phx-submit="add_target" class="flex items-center gap-2">
|
||||
<input type="hidden" name="rule_id" value={rule.id} />
|
||||
<select
|
||||
name="target_type"
|
||||
id={"target-type-#{rule.id}"}
|
||||
class="block rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1.5"
|
||||
>
|
||||
<option value="user">{t("User")}</option>
|
||||
<option value="schedule">{t("Schedule")}</option>
|
||||
</select>
|
||||
<select
|
||||
name="target_id"
|
||||
class="block w-full max-w-xs rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1.5"
|
||||
>
|
||||
<option value="">{t("Select...")}</option>
|
||||
<optgroup label={t("Users")}>
|
||||
<%= for user <- @org_users do %>
|
||||
<option value={user.id}>{user.email}</option>
|
||||
<% end %>
|
||||
</optgroup>
|
||||
<optgroup label={t("Schedules")}>
|
||||
<%= for sched <- @org_schedules do %>
|
||||
<option value={sched.id}>{sched.name}</option>
|
||||
<% end %>
|
||||
</optgroup>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center rounded-md bg-gray-100 dark:bg-gray-800 px-2.5 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<.icon name="hero-plus" class="h-3 w-3 mr-1" />
|
||||
{t("Add")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
{t("Notify the following users immediately and escalate after")}
|
||||
<span class="font-semibold text-gray-900 dark:text-white">
|
||||
{rule.timeout_minutes}
|
||||
</span>
|
||||
{t("minutes.")}
|
||||
</p>
|
||||
|
||||
<%!-- Targets --%>
|
||||
<div>
|
||||
<%= if Enum.empty?(rule.targets) do %>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mb-2">
|
||||
{t("No targets yet")}
|
||||
</p>
|
||||
<% else %>
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
<%= for target <- rule.targets do %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-gray-100 dark:bg-gray-800 pl-2.5 pr-1 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
<%= 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 %>
|
||||
<button
|
||||
phx-click="delete_target"
|
||||
phx-value-id={target.id}
|
||||
class="ml-0.5 rounded-full p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Add target form --%>
|
||||
<form phx-submit="add_target" class="flex items-center gap-2">
|
||||
<input type="hidden" name="rule_id" value={rule.id} />
|
||||
<select
|
||||
name="target_type"
|
||||
id={"target-type-#{rule.id}"}
|
||||
class="block rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1.5"
|
||||
>
|
||||
<option value="user">{t("User")}</option>
|
||||
<option value="schedule">{t("Schedule")}</option>
|
||||
</select>
|
||||
<select
|
||||
name="target_id"
|
||||
class="block w-full max-w-xs rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1.5"
|
||||
>
|
||||
<option value="">{t("Select...")}</option>
|
||||
<optgroup label={t("Users")}>
|
||||
<%= for user <- @org_users do %>
|
||||
<option value={user.id}>{user.email}</option>
|
||||
<% end %>
|
||||
</optgroup>
|
||||
<optgroup label={t("Schedules")}>
|
||||
<%= for sched <- @org_schedules do %>
|
||||
<option value={sched.id}>{sched.name}</option>
|
||||
<% end %>
|
||||
</optgroup>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center rounded-md bg-gray-100 dark:bg-gray-800 px-2.5 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<.icon name="hero-plus" class="h-3 w-3 mr-1" />
|
||||
{t("Add")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Add Level button + form --%>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -340,26 +340,40 @@
|
|||
<% else %>
|
||||
<% sorted_layers = Enum.sort_by(@schedule.layers, & &1.position) %>
|
||||
<% layer_count = length(sorted_layers) %>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
id="layer-list"
|
||||
class="space-y-4"
|
||||
phx-hook="SortableList"
|
||||
data-event="reorder_layers"
|
||||
>
|
||||
<%= for {layer, idx} <- Enum.with_index(sorted_layers) do %>
|
||||
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{layer.name}</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{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")}
|
||||
</p>
|
||||
<div
|
||||
data-id={layer.id}
|
||||
draggable="true"
|
||||
class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4 cursor-move"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-3 gap-3">
|
||||
<div class="flex items-start gap-2 min-w-0">
|
||||
<.icon name="hero-bars-3" class="h-4 w-4 text-gray-400 mt-0.5 shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white truncate">
|
||||
{layer.name}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{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")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
phx-click="move_layer"
|
||||
phx-value-id={layer.id}
|
||||
|
|
@ -435,6 +449,76 @@
|
|||
</select>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Restrictions editor --%>
|
||||
<div class="mt-3 pt-3 border-t border-gray-100 dark:border-white/5">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
|
||||
{t("Restrict on-call shifts")}
|
||||
</p>
|
||||
<form
|
||||
phx-change="set_layer_restriction"
|
||||
phx-submit="set_layer_restriction"
|
||||
class="space-y-2"
|
||||
>
|
||||
<input type="hidden" name="layer_id" value={layer.id} />
|
||||
<select
|
||||
name="restriction_type"
|
||||
class="block rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1"
|
||||
>
|
||||
<option value="" selected={is_nil(layer.restriction_type)}>
|
||||
{t("Always on call (no restriction)")}
|
||||
</option>
|
||||
<option value="time_of_day" selected={layer.restriction_type == "time_of_day"}>
|
||||
{t("Time of day")}
|
||||
</option>
|
||||
<option value="time_of_week" selected={layer.restriction_type == "time_of_week"}>
|
||||
{t("Time of week")}
|
||||
</option>
|
||||
</select>
|
||||
<%= if layer.restriction_type in ["time_of_day", "time_of_week"] do %>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-700 dark:text-gray-300">
|
||||
<span>{t("From")}</span>
|
||||
<input
|
||||
type="time"
|
||||
name="start_time"
|
||||
value={layer.restrictions["start_time"] || "09:00"}
|
||||
class="rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1"
|
||||
/>
|
||||
<span>{t("to")}</span>
|
||||
<input
|
||||
type="time"
|
||||
name="end_time"
|
||||
value={layer.restrictions["end_time"] || "17:00"}
|
||||
class="rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-xs py-1"
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if layer.restriction_type == "time_of_week" do %>
|
||||
<div class="flex flex-wrap gap-2 text-xs text-gray-700 dark:text-gray-300">
|
||||
<% 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 %>
|
||||
<label class="inline-flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={"days[#{day}]"}
|
||||
value="true"
|
||||
checked={day in selected_days}
|
||||
class="rounded border-gray-300 dark:border-gray-600 dark:bg-gray-800"
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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: %{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue