diff --git a/CHANGELOG.txt b/CHANGELOG.txt index f3b44738..39cc7dcb 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -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 diff --git a/e2e/tests/schedules.spec.ts b/e2e/tests/schedules.spec.ts index d3ff2057..b080d73b 100644 --- a/e2e/tests/schedules.spec.ts +++ b/e2e/tests/schedules.spec.ts @@ -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(); + } + }); }); }); diff --git a/lib/towerops_web/live/schedule_live/index.ex b/lib/towerops_web/live/schedule_live/index.ex index e7f95acd..642b3945 100644 --- a/lib/towerops_web/live/schedule_live/index.ex +++ b/lib/towerops_web/live/schedule_live/index.ex @@ -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 + def handle_event("search", %{"search" => search}, socket) do + overrides = %{search: String.trim(search), page: 1} + + {:noreply, + push_patch(socket, + 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?#{[tab: socket.assigns.tab, start: Date.to_iso8601(start_date), range: range_days]}" + 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 diff --git a/lib/towerops_web/live/schedule_live/index.html.heex b/lib/towerops_web/live/schedule_live/index.html.heex index a72b01bb..604ab06f 100644 --- a/lib/towerops_web/live/schedule_live/index.html.heex +++ b/lib/towerops_web/live/schedule_live/index.html.heex @@ -102,6 +102,23 @@ <% end %> <% else %> + <%!-- Search input --%> +
+
+ + <.icon name="hero-magnifying-glass" class="h-4 w-4 text-gray-400" /> + + +
+
+ <%!-- Date navigation header --%>
@@ -237,6 +254,19 @@
<% end %>
+ + <.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 %> diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt index 1d4ed51a..6ab86139 100644 --- a/priv/static/changelog.txt +++ b/priv/static/changelog.txt @@ -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 diff --git a/test/towerops_web/live/schedule_live_test.exs b/test/towerops_web/live/schedule_live_test.exs index 08b7cf48..efec6518 100644 --- a/test/towerops_web/live/schedule_live_test.exs +++ b/test/towerops_web/live/schedule_live_test.exs @@ -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)