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:
parent
77cf289058
commit
c5d8d73a0c
5 changed files with 192 additions and 64 deletions
|
|
@ -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 != []}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
107
test/towerops_web/components/core_components_test.exs
Normal file
107
test/towerops_web/components/core_components_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue