feat(on_call): schedules list search + pagination + e2e refresh (chunk 4)

ScheduleLive.Index
- Name search input above the date nav (phx-debounce 300ms).
- Standard <.pagination> footer (10 per page) under the gantt cards.
- Both reflected in URL params (?search= and ?page=) for shareable
  links; defaults are stripped from the URL.
- Refactored: load_schedules_with_timeline/3 -> hydrate_schedules/3
  so filter/paginate/hydrate are clearly separate steps.

Tests
- 2 new ExUnit integration tests (search filter narrows results +
  search input renders).
- e2e/tests/schedules.spec.ts: switched from table-row selectors to
  card-link selectors (schedules tab is now gantt cards, not a table),
  added smoke tests for date nav controls + search input + REPEAT
  footer + Services sidebar, renamed "Add Rule" -> "Add Level".

Full suite 10,216 / 0 failures. Format/dialyzer clean.
This commit is contained in:
Graham McIntire 2026-04-28 14:12:42 -05:00
parent c76a354d89
commit a7c0d362e5
6 changed files with 213 additions and 22 deletions

View file

@ -1,3 +1,19 @@
2026-04-28
feat(on_call): schedules list search/pagination + e2e refresh (chunk 4)
- ScheduleLive.Index: name search input (phx-debounce 300ms) + standard
<.pagination> footer (10 per page). Both wired to URL params for
shareable views; defaults are stripped from the URL to keep it clean.
- Refactored: load_schedules_with_timeline/3 -> hydrate_schedules/3
so filter/paginate/hydrate are clear separate steps.
- 2 new integration tests (search filter + search input present).
- Updated e2e/schedules.spec.ts: switched from table-row selectors to
card-link selectors (schedules tab is now gantt cards, not a table),
added smoke tests for date nav controls + search input + REPEAT
footer + Services sidebar, renamed "Add Rule" -> "Add Level".
Files: lib/towerops_web/live/schedule_live/{index.ex,index.html.heex},
test/towerops_web/live/schedule_live_test.exs,
e2e/tests/schedules.spec.ts
2026-04-28
feat(on_call): schedule editor layer reorder + unified resolver (chunk 3)
- OnCall.move_layer/2: up/down position swap for schedule layers (mirror

View file

@ -103,19 +103,37 @@ test.describe('On-Call Schedules & Escalation Policies', () => {
await expect(policiesTab).toBeVisible();
});
test('schedules tab shows empty state or schedule list', async ({ page }) => {
test('schedules tab shows empty state or schedule cards', async ({ page }) => {
await page.goto('/schedules');
await page.waitForTimeout(500);
// Either shows empty state or a table of schedules
// Either shows empty state or a list of schedule cards
const emptyState = page.getByText('No schedules');
const scheduleTable = page.locator('table');
const scheduleLink = page.locator('a[href^="/schedules/"]').first();
const hasEmptyState = await emptyState.isVisible({ timeout: 2000 }).catch(() => false);
const hasTable = await scheduleTable.isVisible({ timeout: 2000 }).catch(() => false);
const hasCards = await scheduleLink.isVisible({ timeout: 2000 }).catch(() => false);
// One of these should be visible
expect(hasEmptyState || hasTable).toBe(true);
expect(hasEmptyState || hasCards).toBe(true);
});
test('schedules tab renders date navigation controls', async ({ page }) => {
await page.goto('/schedules');
await page.waitForTimeout(500);
// Today / prev / next buttons + range selector
await expect(page.getByRole('button', { name: 'Today' })).toBeVisible();
await expect(page.locator('button[phx-click="prev"]')).toBeVisible();
await expect(page.locator('button[phx-click="next"]')).toBeVisible();
await expect(page.locator('select[name="range"]')).toBeVisible();
});
test('schedules tab renders the search input', async ({ page }) => {
await page.goto('/schedules');
await page.waitForTimeout(500);
const searchInput = page.locator('input[name="search"]');
await expect(searchInput).toBeVisible();
});
test('can navigate to new schedule form', async ({ page }) => {
@ -186,8 +204,8 @@ test.describe('On-Call Schedules & Escalation Policies', () => {
await page.goto('/schedules');
await page.waitForTimeout(500);
// Look for a schedule row in the table
const scheduleRow = page.locator('tr[class*="cursor-pointer"]').first();
// Schedule links are now anchor tags inside the gantt cards
const scheduleRow = page.locator('a[href^="/schedules/"]').nth(0);
if (await scheduleRow.isVisible({ timeout: 2000 }).catch(() => false)) {
await scheduleRow.click();
await page.waitForTimeout(500);
@ -375,9 +393,14 @@ test.describe('On-Call Schedules & Escalation Policies', () => {
const nameInput = page.locator('input[name*="name"]').first();
await expect(nameInput).toBeVisible();
// Inline repeat block (not a standalone Repeat Count label anymore)
const repeatInput = page.locator('input[name*="repeat_count"]').first();
await expect(repeatInput).toBeVisible();
// Handoff notifications mode select shipped in chunk 1
const handoffSelect = page.locator('select[name*="handoff_notifications_mode"]').first();
await expect(handoffSelect).toBeVisible();
const saveButton = page.getByText('Save Policy');
await expect(saveButton).toBeVisible();
});
@ -491,7 +514,7 @@ test.describe('On-Call Schedules & Escalation Policies', () => {
}
});
test('can toggle the Add Rule form on policy show page', async ({ page }) => {
test('can toggle the Add Level form on policy show page', async ({ page }) => {
await page.goto('/schedules?tab=escalation-policies');
await page.waitForTimeout(500);
@ -500,19 +523,17 @@ test.describe('On-Call Schedules & Escalation Policies', () => {
await policyRow.click();
await page.waitForTimeout(500);
const addRuleButton = page.getByText('Add Rule', { exact: true }).first();
if (await addRuleButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await addRuleButton.click();
const addLevelButton = page.getByText('Add Level', { exact: true }).first();
if (await addLevelButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await addLevelButton.click();
await page.waitForTimeout(500);
// Rule form should be visible with timeout field
const timeoutInput = page.locator('input[name="timeout_minutes"]').first();
const formVisible = await timeoutInput.isVisible({ timeout: 2000 }).catch(() => false);
if (formVisible) {
await expect(timeoutInput).toBeVisible();
// Click cancel to hide
const cancelButton = page.locator('button:has-text("Cancel")').first();
if (await cancelButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await cancelButton.click();
@ -522,5 +543,34 @@ test.describe('On-Call Schedules & Escalation Policies', () => {
}
}
});
test('policy show page renders the REPEAT footer surfacing repeat_count', async ({ page }) => {
await page.goto('/schedules?tab=escalation-policies');
await page.waitForTimeout(500);
const policyRow = page.locator('tr[class*="cursor-pointer"]').first();
if (await policyRow.isVisible({ timeout: 2000 }).catch(() => false)) {
await policyRow.click();
await page.waitForTimeout(500);
await expect(
page.getByText('If no one acknowledges, repeat this policy', { exact: false })
).toBeVisible();
}
});
test('policy show page renders the Services sidebar', async ({ page }) => {
await page.goto('/schedules?tab=escalation-policies');
await page.waitForTimeout(500);
const policyRow = page.locator('tr[class*="cursor-pointer"]').first();
if (await policyRow.isVisible({ timeout: 2000 }).catch(() => false)) {
await policyRow.click();
await page.waitForTimeout(500);
// The sidebar header reads "Services (N)" — match by prefix
await expect(page.getByText(/^Services \(\d+\)/)).toBeVisible();
}
});
});
});

View file

@ -8,6 +8,7 @@ defmodule ToweropsWeb.ScheduleLive.Index do
@valid_ranges [7, 14, 28]
@default_range 14
@per_page 10
@impl true
def mount(_params, _session, socket) do
@ -17,7 +18,9 @@ defmodule ToweropsWeb.ScheduleLive.Index do
|> assign(:active_page, "schedules")
|> assign(:tab, "schedules")
|> assign(:start_date, default_start_date())
|> assign(:range_days, @default_range)}
|> assign(:range_days, @default_range)
|> assign(:search, "")
|> assign(:page, 1)}
end
@impl true
@ -25,6 +28,8 @@ defmodule ToweropsWeb.ScheduleLive.Index do
tab = Map.get(params, "tab", "schedules")
start_date = parse_start_date(params["start"])
range_days = parse_range_days(params["range"])
search = params["search"] |> to_string() |> String.trim()
page = parse_page(params["page"])
org_id = socket.assigns.current_scope.organization.id
{:noreply,
@ -32,6 +37,8 @@ defmodule ToweropsWeb.ScheduleLive.Index do
|> assign(:tab, tab)
|> assign(:start_date, start_date)
|> assign(:range_days, range_days)
|> assign(:search, search)
|> assign(:page, page)
|> load_data(org_id)}
end
@ -55,12 +62,36 @@ defmodule ToweropsWeb.ScheduleLive.Index do
{:noreply, navigate_to_window(socket, socket.assigns.start_date, parsed)}
end
defp navigate_to_window(socket, start_date, range_days) do
def handle_event("search", %{"search" => search}, socket) do
overrides = %{search: String.trim(search), page: 1}
{:noreply,
push_patch(socket,
to: ~p"/schedules?#{[tab: socket.assigns.tab, start: Date.to_iso8601(start_date), range: range_days]}"
to: ~p"/schedules?#{base_query_params(socket, overrides)}"
)}
end
defp navigate_to_window(socket, start_date, range_days) do
overrides = %{start: Date.to_iso8601(start_date), range: range_days, page: 1}
push_patch(socket,
to: ~p"/schedules?#{base_query_params(socket, overrides)}"
)
end
defp base_query_params(socket, overrides) do
%{
tab: socket.assigns.tab,
start: Date.to_iso8601(socket.assigns.start_date),
range: socket.assigns.range_days,
search: socket.assigns.search,
page: socket.assigns.page
}
|> Map.merge(overrides)
|> Enum.reject(fn {_k, v} -> v in [nil, "", 1] end)
|> Map.new()
end
defp load_data(socket, org_id) do
case socket.assigns.tab do
"escalation-policies" ->
@ -68,19 +99,45 @@ defmodule ToweropsWeb.ScheduleLive.Index do
assign(socket, :policies, policies)
_ ->
schedules = load_schedules_with_timeline(org_id, socket.assigns.start_date, socket.assigns.range_days)
assign(socket, :schedules, schedules)
all_schedules = OnCall.list_schedules(org_id)
filtered = filter_schedules(all_schedules, socket.assigns.search)
total_count = length(filtered)
total_pages = max(1, ceil(total_count / @per_page))
page = max(1, min(socket.assigns.page, total_pages))
page_schedules =
filtered
|> Enum.slice((page - 1) * @per_page, @per_page)
|> hydrate_schedules(socket.assigns.start_date, socket.assigns.range_days)
socket
|> assign(:schedules, page_schedules)
|> assign(:page, page)
|> assign(:pagination, %{
page: page,
per_page: @per_page,
total_count: total_count,
total_pages: total_pages
})
end
end
defp load_schedules_with_timeline(org_id, start_date, range_days) do
defp filter_schedules(schedules, ""), do: schedules
defp filter_schedules(schedules, term) when is_binary(term) do
needle = String.downcase(term)
Enum.filter(schedules, fn s ->
String.contains?(String.downcase(s.name || ""), needle)
end)
end
defp hydrate_schedules(schedules, start_date, range_days) do
end_date = Date.add(start_date, range_days)
{:ok, start_at} = DateTime.new(start_date, ~T[00:00:00], "Etc/UTC")
{:ok, end_at} = DateTime.new(end_date, ~T[00:00:00], "Etc/UTC")
org_id
|> OnCall.list_schedules()
|> Enum.map(fn schedule ->
Enum.map(schedules, fn schedule ->
preloaded = OnCall.get_schedule!(schedule.id)
on_call = Resolver.resolve(preloaded, DateTime.utc_now())
@ -104,6 +161,18 @@ defmodule ToweropsWeb.ScheduleLive.Index do
end)
end
defp parse_page(nil), do: 1
defp parse_page(value) when is_binary(value) do
case Integer.parse(value) do
{int, ""} when int >= 1 -> int
_ -> 1
end
end
defp parse_page(value) when is_integer(value) and value >= 1, do: value
defp parse_page(_), do: 1
# Day index (0-based) within the visible window.
defp day_offset(%DateTime{} = at, %Date{} = window_start) do
at

View file

@ -102,6 +102,23 @@
</div>
<% end %>
<% else %>
<%!-- Search input --%>
<form phx-change="search" phx-submit="search" class="mb-4">
<div class="relative max-w-md">
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2.5">
<.icon name="hero-magnifying-glass" class="h-4 w-4 text-gray-400" />
</span>
<input
type="text"
name="search"
value={@search}
placeholder={t("Search schedules…")}
phx-debounce="300"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white text-sm pl-9 py-1.5"
/>
</div>
</form>
<%!-- Date navigation header --%>
<div class="flex items-center justify-between flex-wrap gap-3 mb-4">
<div class="flex items-center gap-2">
@ -237,6 +254,19 @@
</div>
<% end %>
</div>
<.pagination
meta={@pagination}
path={~p"/schedules"}
params={
%{
"tab" => @tab,
"start" => Date.to_iso8601(@start_date),
"range" => Integer.to_string(@range_days),
"search" => @search
}
}
/>
<% end %>
<% end %>
</Layouts.authenticated>

View file

@ -1,3 +1,8 @@
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
* End-to-end browser tests updated for the new gantt-based UI
2026-04-28 — Schedule Layer Reorder
* Added up/down reorder controls to layers on the schedule detail page
* Schedule timelines now use the same per-day rendering engine as the schedules list

View file

@ -124,6 +124,27 @@ defmodule ToweropsWeb.ScheduleLiveTest do
assert html =~ "2 Weeks"
end
test "filters schedules by name when search is provided", %{
conn: conn,
organization: organization
} do
_ = schedule_fixture(organization.id, %{name: "Network Primary"})
_ = schedule_fixture(organization.id, %{name: "Server Backup"})
{:ok, _view, html} = live(conn, ~p"/schedules?search=network")
assert html =~ "Network Primary"
refute html =~ "Server Backup"
end
test "search input search box is rendered", %{conn: conn, organization: organization} do
_ = schedule_fixture(organization.id)
{:ok, view, _html} = live(conn, ~p"/schedules")
assert has_element?(view, ~s|input[name="search"]|)
end
test "shows 'No one' when nobody is on-call", %{conn: conn, organization: organization} do
schedule_fixture(organization.id)