add row_link to table component for proper anchor tags

Replace phx-click JS.navigate with real <.link navigate> elements
on table rows so users can Ctrl+Click to open in new tabs, use
right-click context menus, and navigate via keyboard. Adds row_link
attr to <.table> core component and applies it to all agent tables.
Converts device list custom table from phx-click to inner links.
This commit is contained in:
Graham McIntire 2026-02-11 08:46:03 -06:00
parent 77cf289058
commit c5d8d73a0c
No known key found for this signature in database
5 changed files with 192 additions and 64 deletions

View file

@ -456,6 +456,10 @@ defmodule ToweropsWeb.CoreComponents do
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
attr :row_link, :any,
default: nil,
doc: "the function for generating a navigate path for each row, renders real <a> tags"
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
@ -498,13 +502,23 @@ defmodule ToweropsWeb.CoreComponents do
:for={{col, i} <- Enum.with_index(@col)}
phx-click={@row_click && @row_click.(row)}
class={[
"py-4 pr-3 pl-4 text-sm whitespace-nowrap sm:pl-6",
i == 0 && "font-medium text-gray-900 dark:text-white",
i > 0 && "text-gray-500 dark:text-gray-400",
@row_link && "p-0 text-sm whitespace-nowrap",
!@row_link && "py-4 pr-3 pl-4 text-sm whitespace-nowrap sm:pl-6",
@row_click && "cursor-pointer"
]}
>
{render_slot(col, @row_item.(row))}
<%= if @row_link do %>
<.link
navigate={@row_link.(row)}
class="block py-4 pr-3 pl-4 sm:pl-6 text-inherit no-underline"
>
{render_slot(col, @row_item.(row))}
</.link>
<% else %>
{render_slot(col, @row_item.(row))}
<% end %>
</td>
<td
:if={@action != []}

View file

@ -13,15 +13,14 @@
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">
<.icon name="hero-cloud" class="w-5 h-5 inline mr-1" /> Cloud Pollers
</h2>
<.table id="cloud-pollers-table" rows={@streams.cloud_pollers}>
<.table
id="cloud-pollers-table"
rows={@streams.cloud_pollers}
row_link={fn {_id, agent} -> ~p"/agents/#{agent.id}" end}
>
<:col :let={{_id, agent}} label="Name">
<div class="flex items-center gap-2">
<.link
navigate={~p"/agents/#{agent.id}"}
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{agent.name}
</.link>
<span class="font-medium">{agent.name}</span>
<%= if agent.allow_remote_debug do %>
<span class="inline-flex items-center gap-1 rounded-full bg-amber-50 dark:bg-amber-900/30 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300 ring-1 ring-inset ring-amber-600/20 dark:ring-amber-400/30">
<.icon name="hero-bug-ant" class="h-3 w-3" /> Debug
@ -83,16 +82,15 @@
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">
<.icon name="hero-server" class="w-5 h-5 inline mr-1" /> Organization Agents
</h2>
<.table id="org-agents-table" rows={@streams.org_agents}>
<.table
id="org-agents-table"
rows={@streams.org_agents}
row_link={fn {_id, agent} -> ~p"/agents/#{agent.id}" end}
>
<:col :let={{_id, agent}} label="Name">
<div>
<div class="flex items-center gap-2">
<.link
navigate={~p"/agents/#{agent.id}"}
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{agent.name}
</.link>
<span class="font-medium">{agent.name}</span>
<%= if agent.allow_remote_debug do %>
<span class="inline-flex items-center gap-1 rounded-full bg-amber-50 dark:bg-amber-900/30 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300 ring-1 ring-inset ring-amber-600/20 dark:ring-amber-400/30">
<.icon name="hero-bug-ant" class="h-3 w-3" /> Debug

View file

@ -67,15 +67,14 @@
</div>
<% else %>
<div class="mt-6">
<.table id="agents-table" rows={@streams.agent_tokens}>
<.table
id="agents-table"
rows={@streams.agent_tokens}
row_link={fn {_id, agent} -> ~p"/agents/#{agent.id}" end}
>
<:col :let={{_id, agent}} label="Name">
<div class="flex items-center gap-2">
<.link
navigate={~p"/agents/#{agent.id}"}
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{agent.name}
</.link>
<span class="font-medium">{agent.name}</span>
<%= if agent.is_cloud_poller do %>
<span class="inline-flex items-center gap-1 rounded-full bg-purple-50 dark:bg-purple-900/30 px-2 py-0.5 text-xs font-medium text-purple-700 dark:text-purple-300 ring-1 ring-inset ring-purple-600/20 dark:ring-purple-400/30">
<.icon name="hero-cloud" class="h-3 w-3" /> Cloud
@ -178,15 +177,14 @@
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Application-wide agents that can poll devices for any organization. Only visible and manageable by superadmins.
</p>
<.table id="cloud-pollers-table" rows={@streams.cloud_pollers}>
<.table
id="cloud-pollers-table"
rows={@streams.cloud_pollers}
row_link={fn {_id, agent} -> ~p"/agents/#{agent.id}" end}
>
<:col :let={{_id, agent}} label="Name">
<div class="flex items-center gap-2">
<.link
navigate={~p"/agents/#{agent.id}"}
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{agent.name}
</.link>
<span class="font-medium">{agent.name}</span>
<%= if agent.id == @global_default_cloud_poller_id do %>
<span class="inline-flex items-center gap-1 rounded-full bg-green-50 dark:bg-green-900/30 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300 ring-1 ring-inset ring-green-600/20 dark:ring-green-400/30">
<.icon name="hero-check-circle" class="h-3 w-3" /> Default

View file

@ -319,43 +319,54 @@
<.icon name="hero-bars-3" class="h-5 w-5" />
</button>
</td>
<td
phx-click={JS.navigate(~p"/devices/#{row.device.id}")}
class={[
"py-4 pr-3 text-sm font-medium whitespace-nowrap text-gray-900 dark:text-white cursor-pointer",
@reorder_mode && "pl-2",
!@reorder_mode && "pl-4 sm:pl-3"
]}
>
{row.device.name}
<td class="p-0 text-sm font-medium whitespace-nowrap text-gray-900 dark:text-white">
<.link
navigate={~p"/devices/#{row.device.id}"}
class={[
"block py-4 pr-3 text-inherit no-underline",
@reorder_mode && "pl-2",
!@reorder_mode && "pl-4 sm:pl-3"
]}
>
{row.device.name}
</.link>
</td>
<td
phx-click={JS.navigate(~p"/devices/#{row.device.id}")}
class="px-3 py-4 text-sm whitespace-nowrap text-gray-500 dark:text-gray-400 font-mono cursor-pointer"
>
{row.device.ip_address}
<td class="p-0 text-sm whitespace-nowrap text-gray-500 dark:text-gray-400 font-mono">
<.link
navigate={~p"/devices/#{row.device.id}"}
class="block px-3 py-4 text-inherit no-underline"
>
{row.device.ip_address}
</.link>
</td>
<td
phx-click={JS.navigate(~p"/devices/#{row.device.id}")}
class="px-3 py-4 text-sm whitespace-nowrap cursor-pointer"
>
<span class={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
row.device.status == :up &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
row.device.status == :down &&
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
row.device.status == :unknown &&
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
]}>
{row.device.status |> to_string() |> String.upcase()}
</span>
<td class="p-0 text-sm whitespace-nowrap">
<.link
navigate={~p"/devices/#{row.device.id}"}
class="block px-3 py-4 text-inherit no-underline"
>
<span class={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
row.device.status == :up &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
row.device.status == :down &&
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
row.device.status == :unknown &&
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
]}>
{row.device.status |> to_string() |> String.upcase()}
</span>
</.link>
</td>
<td
phx-click={JS.navigate(~p"/devices/#{row.device.id}")}
class="px-3 py-4 text-sm whitespace-nowrap text-gray-500 dark:text-gray-400 cursor-pointer"
>
<.timestamp datetime={row.device.last_checked_at} timezone={@timezone} />
<td class="p-0 text-sm whitespace-nowrap text-gray-500 dark:text-gray-400">
<.link
navigate={~p"/devices/#{row.device.id}"}
class="block px-3 py-4 text-inherit no-underline"
>
<.timestamp
datetime={row.device.last_checked_at}
timezone={@timezone}
/>
</.link>
</td>
</tr>
<% end %>

View file

@ -0,0 +1,107 @@
defmodule ToweropsWeb.CoreComponentsTest do
use ExUnit.Case, async: true
import Phoenix.Component
import Phoenix.LiveViewTest
alias ToweropsWeb.CoreComponents
describe "table/1" do
test "renders basic table without row_link" do
assigns = %{}
html =
rendered_to_string(~H"""
<CoreComponents.table
id="test-table"
rows={[%{id: "1", name: "Alice"}, %{id: "2", name: "Bob"}]}
>
<:col :let={row} label="Name">{row.name}</:col>
</CoreComponents.table>
""")
assert html =~ "Alice"
assert html =~ "Bob"
refute html =~ "<a "
end
test "renders row_link as real anchor tags with navigate" do
assigns = %{}
html =
rendered_to_string(~H"""
<CoreComponents.table
id="test-table"
rows={[%{id: "1", name: "Alice"}, %{id: "2", name: "Bob"}]}
row_link={fn row -> "/users/#{row.id}" end}
>
<:col :let={row} label="Name">{row.name}</:col>
</CoreComponents.table>
""")
assert html =~ ~s|href="/users/1"|
assert html =~ ~s|href="/users/2"|
assert html =~ "Alice"
assert html =~ "Bob"
end
test "row_link renders a link in each column cell" do
assigns = %{}
html =
rendered_to_string(~H"""
<CoreComponents.table
id="test-table"
rows={[%{id: "1", name: "Alice", email: "alice@example.com"}]}
row_link={fn row -> "/users/#{row.id}" end}
>
<:col :let={row} label="Name">{row.name}</:col>
<:col :let={row} label="Email">{row.email}</:col>
</CoreComponents.table>
""")
# Each column cell should have a link (2 columns = 2 links)
link_count = length(Regex.scan(~r/href="\/users\/1"/, html))
assert link_count == 2
end
test "row_click still works without row_link" do
assigns = %{}
html =
rendered_to_string(~H"""
<CoreComponents.table
id="test-table"
rows={[%{id: "1", name: "Alice"}]}
row_click={fn row -> Phoenix.LiveView.JS.navigate("/users/#{row.id}") end}
>
<:col :let={row} label="Name">{row.name}</:col>
</CoreComponents.table>
""")
assert html =~ "phx-click"
assert html =~ "cursor-pointer"
refute html =~ ~s|<a |
end
test "row_link zeros td padding and moves it to the link" do
assigns = %{}
html =
rendered_to_string(~H"""
<CoreComponents.table
id="test-table"
rows={[%{id: "1", name: "Alice"}]}
row_link={fn row -> "/users/#{row.id}" end}
>
<:col :let={row} label="Name">{row.name}</:col>
</CoreComponents.table>
""")
# td should have p-0 when row_link is used
assert html =~ "p-0"
# link should have the padding
assert html =~ "py-4"
end
end
end