From c5d8d73a0c50527311d17b15147b89d08b4b8b9e Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 11 Feb 2026 08:46:03 -0600 Subject: [PATCH] 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. --- .../components/core_components.ex | 18 ++- .../live/admin/agent_live/index.html.heex | 26 ++--- .../live/agent_live/index.html.heex | 26 ++--- .../live/device_live/index.html.heex | 79 +++++++------ .../components/core_components_test.exs | 107 ++++++++++++++++++ 5 files changed, 192 insertions(+), 64 deletions(-) create mode 100644 test/towerops_web/components/core_components_test.exs diff --git a/lib/towerops_web/components/core_components.ex b/lib/towerops_web/components/core_components.ex index c66324f6..db24e584 100644 --- a/lib/towerops_web/components/core_components.ex +++ b/lib/towerops_web/components/core_components.ex @@ -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 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))} + + <% else %> + {render_slot(col, @row_item.(row))} + <% end %> <.icon name="hero-cloud" class="w-5 h-5 inline mr-1" /> Cloud Pollers - <.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">
- <.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} - + {agent.name} <%= if agent.allow_remote_debug do %> <.icon name="hero-bug-ant" class="h-3 w-3" /> Debug @@ -83,16 +82,15 @@

<.icon name="hero-server" class="w-5 h-5 inline mr-1" /> Organization Agents

- <.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">
- <.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} - + {agent.name} <%= if agent.allow_remote_debug do %> <.icon name="hero-bug-ant" class="h-3 w-3" /> Debug diff --git a/lib/towerops_web/live/agent_live/index.html.heex b/lib/towerops_web/live/agent_live/index.html.heex index 64335f31..bc7dab1a 100644 --- a/lib/towerops_web/live/agent_live/index.html.heex +++ b/lib/towerops_web/live/agent_live/index.html.heex @@ -67,15 +67,14 @@
<% else %>
- <.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">
- <.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} - + {agent.name} <%= if agent.is_cloud_poller do %> <.icon name="hero-cloud" class="h-3 w-3" /> Cloud @@ -178,15 +177,14 @@

Application-wide agents that can poll devices for any organization. Only visible and manageable by superadmins.

- <.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">
- <.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} - + {agent.name} <%= if agent.id == @global_default_cloud_poller_id do %> <.icon name="hero-check-circle" class="h-3 w-3" /> Default diff --git a/lib/towerops_web/live/device_live/index.html.heex b/lib/towerops_web/live/device_live/index.html.heex index 9e831b4a..bd3d23f1 100644 --- a/lib/towerops_web/live/device_live/index.html.heex +++ b/lib/towerops_web/live/device_live/index.html.heex @@ -319,43 +319,54 @@ <.icon name="hero-bars-3" class="h-5 w-5" /> - - {row.device.name} + + <.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} + - - {row.device.ip_address} + + <.link + navigate={~p"/devices/#{row.device.id}"} + class="block px-3 py-4 text-inherit no-underline" + > + {row.device.ip_address} + - - - {row.device.status |> to_string() |> String.upcase()} - + + <.link + navigate={~p"/devices/#{row.device.id}"} + class="block px-3 py-4 text-inherit no-underline" + > + + {row.device.status |> to_string() |> String.upcase()} + + - - <.timestamp datetime={row.device.last_checked_at} timezone={@timezone} /> + + <.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} + /> + <% end %> diff --git a/test/towerops_web/components/core_components_test.exs b/test/towerops_web/components/core_components_test.exs new file mode 100644 index 00000000..6c962634 --- /dev/null +++ b/test/towerops_web/components/core_components_test.exs @@ -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""" + + <:col :let={row} label="Name">{row.name} + + """) + + assert html =~ "Alice" + assert html =~ "Bob" + refute html =~ " "/users/#{row.id}" end} + > + <:col :let={row} label="Name">{row.name} + + """) + + 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""" + "/users/#{row.id}" end} + > + <:col :let={row} label="Name">{row.name} + <:col :let={row} label="Email">{row.email} + + """) + + # 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""" + Phoenix.LiveView.JS.navigate("/users/#{row.id}") end} + > + <:col :let={row} label="Name">{row.name} + + """) + + assert html =~ "phx-click" + assert html =~ "cursor-pointer" + refute html =~ ~s| "/users/#{row.id}" end} + > + <:col :let={row} label="Name">{row.name} + + """) + + # 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