From 20719e37da355a7df05b2e3de8d3e7cfc4ac06ca Mon Sep 17 00:00:00 2001 From: sharleenawinja Date: Tue, 28 Apr 2026 13:23:13 +0300 Subject: [PATCH 1/5] 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 378505f141a..c5d7d6c7a10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,11 @@ and this project adheres to ### 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 777b2b6be8e..5bb186d6631 100644 --- a/lib/lightning_web/live/components/data_tables.ex +++ b/lib/lightning_web/live/components/data_tables.ex @@ -13,6 +13,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" @@ -114,6 +116,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 %> """ @@ -124,6 +134,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" @@ -189,6 +201,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 %> """ @@ -197,7 +217,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" @@ -208,7 +231,7 @@ defmodule LightningWeb.Components.DataTables do def oauth_clients_table(assigns) do ~H"""
-
+
{@title}
<%= if Enum.empty?(@clients) do %> @@ -258,11 +281,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 2005360b7db..aa3c0d4eb58 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 = @@ -403,6 +427,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 74815de39b0..b151e10fd90 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} @@ -231,6 +268,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 b1151cebcd5..8d01b449eae 100644 --- a/test/lightning_web/live/credential_live_test.exs +++ b/test/lightning_web/live/credential_live_test.exs @@ -508,6 +508,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 850abc251396aae467e45d9a3a80edc363138251 Mon Sep 17 00:00:00 2001 From: sharleenawinja Date: Wed, 6 May 2026 04:43:02 +0300 Subject: [PATCH 2/5] 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 c5d7d6c7a10..7b92883a8a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,9 +58,11 @@ and this project adheres to ### 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 ed237b5d75c..718da7f2af6 100644 --- a/lib/lightning/credentials.ex +++ b/lib/lightning/credentials.ex @@ -124,6 +124,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, @@ -1778,6 +1804,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 b7a6b528c58..29cee7a99a9 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 ec1f0c2a226..02fb15078f1 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 5bb186d6631..9bd5465dadc 100644 --- a/lib/lightning_web/live/components/data_tables.ex +++ b/lib/lightning_web/live/components/data_tables.ex @@ -14,7 +14,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" @@ -31,7 +31,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 @@ -116,14 +116,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 %>
""" @@ -135,7 +127,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" @@ -152,7 +144,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 @@ -201,14 +193,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 %> """ @@ -220,7 +204,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" @@ -237,7 +221,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 @@ -281,109 +265,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 aa3c0d4eb58..872452ad1a3 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: project, project_user: project_user} ) - }) - |> 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 @@ -188,7 +155,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 @@ -204,7 +171,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 @@ -240,7 +207,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 @@ -260,7 +227,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 @@ -276,7 +244,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 @@ -292,8 +261,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)) @@ -315,7 +285,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)) @@ -348,14 +318,12 @@ defmodule LightningWeb.CredentialLive.CredentialIndexComponent do if credential && can_delete_credential(current_user, credential) do Lightning.Credentials.delete_keychain_credential(credential) |> 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, @@ -427,55 +395,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 b151e10fd90..e2620d66021 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 @@ -239,7 +234,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} @@ -255,7 +250,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} @@ -268,7 +263,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} @@ -299,7 +294,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 33c51e3fa8e..d72dd04b4fa 100644 --- a/lib/lightning_web/live/project_live/settings.ex +++ b/lib/lightning_web/live/project_live/settings.ex @@ -10,6 +10,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 @@ -128,9 +129,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, @@ -161,9 +169,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) concurrency_input_component = socket.router @@ -174,6 +183,21 @@ defmodule LightningWeb.ProjectLive.Settings do ) |> Map.get(:concurrency_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", @@ -185,6 +209,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 @@ -797,6 +848,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 296e9e9a698..872c56737ab 100644 --- a/lib/lightning_web/live/project_live/settings.html.heex +++ b/lib/lightning_web/live/project_live/settings.html.heex @@ -353,6 +353,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 e58ad8a5127..b49ecc76993 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 075ab54f8c2..f486655cec7 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 8d01b449eae..3d1c2593df4 100644 --- a/test/lightning_web/live/credential_live_test.exs +++ b/test/lightning_web/live/credential_live_test.exs @@ -509,44 +509,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", @@ -581,7 +572,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) @@ -591,29 +582,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 487fd5d36e0a1c77061d6bf885e8f1b3fa05ba78 Mon Sep 17 00:00:00 2001 From: sharleenawinja Date: Wed, 6 May 2026 04:49:20 +0300 Subject: [PATCH 3/5] 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 a4ab2f038bbf38b8d78be325e136641be032eb74 Mon Sep 17 00:00:00 2001 From: sharleenawinja Date: Wed, 6 May 2026 05:06:00 +0300 Subject: [PATCH 4/5] 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 d72dd04b4fa..0c020f02c69 100644 --- a/lib/lightning_web/live/project_live/settings.ex +++ b/lib/lightning_web/live/project_live/settings.ex @@ -129,7 +129,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 fe59089536f307ee94389fe6cfad8698aebc356b Mon Sep 17 00:00:00 2001 From: sharleenawinja Date: Wed, 6 May 2026 05:39:37 +0300 Subject: [PATCH 5/5] 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 0c020f02c69..17beb79d327 100644 --- a/lib/lightning_web/live/project_live/settings.ex +++ b/lib/lightning_web/live/project_live/settings.ex @@ -129,14 +129,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 8a1f7dcede6..4e734a77d7c 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 end