From ef702075be9d98b87f57ed7c9ef4df8f9cb0b00b Mon Sep 17 00:00:00 2001 From: sharleenawinja Date: Tue, 28 Apr 2026 13:23:13 +0300 Subject: [PATCH 01/10] Add pagination to credentials and project credentials page --- CHANGELOG.md | 5 + assets/js/app.js | 7 + .../live/components/data_tables.ex | 123 ++++++++++++++- .../credential_index_component.ex | 40 +++++ .../credential_index_component.html.heex | 142 +++++++++++------- .../live/credential_live_test.exs | 109 ++++++++++++++ 6 files changed, 373 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa4d690ff11..a4e2b4fecdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1449,6 +1449,11 @@ Migrations in this release, all in `priv/repo/migrations/`: ### Added +- Pagination (10 items per page) for credentials, keychain credentials, and + OAuth clients tables on the credentials list pages. OAuth clients are now + behind a collapsible section, collapsed by default. Tables are ordered: + credentials first, keychain credentials second, OAuth clients last. + [#4301](https://github.com/OpenFn/lightning/issues/4301) - Add support for sync v2 protocol [#4523](https://github.com/OpenFn/lightning/issues/4523) - Support collections in sandboxes. Collection names are now scoped per project, diff --git a/assets/js/app.js b/assets/js/app.js index e21ddcdbed0..78ee6a2bf2c 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -129,6 +129,13 @@ window.addEventListener('phx:page-loading-stop', () => { topbar.hide(); }); +// Scroll the table container to the top +window.addEventListener('phx:scroll-to-top', e => { + document + .getElementById(e.detail.id) + ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); +}); + // connect if there are any LiveViews on the page liveSocket.connect(); // expose liveSocket on window for web console debug logs and latency simulation: diff --git a/lib/lightning_web/live/components/data_tables.ex b/lib/lightning_web/live/components/data_tables.ex index 71d3e3e4f21..9fe989c6994 100644 --- a/lib/lightning_web/live/components/data_tables.ex +++ b/lib/lightning_web/live/components/data_tables.ex @@ -14,6 +14,8 @@ defmodule LightningWeb.Components.DataTables do attr :title, :string, required: true attr :display_table_title, :boolean, default: true attr :show_owner, :boolean, default: false + attr :page, :map, default: nil + attr :phx_target, :any, default: nil slot :actions, doc: "the slot for showing user actions in the last table column" @@ -115,6 +117,14 @@ defmodule LightningWeb.Components.DataTables do <% end %> + <.pagination_footer + :if={@page && @page.total_pages > 1} + id={"#{@id}-pagination"} + page={@page} + table_name="credentials" + container_id={"#{@id}-table-container"} + phx_target={@phx_target} + /> <% end %> """ @@ -125,6 +135,8 @@ defmodule LightningWeb.Components.DataTables do attr :title, :string, required: true attr :display_table_title, :boolean, default: true attr :show_owner, :boolean, default: false + attr :page, :map, default: nil + attr :phx_target, :any, default: nil slot :actions, doc: "the slot for showing user actions in the last table column" @@ -190,6 +202,14 @@ defmodule LightningWeb.Components.DataTables do <% end %> + <.pagination_footer + :if={@page && @page.total_pages > 1} + id={"#{@id}-pagination"} + page={@page} + table_name="keychain_credentials" + container_id={"#{@id}-table-container"} + phx_target={@phx_target} + /> <% end %> """ @@ -198,7 +218,10 @@ defmodule LightningWeb.Components.DataTables do attr :id, :string, required: true attr :clients, :list, required: true attr :title, :string, required: true + attr :display_table_title, :boolean, default: true attr :show_owner, :boolean, default: false + attr :page, :map, default: nil + attr :phx_target, :any, default: nil slot :actions, doc: "the slot for showing user actions in the last table column" @@ -209,7 +232,7 @@ defmodule LightningWeb.Components.DataTables do def oauth_clients_table(assigns) do ~H"""
-
+
{@title}
<%= if Enum.empty?(@clients) do %> @@ -259,11 +282,109 @@ defmodule LightningWeb.Components.DataTables do <% end %> + <.pagination_footer + :if={@page && @page.total_pages > 1} + id={"#{@id}-pagination"} + page={@page} + table_name="oauth_clients" + container_id={"#{@id}-table-container"} + phx_target={@phx_target} + /> <% end %>
""" end + attr :id, :string, required: true + attr :page, :map, required: true + attr :table_name, :string, required: true + attr :container_id, :string, required: true + attr :phx_target, :any, default: nil + + defp pagination_footer(assigns) do + ~H""" +
+

+ Showing + + {@page.page_number * @page.page_size - @page.page_size + 1} + + – + + {min(@page.page_number * @page.page_size, @page.total_entries)} + + of {@page.total_entries} +

+ +
+ """ + end + defp credential_type(%Credential{schema: "oauth", oauth_client: client}) do if client do String.downcase(client.name) diff --git a/lib/lightning_web/live/credential_live/credential_index_component.ex b/lib/lightning_web/live/credential_live/credential_index_component.ex index 888ba504870..0f3fc7527e4 100644 --- a/lib/lightning_web/live/credential_live/credential_index_component.ex +++ b/lib/lightning_web/live/credential_live/credential_index_component.ex @@ -7,6 +7,8 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do alias Lightning.OauthClients alias Lightning.Policies + @page_size 10 + @impl true def mount(socket) do {:ok, @@ -16,10 +18,14 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do can_create_project_credential: false, credential: nil, credentials: [], + credentials_page: 1, current_user: nil, keychain_credentials: nil, + keychain_credentials_page: 1, oauth_client: nil, oauth_clients: [], + oauth_clients_expanded: false, + oauth_clients_page: 1, project: nil, project_user: nil, projects: [], @@ -88,6 +94,24 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do |> load_credentials()} end + def handle_event( + "change_page", + %{"table" => table, "page" => page, "container_id" => container_id}, + socket + ) do + page = if is_integer(page), do: page, else: String.to_integer(page) + key = :"#{table}_page" + + {:noreply, + socket + |> assign(key, page) + |> push_event("scroll-to-top", %{id: container_id})} + end + + def handle_event("toggle_oauth_clients", _params, socket) do + {:noreply, update(socket, :oauth_clients_expanded, &(!&1))} + end + def handle_event("show_modal", %{"target" => "new_credential"}, socket) do if socket.assigns.can_create_project_credential do project_credentials = @@ -423,6 +447,22 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do |> push_patch(to: socket.assigns.return_to)} end + defp paginate(list, page) when is_list(list) do + total = length(list) + total_pages = max(1, div(total + @page_size - 1, @page_size)) + page_num = max(1, min(page, total_pages)) + slice = Enum.slice(list, (page_num - 1) * @page_size, @page_size) + + page_info = %{ + page_number: page_num, + page_size: @page_size, + total_entries: total, + total_pages: total_pages + } + + {slice, page_info} + end + defp list_credentials(user_or_project) do user_or_project |> Credentials.list_credentials() diff --git a/lib/lightning_web/live/credential_live/credential_index_component.html.heex b/lib/lightning_web/live/credential_live/credential_index_component.html.heex index cf4ea55a0b2..0c202114019 100644 --- a/lib/lightning_web/live/credential_live/credential_index_component.html.heex +++ b/lib/lightning_web/live/credential_live/credential_index_component.html.heex @@ -1,60 +1,13 @@
- - <:empty_state> - <.empty_state - icon="hero-plus-circle" - message="No OAuth clients found." - button_text="Create a new OAuth client" - button_id="open-create-oauth-client-modal-big-buttton" - phx-target={@myself} - phx-click="show_modal" - phx-value-target="new_oauth_client" - button_disabled={false} - /> - - <:actions :let={client}> -
- - <:button> - Actions - - - <:options> - <.link - id={"oauth-client-actions-#{client.id}-edit"} - phx-click="edit_oauth_client" - phx-value-id={client.id} - phx-target={@myself} - > - Edit - - <.link - id={"oauth-client-actions-#{client.id}-delete"} - phx-click="request_oauth_client_deletion" - phx-value-id={client.id} - phx-target={@myself} - > - Delete - - - -
- -
+ <% {creds_slice, creds_page} = paginate(@credentials, @credentials_page) %> <:empty_state> <.empty_state @@ -111,12 +64,17 @@
+ <%= if @keychain_credentials do %> + <% {keychain_slice, keychain_page} = + paginate(@keychain_credentials, @keychain_credentials_page) %> <:empty_state> <.empty_state @@ -163,6 +121,85 @@ <% end %> + +
+ + + <%= if @oauth_clients_expanded do %> + <% {oauth_slice, oauth_page} = + paginate(@oauth_clients, @oauth_clients_page) %> +
+ + <:empty_state> + <.empty_state + icon="hero-plus-circle" + message="No OAuth clients found." + button_text="Create a new OAuth client" + button_id="open-create-oauth-client-modal-big-buttton" + phx-target={@myself} + phx-click="show_modal" + phx-value-target="new_oauth_client" + button_disabled={false} + /> + + <:actions :let={client}> +
+ + <:button> + Actions + + + <:options> + <.link + id={"oauth-client-actions-#{client.id}-edit"} + phx-click="edit_oauth_client" + phx-value-id={client.id} + phx-target={@myself} + > + Edit + + <.link + id={"oauth-client-actions-#{client.id}-delete"} + phx-click="request_oauth_client_deletion" + phx-value-id={client.id} + phx-target={@myself} + > + Delete + + + +
+ +
+
+ <% end %> +
<.live_component :if={@active_modal == :new_oauth_client} @@ -233,6 +270,7 @@ credential_type={nil} credential={@credential} oauth_client={@oauth_client} + oauth_clients={@oauth_clients} project={@project} projects={@projects} current_user={@current_user} diff --git a/test/lightning_web/live/credential_live_test.exs b/test/lightning_web/live/credential_live_test.exs index 90a5ed97253..fbdbe04365d 100644 --- a/test/lightning_web/live/credential_live_test.exs +++ b/test/lightning_web/live/credential_live_test.exs @@ -631,6 +631,115 @@ defmodule LightningWeb.CredentialLiveTest do end end + describe "CredentialIndexComponent pagination and collapsible" do + test "credentials table shows pagination footer and supports page changes when there are more than 10 credentials", + %{conn: conn, user: user} do + for _i <- 1..12, do: insert(:credential, user: user) + + {:ok, index_live, _html} = live(conn, ~p"/credentials", on_error: :raise) + + assert has_element?(index_live, "#credentials-pagination") + + pagination_html = + index_live |> element("#credentials-pagination") |> render() + + assert pagination_html =~ "Showing" + assert pagination_html =~ "12" + + index_live + |> with_target("#credentials-index-component") + |> render_click("change_page", %{ + "table" => "credentials", + "page" => 2, + "container_id" => "credentials-table-container" + }) + + assert has_element?(index_live, "#credentials-pagination") + + pagination_html = + index_live |> element("#credentials-pagination") |> render() + + assert pagination_html =~ "Showing" + assert pagination_html =~ "12" + end + + test "credentials table does not show a pagination footer when there are 10 or fewer credentials", + %{conn: conn, user: user} do + for _i <- 1..5, do: insert(:credential, user: user) + + {:ok, index_live, _html} = live(conn, ~p"/credentials", on_error: :raise) + + refute has_element?(index_live, "#credentials-pagination") + end + + test "OAuth clients section is collapsed by default and toggle button is visible", + %{conn: conn, user: user} do + oauth_client = insert(:oauth_client, user: user) + + {:ok, index_live, html} = live(conn, ~p"/credentials", on_error: :raise) + + assert has_element?(index_live, "#oauth-clients-section") + assert has_element?(index_live, "button[phx-click='toggle_oauth_clients']") + refute html =~ oauth_client.name + end + + test "toggling OAuth clients section shows and then hides the clients table", + %{conn: conn, user: user} do + oauth_client = insert(:oauth_client, user: user) + + {:ok, index_live, _html} = live(conn, ~p"/credentials", on_error: :raise) + + html = + index_live + |> with_target("#credentials-index-component") + |> render_click("toggle_oauth_clients", %{}) + + assert html =~ oauth_client.name + + html = + index_live + |> with_target("#credentials-index-component") + |> render_click("toggle_oauth_clients", %{}) + + refute html =~ oauth_client.name + end + + test "OAuth clients table supports pagination after section is expanded", + %{conn: conn, user: user} do + for _i <- 1..12, do: insert(:oauth_client, user: user) + + {:ok, index_live, _html} = live(conn, ~p"/credentials", on_error: :raise) + + index_live + |> with_target("#credentials-index-component") + |> render_click("toggle_oauth_clients", %{}) + + assert has_element?(index_live, "#oauth-clients-pagination") + + pagination_html = + index_live |> element("#oauth-clients-pagination") |> render() + + assert pagination_html =~ "Showing" + assert pagination_html =~ "12" + + index_live + |> with_target("#credentials-index-component") + |> render_click("change_page", %{ + "table" => "oauth_clients", + "page" => 2, + "container_id" => "oauth-clients-table-container" + }) + + assert has_element?(index_live, "#oauth-clients-pagination") + + pagination_html = + index_live |> element("#oauth-clients-pagination") |> render() + + assert pagination_html =~ "Showing" + assert pagination_html =~ "12" + end + end + describe "Clicking new from the list view" do test "allows the user to define and save a new raw credential", %{ conn: conn, From cf9daf919585983a69b70751fd548411763095cf Mon Sep 17 00:00:00 2001 From: sharleenawinja Date: Wed, 6 May 2026 04:43:02 +0300 Subject: [PATCH 02/10] implement server side pagination for credentials and project credentials --- CHANGELOG.md | 8 +- lib/lightning/credentials.ex | 38 +++++ lib/lightning/oauth_clients.ex | 31 ++++ lib/lightning/scrivener/query_paginater.ex | 2 +- .../live/components/credentials.ex | 6 + .../live/components/data_tables.ex | 126 +--------------- .../credential_index_component.ex | 141 ++++-------------- .../credential_index_component.html.heex | 33 ++-- .../live/credential_live/index.ex | 65 +++++++- .../live/credential_live/index.html.heex | 4 + .../live/project_live/settings.ex | 85 ++++++++++- .../live/project_live/settings.html.heex | 6 + test/lightning/credentials_test.exs | 80 ++++++++++ test/lightning/oauth_clients_test.exs | 51 +++++++ .../live/credential_live_test.exs | 141 +++++++++++++----- 15 files changed, 518 insertions(+), 299 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4e2b4fecdd..d944ede8e09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1450,9 +1450,11 @@ Migrations in this release, all in `priv/repo/migrations/`: ### Added - Pagination (10 items per page) for credentials, keychain credentials, and - OAuth clients tables on the credentials list pages. OAuth clients are now - behind a collapsible section, collapsed by default. Tables are ordered: - credentials first, keychain credentials second, OAuth clients last. + OAuth clients on the credentials page (`/credentials`) and the project + settings credentials tab. Each table paginates independently via URL + parameters. OAuth clients are behind a collapsible section, collapsed by + default. Tables are ordered: credentials first, keychain credentials second + (project settings only), OAuth clients last. [#4301](https://github.com/OpenFn/lightning/issues/4301) - Add support for sync v2 protocol [#4523](https://github.com/OpenFn/lightning/issues/4523) diff --git a/lib/lightning/credentials.ex b/lib/lightning/credentials.ex index 4129c6a9d4a..c2313d95fc2 100644 --- a/lib/lightning/credentials.ex +++ b/lib/lightning/credentials.ex @@ -128,6 +128,32 @@ defmodule Lightning.Credentials do |> Repo.all() end + @spec list_credentials(Project.t(), map()) :: Scrivener.Page.t() + def list_credentials(%Project{} = project, page_params) do + query = + from c in Credential, + join: pc in assoc(c, :project_credentials), + on: pc.project_id == ^project.id, + preload: [ + :user, + :project_credentials, + :projects, + :credential_bodies, + :oauth_client + ], + order_by: [asc: fragment("lower(?)", c.name)], + group_by: c.id + + Repo.paginate(query, page_params) + end + + @spec list_credentials(User.t(), map()) :: Scrivener.Page.t() + def list_credentials(%User{id: user_id}, page_params) do + list_credentials_query(user_id) + |> order_by([c], asc: fragment("lower(?)", c.name)) + |> Repo.paginate(page_params) + end + defp list_credentials_query(user_id) do from(c in Credential, where: c.user_id == ^user_id, @@ -1918,6 +1944,18 @@ defmodule Lightning.Credentials do |> Repo.all() end + def list_keychain_credentials_for_project( + %Project{id: project_id}, + page_params + ) do + from(kc in KeychainCredential, + where: kc.project_id == ^project_id, + order_by: [asc: fragment("lower(?)", kc.name)], + preload: [:project, :created_by, :default_credential] + ) + |> Repo.paginate(page_params) + end + @doc """ Gets a single keychain credential. diff --git a/lib/lightning/oauth_clients.ex b/lib/lightning/oauth_clients.ex index 5562a15d600..d63c1a5e34d 100644 --- a/lib/lightning/oauth_clients.ex +++ b/lib/lightning/oauth_clients.ex @@ -88,6 +88,37 @@ defmodule Lightning.OauthClients do |> Repo.all() end + def list_clients(%Project{} = project, page_params) do + global_clients_subquery = + from(c in OauthClient, + where: c.global == true, + select: c.id + ) + + clients_query = + from(c in OauthClient, + left_join: poc in ProjectOauthClient, + on: poc.oauth_client_id == c.id, + where: + poc.project_id == ^project.id or + c.id in subquery(global_clients_subquery), + preload: [:user, :project_oauth_clients, :projects], + order_by: [asc: fragment("lower(?)", c.name)], + group_by: c.id + ) + + Repo.paginate(clients_query, page_params) + end + + def list_clients(%User{id: user_id}, page_params) do + from(c in OauthClient, + where: c.user_id == ^user_id or c.global, + preload: :projects, + order_by: [asc: fragment("lower(?)", c.name)] + ) + |> Repo.paginate(page_params) + end + @doc """ Retrieves a single OAuth client by its ID, raising an error if not found. diff --git a/lib/lightning/scrivener/query_paginater.ex b/lib/lightning/scrivener/query_paginater.ex index 7051f15492e..48a0d338cae 100644 --- a/lib/lightning/scrivener/query_paginater.ex +++ b/lib/lightning/scrivener/query_paginater.ex @@ -90,7 +90,7 @@ defimpl Scrivener.Paginater, for: Ecto.Query do defp aggregate( %{ group_bys: [ - %Ecto.Query.QueryExpr{ + %{ expr: [ {{:., [], [{:&, [], [source_index]}, field]}, [], []} | _ ] diff --git a/lib/lightning_web/live/components/credentials.ex b/lib/lightning_web/live/components/credentials.ex index 4c5311f6c3b..1f917737aaf 100644 --- a/lib/lightning_web/live/components/credentials.ex +++ b/lib/lightning_web/live/components/credentials.ex @@ -19,6 +19,12 @@ defmodule LightningWeb.Components.Credentials do attr :can_create_project_credential, :any, required: true attr :show_owner_in_tables, :boolean, default: false attr :return_to, :string, required: true + attr :credentials_page, :map, default: nil + attr :keychain_credentials_page, :map, default: nil + attr :oauth_clients_page, :map, default: nil + attr :credentials_url, :any, default: nil + attr :keychain_url, :any, default: nil + attr :oauth_clients_url, :any, default: nil def credentials_index_live_component(assigns) do ~H""" diff --git a/lib/lightning_web/live/components/data_tables.ex b/lib/lightning_web/live/components/data_tables.ex index 9fe989c6994..ab0ccab5a13 100644 --- a/lib/lightning_web/live/components/data_tables.ex +++ b/lib/lightning_web/live/components/data_tables.ex @@ -15,7 +15,7 @@ defmodule LightningWeb.Components.DataTables do attr :display_table_title, :boolean, default: true attr :show_owner, :boolean, default: false attr :page, :map, default: nil - attr :phx_target, :any, default: nil + attr :url, :any, default: nil slot :actions, doc: "the slot for showing user actions in the last table column" @@ -32,7 +32,7 @@ defmodule LightningWeb.Components.DataTables do <%= if Enum.empty?(@credentials) do %> {render_slot(@empty_state)} <% else %> - <.table id={"#{@id}-table"}> + <.table id={"#{@id}-table"} page={@page} url={@url}> <:header> <.tr> <.th>Name @@ -117,14 +117,6 @@ defmodule LightningWeb.Components.DataTables do <% end %> - <.pagination_footer - :if={@page && @page.total_pages > 1} - id={"#{@id}-pagination"} - page={@page} - table_name="credentials" - container_id={"#{@id}-table-container"} - phx_target={@phx_target} - /> <% end %>
""" @@ -136,7 +128,7 @@ defmodule LightningWeb.Components.DataTables do attr :display_table_title, :boolean, default: true attr :show_owner, :boolean, default: false attr :page, :map, default: nil - attr :phx_target, :any, default: nil + attr :url, :any, default: nil slot :actions, doc: "the slot for showing user actions in the last table column" @@ -153,7 +145,7 @@ defmodule LightningWeb.Components.DataTables do <%= if Enum.empty?(@keychain_credentials) do %> {render_slot(@empty_state)} <% else %> - <.table id={"#{@id}-table"}> + <.table id={"#{@id}-table"} page={@page} url={@url}> <:header> <.tr> <.th>Name @@ -202,14 +194,6 @@ defmodule LightningWeb.Components.DataTables do <% end %> - <.pagination_footer - :if={@page && @page.total_pages > 1} - id={"#{@id}-pagination"} - page={@page} - table_name="keychain_credentials" - container_id={"#{@id}-table-container"} - phx_target={@phx_target} - /> <% end %> """ @@ -221,7 +205,7 @@ defmodule LightningWeb.Components.DataTables do attr :display_table_title, :boolean, default: true attr :show_owner, :boolean, default: false attr :page, :map, default: nil - attr :phx_target, :any, default: nil + attr :url, :any, default: nil slot :actions, doc: "the slot for showing user actions in the last table column" @@ -238,7 +222,7 @@ defmodule LightningWeb.Components.DataTables do <%= if Enum.empty?(@clients) do %> {render_slot(@empty_state)} <% else %> - <.table id={"#{@id}-table"}> + <.table id={"#{@id}-table"} page={@page} url={@url}> <:header> <.tr> <.th>Name @@ -282,109 +266,11 @@ defmodule LightningWeb.Components.DataTables do <% end %> - <.pagination_footer - :if={@page && @page.total_pages > 1} - id={"#{@id}-pagination"} - page={@page} - table_name="oauth_clients" - container_id={"#{@id}-table-container"} - phx_target={@phx_target} - /> <% end %> """ end - attr :id, :string, required: true - attr :page, :map, required: true - attr :table_name, :string, required: true - attr :container_id, :string, required: true - attr :phx_target, :any, default: nil - - defp pagination_footer(assigns) do - ~H""" -
-

- Showing - - {@page.page_number * @page.page_size - @page.page_size + 1} - - – - - {min(@page.page_number * @page.page_size, @page.total_entries)} - - of {@page.total_entries} -

- -
- """ - end - defp credential_type(%Credential{schema: "oauth", oauth_client: client}) do if client do String.downcase(client.name) diff --git a/lib/lightning_web/live/credential_live/credential_index_component.ex b/lib/lightning_web/live/credential_live/credential_index_component.ex index 0f3fc7527e4..d1695b5f7fa 100644 --- a/lib/lightning_web/live/credential_live/credential_index_component.ex +++ b/lib/lightning_web/live/credential_live/credential_index_component.ex @@ -7,7 +7,13 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do alias Lightning.OauthClients alias Lightning.Policies - @page_size 10 + @empty_page %{ + entries: [], + page_size: 0, + total_entries: 0, + page_number: 1, + total_pages: 0 + } @impl true def mount(socket) do @@ -17,15 +23,15 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do can_create_keychain_credential: false, can_create_project_credential: false, credential: nil, - credentials: [], - credentials_page: 1, + credentials_page: @empty_page, + credentials_url: nil, current_user: nil, - keychain_credentials: nil, - keychain_credentials_page: 1, + keychain_credentials_page: @empty_page, + keychain_url: nil, oauth_client: nil, - oauth_clients: [], + oauth_clients_page: @empty_page, oauth_clients_expanded: false, - oauth_clients_page: 1, + oauth_clients_url: nil, project: nil, project_user: nil, projects: [], @@ -51,8 +57,7 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do current_user, project ) - }) - |> load_credentials()} + })} end @impl true @@ -60,52 +65,14 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do %{current_user: _, projects: _, return_to: _} = assigns, socket ) do - {:ok, socket |> assign(assigns) |> load_credentials()} - end - - defp load_credentials(socket) do - %{current_user: current_user, project: project} = socket.assigns - - socket - |> assign(%{ - credentials: list_credentials(project || current_user), - oauth_clients: list_clients(project || current_user) - }) - |> then(fn socket -> - if socket.assigns.project do - socket - |> assign( - :keychain_credentials, - Lightning.Credentials.list_keychain_credentials_for_project( - socket.assigns.project - ) - ) - else - socket - end - end) + {:ok, socket |> assign(assigns)} end @impl true def handle_event("close_active_modal", _params, socket) do {:noreply, socket - |> assign(active_modal: nil, credential: nil, oauth_client: nil) - |> load_credentials()} - end - - def handle_event( - "change_page", - %{"table" => table, "page" => page, "container_id" => container_id}, - socket - ) do - page = if is_integer(page), do: page, else: String.to_integer(page) - key = :"#{table}_page" - - {:noreply, - socket - |> assign(key, page) - |> push_event("scroll-to-top", %{id: container_id})} + |> assign(active_modal: nil, credential: nil, oauth_client: nil)} end def handle_event("toggle_oauth_clients", _params, socket) do @@ -182,7 +149,7 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do end def handle_event("edit_oauth_client", %{"id" => client_id}, socket) do - %{oauth_clients: oauth_clients} = socket.assigns + oauth_clients = socket.assigns.oauth_clients_page.entries client = Enum.find(oauth_clients, fn client -> client.id == client_id end) if can_edit_credential(socket.assigns.current_user, client) do @@ -198,7 +165,7 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do end def handle_event("request_oauth_client_deletion", %{"id" => client_id}, socket) do - %{oauth_clients: oauth_clients} = socket.assigns + oauth_clients = socket.assigns.oauth_clients_page.entries client = Enum.find(oauth_clients, fn client -> client.id == client_id end) if can_edit_credential(socket.assigns.current_user, client) do @@ -246,7 +213,7 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do end def handle_event("edit_credential", %{"id" => credential_id}, socket) do - %{credentials: credentials} = socket.assigns + credentials = socket.assigns.credentials_page.entries credential = Enum.find(credentials, fn cred -> cred.id == credential_id end) if can_edit_credential(socket.assigns.current_user, credential) do @@ -266,7 +233,8 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do %{"id" => credential_id}, socket ) do - %{current_user: current_user, credentials: credentials} = socket.assigns + %{current_user: current_user} = socket.assigns + credentials = socket.assigns.credentials_page.entries credential = Enum.find(credentials, &(&1.id == credential_id)) if credential && can_delete_credential(current_user, credential) do @@ -282,7 +250,8 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do %{"id" => credential_id}, socket ) do - %{current_user: current_user, credentials: credentials} = socket.assigns + %{current_user: current_user} = socket.assigns + credentials = socket.assigns.credentials_page.entries credential = Enum.find(credentials, &(&1.id == credential_id)) if credential && can_edit_credential(current_user, credential) do @@ -298,8 +267,9 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do %{"id" => keychain_credential_id}, socket ) do - %{current_user: current_user, keychain_credentials: keychain_credentials} = - socket.assigns + %{current_user: current_user} = socket.assigns + + keychain_credentials = socket.assigns.keychain_credentials_page.entries credential = Enum.find(keychain_credentials, &(&1.id == keychain_credential_id)) @@ -321,7 +291,7 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do %{"id" => keychain_credential_id}, socket ) do - %{keychain_credentials: keychain_credentials} = socket.assigns + keychain_credentials = socket.assigns.keychain_credentials_page.entries credential = Enum.find(keychain_credentials, &(&1.id == keychain_credential_id)) @@ -354,14 +324,12 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do if credential && can_delete_credential(current_user, credential) do Lightning.Credentials.delete_keychain_credential(credential, current_user) |> case do - {:ok, %{id: id}} -> + {:ok, _deleted} -> {:noreply, socket - |> update(:keychain_credentials, fn credentials -> - credentials |> Enum.reject(&(&1.id == id)) - end) |> push_event("close_modal", %{id: modal_id}) - |> put_flash(:info, "Keychain credential deleted")} + |> put_flash(:info, "Keychain credential deleted") + |> push_patch(to: socket.assigns.return_to)} {:error, _} -> {:noreply, @@ -447,55 +415,6 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do |> push_patch(to: socket.assigns.return_to)} end - defp paginate(list, page) when is_list(list) do - total = length(list) - total_pages = max(1, div(total + @page_size - 1, @page_size)) - page_num = max(1, min(page, total_pages)) - slice = Enum.slice(list, (page_num - 1) * @page_size, @page_size) - - page_info = %{ - page_number: page_num, - page_size: @page_size, - total_entries: total, - total_pages: total_pages - } - - {slice, page_info} - end - - defp list_credentials(user_or_project) do - user_or_project - |> Credentials.list_credentials() - |> Enum.map(fn credential -> - project_names = - Map.get(credential, :projects, []) |> Enum.map(fn p -> p.name end) - - environment_names = - credential - |> Map.get(:credential_bodies, []) - |> Enum.map(& &1.name) - - credential - |> Map.put(:project_names, project_names) - |> Map.put(:environment_names, environment_names) - end) - end - - defp list_clients(user_or_project) do - user_or_project - |> OauthClients.list_clients() - |> Enum.map(fn c -> - project_names = - if c.global, - do: ["GLOBAL"], - else: - Map.get(c, :projects, []) - |> Enum.map(fn p -> p.name end) - - Map.put(c, :project_names, project_names) - end) - end - defp delete_action(assigns) do ~H""" <%= if @credential.scheduled_deletion do %> diff --git a/lib/lightning_web/live/credential_live/credential_index_component.html.heex b/lib/lightning_web/live/credential_live/credential_index_component.html.heex index 0c202114019..077dba7917c 100644 --- a/lib/lightning_web/live/credential_live/credential_index_component.html.heex +++ b/lib/lightning_web/live/credential_live/credential_index_component.html.heex @@ -1,13 +1,12 @@
- <% {creds_slice, creds_page} = paginate(@credentials, @credentials_page) %> <:empty_state> <.empty_state @@ -65,16 +64,14 @@ - <%= if @keychain_credentials do %> - <% {keychain_slice, keychain_page} = - paginate(@keychain_credentials, @keychain_credentials_page) %> + <%= if @project do %> <:empty_state> <.empty_state @@ -141,17 +138,15 @@ <%= if @oauth_clients_expanded do %> - <% {oauth_slice, oauth_page} = - paginate(@oauth_clients, @oauth_clients_page) %>
<:empty_state> <.empty_state @@ -241,7 +236,7 @@ credential_type={nil} credential={@credential} oauth_client={nil} - oauth_clients={@oauth_clients} + oauth_clients={@oauth_clients_page.entries} project={@project} projects={@projects} current_user={@current_user} @@ -257,7 +252,7 @@ action={:edit} keychain_credential={@credential} project={@project} - credentials={@credentials} + credentials={@credentials_page.entries} current_user={@current_user} project_user={@project_user} return_to={@return_to} @@ -270,7 +265,7 @@ credential_type={nil} credential={@credential} oauth_client={@oauth_client} - oauth_clients={@oauth_clients} + oauth_clients={@oauth_clients_page.entries} project={@project} projects={@projects} current_user={@current_user} @@ -302,7 +297,7 @@ action={:new} keychain_credential={@credential} project={@project} - credentials={@credentials} + credentials={@credentials_page.entries} current_user={@current_user} project_user={@project_user} return_to={@return_to} diff --git a/lib/lightning_web/live/credential_live/index.ex b/lib/lightning_web/live/credential_live/index.ex index eab6e6b647f..e6cc8784101 100644 --- a/lib/lightning_web/live/credential_live/index.ex +++ b/lib/lightning_web/live/credential_live/index.ex @@ -4,6 +4,9 @@ defmodule LightningWeb.CredentialLive.Index do """ use LightningWeb, :live_view + alias Lightning.Credentials + alias Lightning.OauthClients + on_mount {LightningWeb.Hooks, :assign_projects} @impl true @@ -21,9 +24,39 @@ defmodule LightningWeb.CredentialLive.Index do {:noreply, apply_action(socket, socket.assigns.live_action, params)} end - defp apply_action(socket, :index, _params) do + defp apply_action(socket, :index, params) do + current_user = socket.assigns.current_user + creds_params = %{"page" => params["credentials_page"] || "1"} + oauth_params = %{"page" => params["oauth_clients_page"] || "1"} + + credentials_page = + Credentials.list_credentials(current_user, creds_params) + |> map_credentials() + + oauth_clients_page = + OauthClients.list_clients(current_user, oauth_params) + |> map_oauth_clients() + socket - |> assign(credential: nil) + |> assign(:credential, nil) + |> assign(:credentials_page, credentials_page) + |> assign(:oauth_clients_page, oauth_clients_page) + |> assign( + :credentials_url, + fn opts -> + Routes.credential_index_path(socket, :index, + credentials_page: opts[:page] + ) + end + ) + |> assign( + :oauth_clients_url, + fn opts -> + Routes.credential_index_path(socket, :index, + oauth_clients_page: opts[:page] + ) + end + ) end @doc """ @@ -34,4 +67,32 @@ defmodule LightningWeb.CredentialLive.Index do send_update(mod, opts) {:noreply, socket} end + + defp map_credentials(%Scrivener.Page{} = page) do + %{page | entries: Enum.map(page.entries, &add_credential_display_fields/1)} + end + + defp add_credential_display_fields(credential) do + project_names = Map.get(credential, :projects, []) |> Enum.map(& &1.name) + + environment_names = + credential |> Map.get(:credential_bodies, []) |> Enum.map(& &1.name) + + credential + |> Map.put(:project_names, project_names) + |> Map.put(:environment_names, environment_names) + end + + defp map_oauth_clients(%Scrivener.Page{} = page) do + %{page | entries: Enum.map(page.entries, &add_oauth_client_display_fields/1)} + end + + defp add_oauth_client_display_fields(client) do + project_names = + if client.global, + do: ["GLOBAL"], + else: Map.get(client, :projects, []) |> Enum.map(& &1.name) + + Map.put(client, :project_names, project_names) + end end diff --git a/lib/lightning_web/live/credential_live/index.html.heex b/lib/lightning_web/live/credential_live/index.html.heex index acd3e10e8d1..3fc10ad45d7 100644 --- a/lib/lightning_web/live/credential_live/index.html.heex +++ b/lib/lightning_web/live/credential_live/index.html.heex @@ -33,6 +33,10 @@ can_create_project_credential={true} show_owner_in_tables={false} return_to={~p"/credentials"} + credentials_page={@credentials_page} + oauth_clients_page={@oauth_clients_page} + credentials_url={@credentials_url} + oauth_clients_url={@oauth_clients_url} /> diff --git a/lib/lightning_web/live/project_live/settings.ex b/lib/lightning_web/live/project_live/settings.ex index 6d2c3cc5ffb..fe884b9150b 100644 --- a/lib/lightning_web/live/project_live/settings.ex +++ b/lib/lightning_web/live/project_live/settings.ex @@ -36,6 +36,7 @@ defmodule LightningWeb.ProjectLive.Settings do alias Lightning.Accounts.User alias Lightning.Credentials alias Lightning.Helpers + alias Lightning.OauthClients alias Lightning.Policies.Permissions alias Lightning.Projects alias Lightning.Projects.Project @@ -197,9 +198,16 @@ defmodule LightningWeb.ProjectLive.Settings do active_menu_item: :settings, can_receive_failure_alerts: can_receive_failure_alerts, collaborators_to_invite: [], + collections: collections, + credentials_page: nil, + credentials_url: nil, current_user: socket.assigns.current_user, github_enabled: VersionControl.github_enabled?(), + keychain_credentials_page: nil, + keychain_url: nil, name: project.name, + oauth_clients_page: nil, + oauth_clients_url: nil, parent_project: parent_project, root_project: root_project, project: project, @@ -230,9 +238,10 @@ defmodule LightningWeb.ProjectLive.Settings do |> apply_action(live_action, params)} end - defp apply_action(socket, :index, _params) do - project_users = Projects.get_project_users!(socket.assigns.project.id) - auth_methods = WebhookAuthMethods.list_for_project(socket.assigns.project) + defp apply_action(socket, :index, params) do + project = socket.assigns.project + project_users = Projects.get_project_users!(project.id) + auth_methods = WebhookAuthMethods.list_for_project(project) route_metadata = socket.router @@ -245,6 +254,21 @@ defmodule LightningWeb.ProjectLive.Settings do concurrency_input_component = Map.get(route_metadata, :concurrency_input) usage_caps_input_component = Map.get(route_metadata, :usage_caps_input) + creds_params = %{"page" => params["credentials_page"] || "1"} + keychain_params = %{"page" => params["keychain_page"] || "1"} + oauth_params = %{"page" => params["oauth_clients_page"] || "1"} + + credentials_page = + Credentials.list_credentials(project, creds_params) + |> map_credentials() + + keychain_credentials_page = + Credentials.list_keychain_credentials_for_project(project, keychain_params) + + oauth_clients_page = + OauthClients.list_clients(project, oauth_params) + |> map_oauth_clients() + socket |> assign( page_title: "Project settings", @@ -257,6 +281,33 @@ defmodule LightningWeb.ProjectLive.Settings do active_modal: nil, active_modal_assigns: nil ) + |> assign(:credentials_page, credentials_page) + |> assign(:keychain_credentials_page, keychain_credentials_page) + |> assign(:oauth_clients_page, oauth_clients_page) + |> assign( + :credentials_url, + fn opts -> + Routes.project_settings_path(socket, :index, project.id, + credentials_page: opts[:page] + ) + end + ) + |> assign( + :keychain_url, + fn opts -> + Routes.project_settings_path(socket, :index, project.id, + keychain_page: opts[:page] + ) + end + ) + |> assign( + :oauth_clients_url, + fn opts -> + Routes.project_settings_path(socket, :index, project.id, + oauth_clients_page: opts[:page] + ) + end + ) end defp apply_action(socket, :delete, %{"project_id" => id}) do @@ -926,6 +977,34 @@ defmodule LightningWeb.ProjectLive.Settings do end end + defp map_credentials(%Scrivener.Page{} = page) do + %{page | entries: Enum.map(page.entries, &add_credential_display_fields/1)} + end + + defp add_credential_display_fields(credential) do + project_names = Map.get(credential, :projects, []) |> Enum.map(& &1.name) + + environment_names = + credential |> Map.get(:credential_bodies, []) |> Enum.map(& &1.name) + + credential + |> Map.put(:project_names, project_names) + |> Map.put(:environment_names, environment_names) + end + + defp map_oauth_clients(%Scrivener.Page{} = page) do + %{page | entries: Enum.map(page.entries, &add_oauth_client_display_fields/1)} + end + + defp add_oauth_client_display_fields(client) do + project_names = + if client.global, + do: ["GLOBAL"], + else: Map.get(client, :projects, []) |> Enum.map(& &1.name) + + Map.put(client, :project_names, project_names) + end + attr :can_edit_project, :boolean, required: true attr :project, :any, required: true diff --git a/lib/lightning_web/live/project_live/settings.html.heex b/lib/lightning_web/live/project_live/settings.html.heex index fa4fed9806c..fe43096c162 100644 --- a/lib/lightning_web/live/project_live/settings.html.heex +++ b/lib/lightning_web/live/project_live/settings.html.heex @@ -362,6 +362,12 @@ can_create_project_credential={@can_create_project_credential} show_owner_in_tables={true} return_to={~p"/projects/#{@project.id}/settings#credentials"} + credentials_page={@credentials_page} + keychain_credentials_page={@keychain_credentials_page} + oauth_clients_page={@oauth_clients_page} + credentials_url={@credentials_url} + keychain_url={@keychain_url} + oauth_clients_url={@oauth_clients_url} /> <:panel hash="collections" class="space-y-4"> diff --git a/test/lightning/credentials_test.exs b/test/lightning/credentials_test.exs index d995de4a664..f5fbcd2db94 100644 --- a/test/lightning/credentials_test.exs +++ b/test/lightning/credentials_test.exs @@ -71,6 +71,86 @@ defmodule Lightning.CredentialsTest do ] end + test "list_credentials/2 returns a paginated page for a user" do + user = insert(:user) + for i <- 1..12, do: insert(:credential, user: user, name: "cred-#{i}") + + page = + Credentials.list_credentials(user, %{"page" => "1", "page_size" => "10"}) + + assert %Scrivener.Page{} = page + assert page.total_entries == 12 + assert page.page_size == 10 + assert length(page.entries) == 10 + + page2 = + Credentials.list_credentials(user, %{"page" => "2", "page_size" => "10"}) + + assert length(page2.entries) == 2 + assert page2.page_number == 2 + end + + test "list_credentials/2 returns a paginated page for a project" do + user = insert(:user) + project = insert(:project, project_users: [%{user: user}]) + other_project = insert(:project) + + for i <- 1..12, + do: + insert(:credential, + user: user, + name: "cred-#{i}", + project_credentials: [%{project: project}] + ) + + insert(:credential, + user: user, + name: "other-cred", + project_credentials: [%{project: other_project}] + ) + + page = + Credentials.list_credentials(project, %{ + "page" => "1", + "page_size" => "10" + }) + + assert %Scrivener.Page{} = page + assert page.total_entries == 12 + assert length(page.entries) == 10 + assert Enum.all?(page.entries, fn c -> c.id != "other-cred" end) + end + + test "list_keychain_credentials_for_project/2 returns a paginated page" do + user = insert(:user) + project = insert(:project, project_users: [%{user: user}]) + other_project = insert(:project) + + for _i <- 1..12, + do: insert(:keychain_credential, project: project, created_by: user) + + insert(:keychain_credential, project: other_project, created_by: user) + + page = + Credentials.list_keychain_credentials_for_project(project, %{ + "page" => "1", + "page_size" => "10" + }) + + assert %Scrivener.Page{} = page + assert page.total_entries == 12 + assert length(page.entries) == 10 + assert Enum.all?(page.entries, fn kc -> kc.project_id == project.id end) + + page2 = + Credentials.list_keychain_credentials_for_project(project, %{ + "page" => "2", + "page_size" => "10" + }) + + assert length(page2.entries) == 2 + end + test "get_credential!/1 returns the credential with given id" do user = insert(:user) credential = insert(:credential, user_id: user.id) diff --git a/test/lightning/oauth_clients_test.exs b/test/lightning/oauth_clients_test.exs index 20220a31933..33bba883896 100644 --- a/test/lightning/oauth_clients_test.exs +++ b/test/lightning/oauth_clients_test.exs @@ -110,6 +110,57 @@ defmodule Lightning.OauthClientsTest do end end + describe "list_clients/2" do + test "returns a paginated page of oauth clients for a user" do + user = insert(:user) + for _i <- 1..12, do: insert(:oauth_client, user: user) + + page = + OauthClients.list_clients(user, %{"page" => "1", "page_size" => "10"}) + + assert %Scrivener.Page{} = page + assert page.page_size == 10 + assert page.total_entries >= 12 + assert length(page.entries) == 10 + + page2 = + OauthClients.list_clients(user, %{"page" => "2", "page_size" => "10"}) + + assert page2.page_number == 2 + assert length(page2.entries) > 0 + end + + test "returns a paginated page of oauth clients for a project, including globals" do + user = insert(:user) + project = insert(:project) + other_project = insert(:project) + + project_clients = + for _i <- 1..3, + do: + insert(:oauth_client, + user: user, + project_oauth_clients: [%{project: project}] + ) + + new_global = insert(:oauth_client, global: true, user: user) + + other_client = + insert(:oauth_client, + user: user, + project_oauth_clients: [%{project: other_project}] + ) + + page = + OauthClients.list_clients(project, %{"page" => "1", "page_size" => "100"}) + + assert %Scrivener.Page{} = page + assert client_id_in_list?(new_global, page.entries) + assert Enum.all?(project_clients, &client_id_in_list?(&1, page.entries)) + refute client_id_in_list?(other_client, page.entries) + end + end + describe "create_client/1 with project association" do test "successfully creates a client and associates with a project" do user = insert(:user) diff --git a/test/lightning_web/live/credential_live_test.exs b/test/lightning_web/live/credential_live_test.exs index fbdbe04365d..7b75aac1441 100644 --- a/test/lightning_web/live/credential_live_test.exs +++ b/test/lightning_web/live/credential_live_test.exs @@ -632,44 +632,35 @@ defmodule LightningWeb.CredentialLiveTest do end describe "CredentialIndexComponent pagination and collapsible" do - test "credentials table shows pagination footer and supports page changes when there are more than 10 credentials", + test "credentials table shows pagination bar and supports page navigation when there are more than 10 credentials", %{conn: conn, user: user} do for _i <- 1..12, do: insert(:credential, user: user) {:ok, index_live, _html} = live(conn, ~p"/credentials", on_error: :raise) - assert has_element?(index_live, "#credentials-pagination") + table_html = index_live |> element("#credentials-table") |> render() - pagination_html = - index_live |> element("#credentials-pagination") |> render() + assert table_html =~ "Showing" + assert table_html =~ "12" - assert pagination_html =~ "Showing" - assert pagination_html =~ "12" + render_patch(index_live, ~p"/credentials?credentials_page=2") - index_live - |> with_target("#credentials-index-component") - |> render_click("change_page", %{ - "table" => "credentials", - "page" => 2, - "container_id" => "credentials-table-container" - }) - - assert has_element?(index_live, "#credentials-pagination") + table_html = index_live |> element("#credentials-table") |> render() - pagination_html = - index_live |> element("#credentials-pagination") |> render() - - assert pagination_html =~ "Showing" - assert pagination_html =~ "12" + assert table_html =~ "Showing" + assert table_html =~ "12" end - test "credentials table does not show a pagination footer when there are 10 or fewer credentials", + test "credentials table does not show page navigation links when there are 10 or fewer credentials", %{conn: conn, user: user} do for _i <- 1..5, do: insert(:credential, user: user) {:ok, index_live, _html} = live(conn, ~p"/credentials", on_error: :raise) - refute has_element?(index_live, "#credentials-pagination") + table_html = index_live |> element("#credentials-table") |> render() + + refute table_html =~ "sr-only\">Previous" + refute table_html =~ "sr-only\">Next" end test "OAuth clients section is collapsed by default and toggle button is visible", @@ -704,7 +695,7 @@ defmodule LightningWeb.CredentialLiveTest do refute html =~ oauth_client.name end - test "OAuth clients table supports pagination after section is expanded", + test "OAuth clients table shows pagination bar after section is expanded and supports page navigation", %{conn: conn, user: user} do for _i <- 1..12, do: insert(:oauth_client, user: user) @@ -714,29 +705,99 @@ defmodule LightningWeb.CredentialLiveTest do |> with_target("#credentials-index-component") |> render_click("toggle_oauth_clients", %{}) - assert has_element?(index_live, "#oauth-clients-pagination") + table_html = index_live |> element("#oauth-clients-table") |> render() - pagination_html = - index_live |> element("#oauth-clients-pagination") |> render() + assert table_html =~ "Showing" + assert table_html =~ "12" - assert pagination_html =~ "Showing" - assert pagination_html =~ "12" + render_patch(index_live, ~p"/credentials?oauth_clients_page=2") - index_live - |> with_target("#credentials-index-component") - |> render_click("change_page", %{ - "table" => "oauth_clients", - "page" => 2, - "container_id" => "oauth-clients-table-container" - }) + table_html = index_live |> element("#oauth-clients-table") |> render() + + assert table_html =~ "Showing" + assert table_html =~ "12" + end + + test "credentials pagination works on the project settings page", + %{conn: conn, user: user, project: project} do + for _i <- 1..12, + do: + insert(:credential, + user: user, + project_credentials: [%{project: project}] + ) + + {:ok, view, _html} = + live(conn, ~p"/projects/#{project}/settings#credentials", + on_error: :raise + ) + + table_html = view |> element("#credentials-table") |> render() + + assert table_html =~ "Showing" + assert table_html =~ "12" + + render_patch(view, ~p"/projects/#{project.id}/settings?credentials_page=2") + + table_html = view |> element("#credentials-table") |> render() + + assert table_html =~ "Showing" + assert table_html =~ "12" + end + + test "keychain credentials table shows pagination on project settings when there are more than 10", + %{conn: conn, user: user, project: project} do + for _i <- 1..12, + do: insert(:keychain_credential, project: project, created_by: user) + + {:ok, view, _html} = + live(conn, ~p"/projects/#{project}/settings#credentials", + on_error: :raise + ) + + table_html = view |> element("#keychain-credentials-table") |> render() + + assert table_html =~ "Showing" + assert table_html =~ "12" + + render_patch(view, ~p"/projects/#{project.id}/settings?keychain_page=2") + + table_html = view |> element("#keychain-credentials-table") |> render() + + assert table_html =~ "Showing" + assert table_html =~ "12" + end + + test "keychain credentials section is not shown on the user credentials page", + %{conn: conn} do + {:ok, index_live, _html} = live(conn, ~p"/credentials", on_error: :raise) + + refute has_element?(index_live, "#keychain-credentials-table") + end + + test "keychain credentials section is shown on the project settings page", + %{conn: conn, user: user, project: project} do + insert(:keychain_credential, project: project, created_by: user) + + {:ok, view, _html} = + live(conn, ~p"/projects/#{project}/settings#credentials", + on_error: :raise + ) + + assert has_element?(view, "#keychain-credentials-table") + end + + test "navigating to a page number beyond total pages falls back gracefully", + %{conn: conn, user: user} do + for _i <- 1..5, do: insert(:credential, user: user) + + {:ok, index_live, _html} = live(conn, ~p"/credentials", on_error: :raise) - assert has_element?(index_live, "#oauth-clients-pagination") + render_patch(index_live, ~p"/credentials?credentials_page=999") - pagination_html = - index_live |> element("#oauth-clients-pagination") |> render() + table_html = index_live |> element("#credentials-table") |> render() - assert pagination_html =~ "Showing" - assert pagination_html =~ "12" + assert table_html =~ "Showing" end end From 384592799bb092e3a16517f85aba462dba12c4f2 Mon Sep 17 00:00:00 2001 From: sharleenawinja Date: Wed, 6 May 2026 04:49:20 +0300 Subject: [PATCH 03/10] remove phx:scroll-to-top event --- assets/js/app.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index 78ee6a2bf2c..e21ddcdbed0 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -129,13 +129,6 @@ window.addEventListener('phx:page-loading-stop', () => { topbar.hide(); }); -// Scroll the table container to the top -window.addEventListener('phx:scroll-to-top', e => { - document - .getElementById(e.detail.id) - ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); -}); - // connect if there are any LiveViews on the page liveSocket.connect(); // expose liveSocket on window for web console debug logs and latency simulation: From 2527171d11a43fe2840c153c592fac06b1d7d3b9 Mon Sep 17 00:00:00 2001 From: sharleenawinja Date: Wed, 6 May 2026 05:06:00 +0300 Subject: [PATCH 04/10] fix merge conflicts --- lib/lightning_web/live/project_live/settings.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/lightning_web/live/project_live/settings.ex b/lib/lightning_web/live/project_live/settings.ex index fe884b9150b..d6683cf0440 100644 --- a/lib/lightning_web/live/project_live/settings.ex +++ b/lib/lightning_web/live/project_live/settings.ex @@ -198,7 +198,6 @@ defmodule LightningWeb.ProjectLive.Settings do active_menu_item: :settings, can_receive_failure_alerts: can_receive_failure_alerts, collaborators_to_invite: [], - collections: collections, credentials_page: nil, credentials_url: nil, current_user: socket.assigns.current_user, From 6ed7cba92ee498cc6d9ffd9d51fcbb1a52aa3283 Mon Sep 17 00:00:00 2001 From: sharleenawinja Date: Wed, 6 May 2026 05:39:37 +0300 Subject: [PATCH 05/10] fix oauth clients tests --- .../live/project_live/settings.ex | 24 ++++++++++-- .../live/oauth_clients_live_test.exs | 39 ++++++++++++------- 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/lib/lightning_web/live/project_live/settings.ex b/lib/lightning_web/live/project_live/settings.ex index d6683cf0440..0050b170c78 100644 --- a/lib/lightning_web/live/project_live/settings.ex +++ b/lib/lightning_web/live/project_live/settings.ex @@ -198,14 +198,32 @@ defmodule LightningWeb.ProjectLive.Settings do active_menu_item: :settings, can_receive_failure_alerts: can_receive_failure_alerts, collaborators_to_invite: [], - credentials_page: nil, + credentials_page: %{ + entries: [], + page_size: 0, + total_entries: 0, + page_number: 1, + total_pages: 0 + }, credentials_url: nil, current_user: socket.assigns.current_user, github_enabled: VersionControl.github_enabled?(), - keychain_credentials_page: nil, + keychain_credentials_page: %{ + entries: [], + page_size: 0, + total_entries: 0, + page_number: 1, + total_pages: 0 + }, keychain_url: nil, name: project.name, - oauth_clients_page: nil, + oauth_clients_page: %{ + entries: [], + page_size: 0, + total_entries: 0, + page_number: 1, + total_pages: 0 + }, oauth_clients_url: nil, parent_project: parent_project, root_project: root_project, diff --git a/test/lightning_web/live/oauth_clients_live_test.exs b/test/lightning_web/live/oauth_clients_live_test.exs index 139409c36a9..0fabd9876d9 100644 --- a/test/lightning_web/live/oauth_clients_live_test.exs +++ b/test/lightning_web/live/oauth_clients_live_test.exs @@ -253,33 +253,44 @@ defmodule LightningWeb.OauthClientsLiveTest do {view, added_mandatory_scopes, added_optional_scopes} = perforom_scopes_management_tests(view) - {:ok, _view, html} = + {:ok, redirected_view, html} = view |> form("#oauth-client-form-new", oauth_client: valid_attrs) |> render_submit() |> follow_redirect(conn, url) - assert html =~ valid_attrs.name assert html =~ "Oauth client created successfully" + expanded_html = + redirected_view + |> with_target("#credentials-index-component") + |> render_click("toggle_oauth_clients", %{}) + + assert expanded_html =~ valid_attrs.name + saved_clients_names = Lightning.Repo.all(OauthClient) |> Enum.map(fn client -> client.name end) assert valid_attrs.name in saved_clients_names - assert Lightning.Repo.all(OauthClient) - |> Enum.map(fn client -> - MapSet.subset?( - MapSet.new(String.split(client.mandatory_scopes, ",")), - MapSet.new(added_mandatory_scopes) - ) and - MapSet.subset?( - MapSet.new(String.split(client.optional_scopes, ",")), - MapSet.new(added_optional_scopes) - ) - end) - |> Enum.all?() + new_client = + Lightning.Repo.all( + from c in OauthClient, where: c.name == ^valid_attrs.name + ) + |> List.first() + + assert new_client + + assert MapSet.subset?( + MapSet.new(String.split(new_client.mandatory_scopes, ",")), + MapSet.new(added_mandatory_scopes) + ) + + assert MapSet.subset?( + MapSet.new(String.split(new_client.optional_scopes, ",")), + MapSet.new(added_optional_scopes) + ) end) end From 0a0f15affb0aa07b650a2cec0eb2e20b64ad4d8c Mon Sep 17 00:00:00 2001 From: sharleenawinja Date: Thu, 22 Oct 2026 09:03:01 +0300 Subject: [PATCH 06/10] add virtual fields --- lib/lightning/credentials/credential.ex | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/lightning/credentials/credential.ex b/lib/lightning/credentials/credential.ex index a3574ac4b07..76a181fa5f9 100644 --- a/lib/lightning/credentials/credential.ex +++ b/lib/lightning/credentials/credential.ex @@ -31,6 +31,9 @@ defmodule Lightning.Credentials.Credential do has_many :projects, through: [:project_credentials, :project] has_many :credential_bodies, Lightning.Credentials.CredentialBody + field :project_names, {:array, :string}, virtual: true + field :environment_names, {:array, :string}, virtual: true + timestamps() end From 741b57ed1b537f323929f88288bb5ba46e09c417 Mon Sep 17 00:00:00 2001 From: sharleenawinja Date: Thu, 22 Oct 2026 09:04:25 +0300 Subject: [PATCH 07/10] replace map with struct --- lib/lightning/scrivener/query_paginater.ex | 2 +- .../scrivener/query_paginater_test.exs | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 test/lightning/scrivener/query_paginater_test.exs diff --git a/lib/lightning/scrivener/query_paginater.ex b/lib/lightning/scrivener/query_paginater.ex index 48a0d338cae..8458ef68a3d 100644 --- a/lib/lightning/scrivener/query_paginater.ex +++ b/lib/lightning/scrivener/query_paginater.ex @@ -90,7 +90,7 @@ defimpl Scrivener.Paginater, for: Ecto.Query do defp aggregate( %{ group_bys: [ - %{ + %Ecto.Query.ByExpr{ expr: [ {{:., [], [{:&, [], [source_index]}, field]}, [], []} | _ ] diff --git a/test/lightning/scrivener/query_paginater_test.exs b/test/lightning/scrivener/query_paginater_test.exs new file mode 100644 index 00000000000..bdb408821ac --- /dev/null +++ b/test/lightning/scrivener/query_paginater_test.exs @@ -0,0 +1,37 @@ +defmodule Lightning.Scrivener.QueryPaginaterTest do + use Lightning.DataCase, async: true + + import Ecto.Query + + alias Lightning.Projects.Project + + describe "paginating a grouped query" do + setup do + for _ <- 1..3 do + insert(:project, + project_users: for(_ <- 1..3, do: %{user: build(:user)}) + ) + end + + :ok + end + + test "counts groups, not joined rows" do + page = + from(p in Project, + join: pu in assoc(p, :project_users), + group_by: p.id, + order_by: p.name + ) + |> Repo.paginate(page: 1, page_size: 2) + + assert page.total_entries == 3 + assert page.total_pages == 2 + assert length(page.entries) == 2 + end + end + + test "group_by builds an Ecto.Query.ByExpr" do + assert [%Ecto.Query.ByExpr{}] = from(p in Project, group_by: p.id).group_bys + end +end From 81fff02b50e1e679dfdc1d06775c60100269a6b1 Mon Sep 17 00:00:00 2001 From: sharleenawinja Date: Thu, 22 Oct 2026 09:05:41 +0300 Subject: [PATCH 08/10] remove duplication --- .../credential_index_component.ex | 22 +++---- .../live/credential_live/helpers.ex | 48 ++++++++++++++++ .../live/credential_live/index.ex | 33 +---------- .../live/project_live/settings.ex | 57 ++----------------- test/lightning_web/live/project_live_test.exs | 8 +++ 5 files changed, 72 insertions(+), 96 deletions(-) diff --git a/lib/lightning_web/live/credential_live/credential_index_component.ex b/lib/lightning_web/live/credential_live/credential_index_component.ex index d1695b5f7fa..b077305813b 100644 --- a/lib/lightning_web/live/credential_live/credential_index_component.ex +++ b/lib/lightning_web/live/credential_live/credential_index_component.ex @@ -6,14 +6,7 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do alias Lightning.Credentials.KeychainCredential alias Lightning.OauthClients alias Lightning.Policies - - @empty_page %{ - entries: [], - page_size: 0, - total_entries: 0, - page_number: 1, - total_pages: 0 - } + alias LightningWeb.CredentialLive.Helpers @impl true def mount(socket) do @@ -23,13 +16,13 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do can_create_keychain_credential: false, can_create_project_credential: false, credential: nil, - credentials_page: @empty_page, + credentials_page: Helpers.empty_page(), credentials_url: nil, current_user: nil, - keychain_credentials_page: @empty_page, + keychain_credentials_page: Helpers.empty_page(), keychain_url: nil, oauth_client: nil, - oauth_clients_page: @empty_page, + oauth_clients_page: Helpers.empty_page(), oauth_clients_expanded: false, oauth_clients_url: nil, project: nil, @@ -72,7 +65,8 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do def handle_event("close_active_modal", _params, socket) do {:noreply, socket - |> assign(active_modal: nil, credential: nil, oauth_client: nil)} + |> assign(active_modal: nil, credential: nil, oauth_client: nil) + |> push_patch(to: socket.assigns.return_to)} end def handle_event("toggle_oauth_clients", _params, socket) do @@ -82,9 +76,7 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do def handle_event("show_modal", %{"target" => "new_credential"}, socket) do if socket.assigns.can_create_project_credential do project_credentials = - LightningWeb.CredentialLive.Helpers.default_project_credentials( - socket.assigns.project - ) + Helpers.default_project_credentials(socket.assigns.project) {:noreply, assign(socket, diff --git a/lib/lightning_web/live/credential_live/helpers.ex b/lib/lightning_web/live/credential_live/helpers.ex index 54ed45a4d2c..7b73ca5a3ca 100644 --- a/lib/lightning_web/live/credential_live/helpers.ex +++ b/lib/lightning_web/live/credential_live/helpers.ex @@ -5,6 +5,9 @@ defmodule LightningWeb.CredentialLive.Helpers do of projects associations to credentials and / or oauth clients. """ + alias Lightning.Credentials.Credential + alias Lightning.Credentials.OauthClient + defp build_project_assoc_map( %{id: id, project_id: project_id}, extra_fields \\ %{} @@ -167,4 +170,49 @@ defmodule LightningWeb.CredentialLive.Helpers do |> Phoenix.LiveView.push_navigate(to: socket.assigns.return_to) end end + + @spec empty_page() :: Scrivener.Page.t() + def empty_page do + %Scrivener.Page{ + entries: [], + page_number: 1, + page_size: 0, + total_entries: 0, + total_pages: 0 + } + end + + @doc """ + Fills in the `project_names` and `environment_names` the credentials table + displays, for every credential on the page. + """ + @spec with_credential_display_fields(Scrivener.Page.t()) :: + Scrivener.Page.t() + def with_credential_display_fields(%Scrivener.Page{} = page) do + %{page | entries: Enum.map(page.entries, &credential_display_fields/1)} + end + + @doc """ + Fills in the `project_names` the OAuth clients table displays, labelling + global clients as `GLOBAL`. + """ + @spec with_oauth_client_display_fields(Scrivener.Page.t()) :: + Scrivener.Page.t() + def with_oauth_client_display_fields(%Scrivener.Page{} = page) do + %{page | entries: Enum.map(page.entries, &oauth_client_display_fields/1)} + end + + defp credential_display_fields(%Credential{} = credential) do + %{ + credential + | project_names: Enum.map(credential.projects, & &1.name), + environment_names: Enum.map(credential.credential_bodies, & &1.name) + } + end + + defp oauth_client_display_fields(%OauthClient{global: true} = client), + do: %{client | project_names: ["GLOBAL"]} + + defp oauth_client_display_fields(%OauthClient{} = client), + do: %{client | project_names: Enum.map(client.projects, & &1.name)} end diff --git a/lib/lightning_web/live/credential_live/index.ex b/lib/lightning_web/live/credential_live/index.ex index e6cc8784101..67d69a798d2 100644 --- a/lib/lightning_web/live/credential_live/index.ex +++ b/lib/lightning_web/live/credential_live/index.ex @@ -6,6 +6,7 @@ defmodule LightningWeb.CredentialLive.Index do alias Lightning.Credentials alias Lightning.OauthClients + alias LightningWeb.CredentialLive.Helpers on_mount {LightningWeb.Hooks, :assign_projects} @@ -31,11 +32,11 @@ defmodule LightningWeb.CredentialLive.Index do credentials_page = Credentials.list_credentials(current_user, creds_params) - |> map_credentials() + |> Helpers.with_credential_display_fields() oauth_clients_page = OauthClients.list_clients(current_user, oauth_params) - |> map_oauth_clients() + |> Helpers.with_oauth_client_display_fields() socket |> assign(:credential, nil) @@ -67,32 +68,4 @@ defmodule LightningWeb.CredentialLive.Index do send_update(mod, opts) {:noreply, socket} end - - defp map_credentials(%Scrivener.Page{} = page) do - %{page | entries: Enum.map(page.entries, &add_credential_display_fields/1)} - end - - defp add_credential_display_fields(credential) do - project_names = Map.get(credential, :projects, []) |> Enum.map(& &1.name) - - environment_names = - credential |> Map.get(:credential_bodies, []) |> Enum.map(& &1.name) - - credential - |> Map.put(:project_names, project_names) - |> Map.put(:environment_names, environment_names) - end - - defp map_oauth_clients(%Scrivener.Page{} = page) do - %{page | entries: Enum.map(page.entries, &add_oauth_client_display_fields/1)} - end - - defp add_oauth_client_display_fields(client) do - project_names = - if client.global, - do: ["GLOBAL"], - else: Map.get(client, :projects, []) |> Enum.map(& &1.name) - - Map.put(client, :project_names, project_names) - end end diff --git a/lib/lightning_web/live/project_live/settings.ex b/lib/lightning_web/live/project_live/settings.ex index 0050b170c78..83498cccf23 100644 --- a/lib/lightning_web/live/project_live/settings.ex +++ b/lib/lightning_web/live/project_live/settings.ex @@ -46,6 +46,7 @@ defmodule LightningWeb.ProjectLive.Settings do alias Lightning.VersionControl alias Lightning.WebhookAuthMethods alias LightningWeb.Components.GithubComponents + alias LightningWeb.CredentialLive.Helpers, as: CredentialHelpers require Logger @@ -198,32 +199,14 @@ defmodule LightningWeb.ProjectLive.Settings do active_menu_item: :settings, can_receive_failure_alerts: can_receive_failure_alerts, collaborators_to_invite: [], - credentials_page: %{ - entries: [], - page_size: 0, - total_entries: 0, - page_number: 1, - total_pages: 0 - }, + credentials_page: CredentialHelpers.empty_page(), credentials_url: nil, current_user: socket.assigns.current_user, github_enabled: VersionControl.github_enabled?(), - keychain_credentials_page: %{ - entries: [], - page_size: 0, - total_entries: 0, - page_number: 1, - total_pages: 0 - }, + keychain_credentials_page: CredentialHelpers.empty_page(), keychain_url: nil, name: project.name, - oauth_clients_page: %{ - entries: [], - page_size: 0, - total_entries: 0, - page_number: 1, - total_pages: 0 - }, + oauth_clients_page: CredentialHelpers.empty_page(), oauth_clients_url: nil, parent_project: parent_project, root_project: root_project, @@ -277,14 +260,14 @@ defmodule LightningWeb.ProjectLive.Settings do credentials_page = Credentials.list_credentials(project, creds_params) - |> map_credentials() + |> CredentialHelpers.with_credential_display_fields() keychain_credentials_page = Credentials.list_keychain_credentials_for_project(project, keychain_params) oauth_clients_page = OauthClients.list_clients(project, oauth_params) - |> map_oauth_clients() + |> CredentialHelpers.with_oauth_client_display_fields() socket |> assign( @@ -994,34 +977,6 @@ defmodule LightningWeb.ProjectLive.Settings do end end - defp map_credentials(%Scrivener.Page{} = page) do - %{page | entries: Enum.map(page.entries, &add_credential_display_fields/1)} - end - - defp add_credential_display_fields(credential) do - project_names = Map.get(credential, :projects, []) |> Enum.map(& &1.name) - - environment_names = - credential |> Map.get(:credential_bodies, []) |> Enum.map(& &1.name) - - credential - |> Map.put(:project_names, project_names) - |> Map.put(:environment_names, environment_names) - end - - defp map_oauth_clients(%Scrivener.Page{} = page) do - %{page | entries: Enum.map(page.entries, &add_oauth_client_display_fields/1)} - end - - defp add_oauth_client_display_fields(client) do - project_names = - if client.global, - do: ["GLOBAL"], - else: Map.get(client, :projects, []) |> Enum.map(& &1.name) - - Map.put(client, :project_names, project_names) - end - attr :can_edit_project, :boolean, required: true attr :project, :any, required: true diff --git a/test/lightning_web/live/project_live_test.exs b/test/lightning_web/live/project_live_test.exs index d7a3cfb3504..0afc16b526a 100644 --- a/test/lightning_web/live/project_live_test.exs +++ b/test/lightning_web/live/project_live_test.exs @@ -1628,6 +1628,14 @@ defmodule LightningWeb.ProjectLiveTest do assert updated.name == "Updated Name" assert updated.path == "$.updated_path" + + view + |> with_target("#credentials-index-component") + |> render_click("close_active_modal", %{}) + + assert_patch(view) + assert has_element?(view, "#keychain-credentials-table", "Updated Name") + refute has_element?(view, "#keychain-credentials-table", "Original Name") end test "shows validation errors when creating invalid keychain credential", From 812be37fc0542b70354a27119dc75bebcb85821e Mon Sep 17 00:00:00 2001 From: sharleenawinja Date: Thu, 22 Oct 2026 09:33:54 +0300 Subject: [PATCH 09/10] resolve dialyzer errors --- lib/lightning_web/live/credential_live/helpers.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/lightning_web/live/credential_live/helpers.ex b/lib/lightning_web/live/credential_live/helpers.ex index 7b73ca5a3ca..2d9ca5f3299 100644 --- a/lib/lightning_web/live/credential_live/helpers.ex +++ b/lib/lightning_web/live/credential_live/helpers.ex @@ -178,7 +178,7 @@ defmodule LightningWeb.CredentialLive.Helpers do page_number: 1, page_size: 0, total_entries: 0, - total_pages: 0 + total_pages: 1 } end From aff2af6e1e66994ddee5dba0e1a4c879372bc878 Mon Sep 17 00:00:00 2001 From: sharleenawinja Date: Thu, 22 Oct 2026 09:54:09 +0300 Subject: [PATCH 10/10] fix tests --- .../live/credential_live/credential_index_component.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/lightning_web/live/credential_live/credential_index_component.ex b/lib/lightning_web/live/credential_live/credential_index_component.ex index b077305813b..5344a31bab0 100644 --- a/lib/lightning_web/live/credential_live/credential_index_component.ex +++ b/lib/lightning_web/live/credential_live/credential_index_component.ex @@ -339,7 +339,8 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do %{"id" => credential_id}, socket ) do - %{current_user: current_user, credentials: credentials} = socket.assigns + %{current_user: current_user} = socket.assigns + credentials = socket.assigns.credentials_page.entries credential = Enum.find(credentials, &(&1.id == credential_id)) if credential && can_delete_credential(current_user, credential) do