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:
parent
c76a354d89
commit
a7c0d362e5
6 changed files with 213 additions and 22 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue