Add active page navigation highlighting

- Add active_page attribute to authenticated layout
- Update nav_link component to show active state with border and text styling
- Update all LiveView templates to pass active_page parameter
- Fix datetime truncation issues in tests and schemas for :utc_datetime fields
- Fix dashboard test assertions for equipment status text
This commit is contained in:
Graham McIntire 2026-01-03 12:28:26 -06:00
parent d283f4ac6c
commit 7d4f7dd7d4
No known key found for this signature in database
11 changed files with 77 additions and 26 deletions

View file

@ -204,7 +204,10 @@ defmodule Towerops.Organizations do
def accept_invitation(invitation, user_id) do
Ecto.Multi.new()
|> Ecto.Multi.update(:invitation, fn _ ->
Ecto.Changeset.change(invitation, %{accepted_at: DateTime.utc_now(), accepted_by_id: user_id})
Ecto.Changeset.change(invitation, %{
accepted_at: DateTime.truncate(DateTime.utc_now(), :second),
accepted_by_id: user_id
})
end)
|> Ecto.Multi.insert(:membership, fn _ ->
Membership.changeset(%Membership{}, %{

View file

@ -42,7 +42,7 @@ defmodule Towerops.Organizations.Invitation do
end
defp set_expires_at(changeset) do
expires_at = DateTime.add(DateTime.utc_now(), 7, :day)
expires_at = DateTime.utc_now() |> DateTime.add(7, :day) |> DateTime.truncate(:second)
put_change(changeset, :expires_at, expires_at)
end
end

View file

@ -52,7 +52,7 @@ defmodule ToweropsWeb.Layouts do
## Examples
<Layouts.authenticated flash={@flash} current_organization={@current_organization}>
<Layouts.authenticated flash={@flash} current_organization={@current_organization} active_page="dashboard">
<h1>Content</h1>
</Layouts.authenticated>
@ -63,6 +63,10 @@ defmodule ToweropsWeb.Layouts do
default: nil,
doc: "the current organization"
attr :active_page, :string,
default: nil,
doc: "the currently active page (dashboard, sites, equipment, or alerts)"
slot :inner_block, required: true
def authenticated(assigns) do
@ -78,16 +82,28 @@ defmodule ToweropsWeb.Layouts do
</.link>
</div>
<div :if={@current_organization} class="ml-10 flex space-x-8">
<.nav_link navigate={~p"/orgs/#{@current_organization.slug}"}>
<.nav_link
navigate={~p"/orgs/#{@current_organization.slug}"}
active={@active_page == "dashboard"}
>
Dashboard
</.nav_link>
<.nav_link navigate={~p"/orgs/#{@current_organization.slug}/sites"}>
<.nav_link
navigate={~p"/orgs/#{@current_organization.slug}/sites"}
active={@active_page == "sites"}
>
Sites
</.nav_link>
<.nav_link navigate={~p"/orgs/#{@current_organization.slug}/equipment"}>
<.nav_link
navigate={~p"/orgs/#{@current_organization.slug}/equipment"}
active={@active_page == "equipment"}
>
Equipment
</.nav_link>
<.nav_link navigate={~p"/orgs/#{@current_organization.slug}/alerts"}>
<.nav_link
navigate={~p"/orgs/#{@current_organization.slug}/alerts"}
active={@active_page == "alerts"}
>
Alerts
</.nav_link>
</div>
@ -149,13 +165,21 @@ defmodule ToweropsWeb.Layouts do
end
attr :navigate, :string, required: true
attr :active, :boolean, default: false
slot :inner_block, required: true
defp nav_link(assigns) do
~H"""
<.link
navigate={@navigate}
class="inline-flex items-center border-b-2 border-transparent px-1 pt-1 text-sm font-medium text-zinc-700 hover:border-zinc-300 hover:text-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:text-zinc-100"
class={[
"inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium",
if(@active,
do: "border-zinc-900 text-zinc-900 dark:border-zinc-100 dark:text-zinc-100",
else:
"border-transparent text-zinc-700 hover:border-zinc-300 hover:text-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:text-zinc-100"
)
]}
>
{render_slot(@inner_block)}
</.link>

View file

@ -1,4 +1,8 @@
<Layouts.authenticated flash={@flash} current_organization={@current_organization}>
<Layouts.authenticated
flash={@flash}
current_organization={@current_organization}
active_page="alerts"
>
<.header>
Alerts
<:subtitle>Monitor and acknowledge system alerts</:subtitle>

View file

@ -1,4 +1,8 @@
<Layouts.authenticated flash={@flash} current_organization={@current_organization}>
<Layouts.authenticated
flash={@flash}
current_organization={@current_organization}
active_page="dashboard"
>
<.header>
Dashboard
<:subtitle>Welcome to {@current_organization.name}</:subtitle>

View file

@ -1,4 +1,8 @@
<Layouts.authenticated flash={@flash} current_organization={@current_organization}>
<Layouts.authenticated
flash={@flash}
current_organization={@current_organization}
active_page="equipment"
>
<.header>
{@page_title}
<:subtitle>Monitor and manage all your network equipment</:subtitle>

View file

@ -1,4 +1,8 @@
<Layouts.authenticated flash={@flash} current_organization={@current_organization}>
<Layouts.authenticated
flash={@flash}
current_organization={@current_organization}
active_page="equipment"
>
<.header>
{@page_title}
<:subtitle>{@equipment.ip_address}</:subtitle>

View file

@ -1,4 +1,8 @@
<Layouts.authenticated flash={@flash} current_organization={@current_organization}>
<Layouts.authenticated
flash={@flash}
current_organization={@current_organization}
active_page="sites"
>
<.header>
{@page_title}
<:subtitle>Manage your site locations and hierarchy</:subtitle>

View file

@ -1,4 +1,8 @@
<Layouts.authenticated flash={@flash} current_organization={@current_organization}>
<Layouts.authenticated
flash={@flash}
current_organization={@current_organization}
active_page="sites"
>
<.header>
{@page_title}
<:subtitle>{@site.location}</:subtitle>

View file

@ -225,7 +225,7 @@ defmodule Towerops.OrganizationsTest do
role: :member,
invited_by_id: owner.id,
token: "test-token-#{System.unique_integer()}",
expires_at: DateTime.add(DateTime.utc_now(), 7, :day)
expires_at: DateTime.truncate(DateTime.add(DateTime.utc_now(), 7, :day), :second)
})
invitations = Organizations.list_pending_invitations(organization.id)
@ -244,8 +244,8 @@ defmodule Towerops.OrganizationsTest do
role: :member,
invited_by_id: owner.id,
token: "test-token-#{System.unique_integer()}",
expires_at: DateTime.add(DateTime.utc_now(), 7, :day),
accepted_at: DateTime.utc_now()
expires_at: DateTime.truncate(DateTime.add(DateTime.utc_now(), 7, :day), :second),
accepted_at: DateTime.truncate(DateTime.utc_now(), :second)
})
invitations = Organizations.list_pending_invitations(organization.id)
@ -263,7 +263,7 @@ defmodule Towerops.OrganizationsTest do
role: :member,
invited_by_id: owner.id,
token: "test-token-#{System.unique_integer()}",
expires_at: DateTime.add(DateTime.utc_now(), -1, :day)
expires_at: DateTime.truncate(DateTime.add(DateTime.utc_now(), -1, :day), :second)
})
invitations = Organizations.list_pending_invitations(organization.id)
@ -280,7 +280,7 @@ defmodule Towerops.OrganizationsTest do
role: :member,
invited_by_id: owner.id,
token: "unique-token-#{System.unique_integer()}",
expires_at: DateTime.add(DateTime.utc_now(), 7, :day)
expires_at: DateTime.truncate(DateTime.add(DateTime.utc_now(), 7, :day), :second)
}
assert {:ok, invitation} = Organizations.create_invitation(valid_attrs)
@ -301,7 +301,7 @@ defmodule Towerops.OrganizationsTest do
role: :member,
invited_by_id: owner.id,
token: token,
expires_at: DateTime.add(DateTime.utc_now(), 7, :day)
expires_at: DateTime.truncate(DateTime.add(DateTime.utc_now(), 7, :day), :second)
})
invitation = Organizations.get_invitation_by_token(token)
@ -321,8 +321,8 @@ defmodule Towerops.OrganizationsTest do
role: :member,
invited_by_id: owner.id,
token: token,
expires_at: DateTime.add(DateTime.utc_now(), 7, :day),
accepted_at: DateTime.utc_now()
expires_at: DateTime.truncate(DateTime.add(DateTime.utc_now(), 7, :day), :second),
accepted_at: DateTime.truncate(DateTime.utc_now(), :second)
})
assert Organizations.get_invitation_by_token(token) == nil
@ -341,7 +341,7 @@ defmodule Towerops.OrganizationsTest do
role: :member,
invited_by_id: owner.id,
token: token,
expires_at: DateTime.add(DateTime.utc_now(), 7, :day)
expires_at: DateTime.truncate(DateTime.add(DateTime.utc_now(), 7, :day), :second)
})
new_user = user_fixture()
@ -364,7 +364,7 @@ defmodule Towerops.OrganizationsTest do
role: :member,
invited_by_id: owner.id,
token: "test-token-#{System.unique_integer()}",
expires_at: DateTime.add(DateTime.utc_now(), 7, :day)
expires_at: DateTime.truncate(DateTime.add(DateTime.utc_now(), 7, :day), :second)
})
assert {:ok, _} = Organizations.delete_invitation(invitation)

View file

@ -63,8 +63,8 @@ defmodule ToweropsWeb.DashboardLiveTest do
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}")
assert html =~ "Equipment"
assert html =~ "UP"
assert html =~ "DOWN"
assert html =~ "Up"
assert html =~ "Down"
end
test "displays active alerts count", %{conn: conn, organization: organization, site: site} do